@xl0/pi-lovely-agents 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/extensions/lovely-agents/agent.ts +1374 -0
- package/extensions/lovely-agents/bash.ts +599 -0
- package/extensions/lovely-agents/child-session.ts +296 -0
- package/extensions/lovely-agents/config.ts +221 -0
- package/extensions/lovely-agents/coordinator.ts +506 -0
- package/extensions/lovely-agents/definitions.ts +380 -0
- package/extensions/lovely-agents/index.ts +400 -0
- package/extensions/lovely-agents/lifecycle.ts +251 -0
- package/extensions/lovely-agents/management.ts +638 -0
- package/extensions/lovely-agents/notifications.ts +220 -0
- package/extensions/lovely-agents/provider-limits.ts +13 -0
- package/extensions/lovely-agents/rendering.ts +90 -0
- package/extensions/lovely-agents/state.ts +1179 -0
- package/extensions/lovely-agents/task-panel.ts +192 -0
- package/extensions/lovely-agents/tools.ts +635 -0
- package/extensions/lovely-agents/updates.ts +45 -0
- package/node_modules/@xl0/pi-lovely-config/CHANGELOG.md +79 -0
- package/node_modules/@xl0/pi-lovely-config/LICENSE +21 -0
- package/node_modules/@xl0/pi-lovely-config/README.md +200 -0
- package/node_modules/@xl0/pi-lovely-config/package.json +59 -0
- package/node_modules/@xl0/pi-lovely-config/src/config.ts +399 -0
- package/node_modules/@xl0/pi-lovely-config/src/index.ts +3 -0
- package/node_modules/@xl0/pi-lovely-config/src/ui.ts +786 -0
- package/package.json +68 -0
- package/skills/agent/SKILL.md +21 -0
- package/skills/agent-creator/SKILL.md +35 -0
|
@@ -0,0 +1,1374 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto"
|
|
2
|
+
import { rm } from "node:fs/promises"
|
|
3
|
+
import type { AgentSessionEvent, ExtensionAPI, ExtensionContext, ScopedModel } from "@earendil-works/pi-coding-agent"
|
|
4
|
+
import { Container, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
|
|
5
|
+
import { Type } from "typebox"
|
|
6
|
+
import {
|
|
7
|
+
type ChildSessionHandle,
|
|
8
|
+
childPromptOptions,
|
|
9
|
+
createChildSession,
|
|
10
|
+
resolveChildSessionSelection,
|
|
11
|
+
resolveChildToolPolicy
|
|
12
|
+
} from "./child-session.js"
|
|
13
|
+
import type { AgentsConfig } from "./config.js"
|
|
14
|
+
import { resolveConfiguredModels } from "./config.js"
|
|
15
|
+
import {
|
|
16
|
+
type AgentReservation,
|
|
17
|
+
getAgentCoordinator,
|
|
18
|
+
type ModelTuple,
|
|
19
|
+
type ResidentAgent,
|
|
20
|
+
type ResidentInputOptions,
|
|
21
|
+
type ResidentInputResult
|
|
22
|
+
} from "./coordinator.js"
|
|
23
|
+
import { type AgentDefinition, discoverAgentDefinitions } from "./definitions.js"
|
|
24
|
+
import { discardTask, stopOwnedTaskTree, stopTask } from "./lifecycle.js"
|
|
25
|
+
import { appendTaskNotification, deliverTaskNotifications, prepareTaskNotification } from "./notifications.js"
|
|
26
|
+
import { isProviderLimitError } from "./provider-limits.js"
|
|
27
|
+
import { renderExpandableResult } from "./rendering.js"
|
|
28
|
+
import {
|
|
29
|
+
type AgentTaskMetadata,
|
|
30
|
+
acquireParentLease,
|
|
31
|
+
appendHistoryLog,
|
|
32
|
+
archivedTaskStoragePaths,
|
|
33
|
+
displayWorkspacePath,
|
|
34
|
+
ensureParentStorage,
|
|
35
|
+
initializeRetainedLogs,
|
|
36
|
+
MAX_AGENT_INPUT_BYTES,
|
|
37
|
+
MAX_AGENT_LABEL_BYTES,
|
|
38
|
+
MAX_QUEUED_FOLLOWUPS,
|
|
39
|
+
mutateTaskMetadata,
|
|
40
|
+
readRetainedOutput,
|
|
41
|
+
readTaskMetadata,
|
|
42
|
+
reserveTaskStorage,
|
|
43
|
+
retainedOutputSnapshot,
|
|
44
|
+
retainedPaths,
|
|
45
|
+
TASK_METADATA_VERSION,
|
|
46
|
+
TASK_REFERENCE_PATTERN,
|
|
47
|
+
type TaskMetadata,
|
|
48
|
+
type TaskStoragePaths,
|
|
49
|
+
taskStoragePaths,
|
|
50
|
+
writeTaskMetadata,
|
|
51
|
+
writeTaskProgress
|
|
52
|
+
} from "./state.js"
|
|
53
|
+
import { loadTaskList } from "./tools.js"
|
|
54
|
+
|
|
55
|
+
const ThinkingLevel = Type.Union([
|
|
56
|
+
Type.Literal("off"),
|
|
57
|
+
Type.Literal("minimal"),
|
|
58
|
+
Type.Literal("low"),
|
|
59
|
+
Type.Literal("medium"),
|
|
60
|
+
Type.Literal("high"),
|
|
61
|
+
Type.Literal("xhigh"),
|
|
62
|
+
Type.Literal("max")
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
type RecoveryMode = "automatic" | "manual" | "stop"
|
|
66
|
+
type SuspendedRuntime = {
|
|
67
|
+
wakeProviderRecovery(mode: RecoveryMode): boolean
|
|
68
|
+
}
|
|
69
|
+
const SUSPENDED_RUNTIMES_SYMBOL = Symbol.for("@xl0/pi-lovely-agents/suspended-runtimes/v1")
|
|
70
|
+
|
|
71
|
+
/** Opens one tuple and wakes every process-resident run suspended on it. */
|
|
72
|
+
export function recoverProviderTuple(tuple: ModelTuple): number {
|
|
73
|
+
getAgentCoordinator().openTuple(tuple)
|
|
74
|
+
const runtimes = suspendedRuntimes().get(modelTupleKey(tuple))
|
|
75
|
+
if (!runtimes) return 0
|
|
76
|
+
let recovered = 0
|
|
77
|
+
for (const runtime of [...runtimes]) {
|
|
78
|
+
if (runtime.wakeProviderRecovery("automatic")) recovered++
|
|
79
|
+
}
|
|
80
|
+
return recovered
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type AgentCreationResult = {
|
|
84
|
+
id: string
|
|
85
|
+
label: string
|
|
86
|
+
definition: string
|
|
87
|
+
state: TaskMetadata["state"]
|
|
88
|
+
latestOutcome: TaskMetadata["latestOutcome"]
|
|
89
|
+
model: string
|
|
90
|
+
thinking: AgentTaskMetadata["thinking"]
|
|
91
|
+
depth: number
|
|
92
|
+
allowAgents: boolean
|
|
93
|
+
detached: boolean
|
|
94
|
+
queuedFollowUps: number
|
|
95
|
+
output: Awaited<ReturnType<typeof readRetainedOutput>>
|
|
96
|
+
tasks: Awaited<ReturnType<typeof loadTaskList>>["details"]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type TaskInputResult = {
|
|
100
|
+
id: string
|
|
101
|
+
requestedDelivery: "followup" | "steer" | undefined
|
|
102
|
+
effectiveDelivery: "followup" | "steer" | "stdin"
|
|
103
|
+
queuePosition: number | null
|
|
104
|
+
state: TaskMetadata["state"]
|
|
105
|
+
latestOutcome: TaskMetadata["latestOutcome"]
|
|
106
|
+
queuedFollowUps: number
|
|
107
|
+
output?: Awaited<ReturnType<typeof readRetainedOutput>>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type AgentToolOptions = {
|
|
111
|
+
getConfig: () => AgentsConfig
|
|
112
|
+
createChild?: typeof createChildSession
|
|
113
|
+
getAgentDir?: () => string
|
|
114
|
+
canDelegate?: () => boolean
|
|
115
|
+
/** Input-schema visibility from allowed producers plus retained owned kinds; not execution gates. */
|
|
116
|
+
agentInputEnabled?: () => boolean
|
|
117
|
+
bashInputEnabled?: () => boolean
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function registerAgentTool(pi: ExtensionAPI, options: AgentToolOptions): void {
|
|
121
|
+
const background = options.getConfig().backgroundAgents
|
|
122
|
+
pi.registerTool({
|
|
123
|
+
name: "agent",
|
|
124
|
+
label: "Agent",
|
|
125
|
+
description: background
|
|
126
|
+
? "Create a durable Lovely Agent session and start its initial run. waitMs permits background detachment."
|
|
127
|
+
: "Create a durable Lovely Agent session and wait for its terminal result. Cancellation stops the accepted work.",
|
|
128
|
+
promptSnippet: "Create or delegate work to a durable agent",
|
|
129
|
+
promptGuidelines: ["Call agent_roster before creating an agent and use task tools for existing work."],
|
|
130
|
+
parameters: Type.Object(
|
|
131
|
+
{
|
|
132
|
+
definition: Type.String({ minLength: 1, description: "Agent Definition name" }),
|
|
133
|
+
label: Type.String({ minLength: 1, description: "Short task label" }),
|
|
134
|
+
prompt: Type.String({ minLength: 1, description: "Initial task prompt" }),
|
|
135
|
+
...(background ? { waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 600_000 })) } : {}),
|
|
136
|
+
model: Type.Optional(
|
|
137
|
+
Type.String({ minLength: 1, description: "Configured provider/model ID or an enabled alias from agent_roster" })
|
|
138
|
+
),
|
|
139
|
+
thinking: Type.Optional(ThinkingLevel),
|
|
140
|
+
...(options.canDelegate?.() === false
|
|
141
|
+
? {}
|
|
142
|
+
: { allowAgents: Type.Optional(Type.Boolean({ description: "Allow this child to create descendants" })) })
|
|
143
|
+
},
|
|
144
|
+
{ additionalProperties: false }
|
|
145
|
+
),
|
|
146
|
+
renderCall(args, theme, context) {
|
|
147
|
+
return {
|
|
148
|
+
render(width) {
|
|
149
|
+
// The result supplies the ID after renderCall; read shared state at paint time.
|
|
150
|
+
const header = `${theme.fg("toolTitle", theme.bold("agent"))}${args.definition ? ` ${theme.fg("muted", args.definition)}` : ""}${args.label ? ` ${theme.fg("dim", `label=${JSON.stringify(args.label)}`)}` : ""}`
|
|
151
|
+
const suffix = context.state.taskRef ? `${theme.fg("muted", " -> ")}${theme.fg("accent", context.state.taskRef)}` : ""
|
|
152
|
+
if (context.expanded) {
|
|
153
|
+
const lines = new Text(header + suffix, 0, 0).render(width)
|
|
154
|
+
if (args.prompt) lines.push(...new Text(`${theme.fg("muted", "Input: ")}${args.prompt}`, 0, 0).render(width))
|
|
155
|
+
return lines
|
|
156
|
+
}
|
|
157
|
+
const available = Math.max(0, width - visibleWidth(suffix))
|
|
158
|
+
const promptWidth = available - visibleWidth(header) - visibleWidth(' prompt=""')
|
|
159
|
+
const preview =
|
|
160
|
+
args.prompt && promptWidth > 0
|
|
161
|
+
? `${header}${theme.fg("muted", ' prompt="')}${truncateToWidth(JSON.stringify(args.prompt.replace(/\s+/g, " ").trim()).slice(1, -1), promptWidth)}${theme.fg("muted", '"')}`
|
|
162
|
+
: truncateToWidth(header, available)
|
|
163
|
+
// Pi's truncation emits full resets; preserve the surrounding tool background.
|
|
164
|
+
return [truncateToWidth(preview + suffix, width).replaceAll("\x1b[0m", "\x1b[22;39m")]
|
|
165
|
+
},
|
|
166
|
+
invalidate() {}
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
renderResult(result, { expanded }, theme, context) {
|
|
170
|
+
const details = result.details as Partial<AgentCreationResult> | undefined
|
|
171
|
+
if (typeof details?.id === "string") context.state.taskRef = details.id
|
|
172
|
+
const output = new Container()
|
|
173
|
+
if (expanded || context.isError) {
|
|
174
|
+
output.addChild(new Text(theme.fg("muted", "── Result ──"), 0, 0))
|
|
175
|
+
output.addChild(renderExpandableResult(result, true, theme))
|
|
176
|
+
}
|
|
177
|
+
return output
|
|
178
|
+
},
|
|
179
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
180
|
+
if (signal?.aborted) throw abortError(signal)
|
|
181
|
+
const config = options.getConfig()
|
|
182
|
+
const background = config.backgroundAgents
|
|
183
|
+
if (!background && "waitMs" in params) throw new Error("waitMs requires backgroundAgents")
|
|
184
|
+
if (
|
|
185
|
+
"waitMs" in params &&
|
|
186
|
+
(typeof params.waitMs !== "number" || !Number.isInteger(params.waitMs) || params.waitMs < 0 || params.waitMs > 600_000)
|
|
187
|
+
) {
|
|
188
|
+
throw new Error("waitMs must be an integer from 0 to 600000")
|
|
189
|
+
}
|
|
190
|
+
if ("allowAgents" in params && typeof params.allowAgents !== "boolean") throw new Error("allowAgents must be boolean")
|
|
191
|
+
const allowAgents = params.allowAgents === true
|
|
192
|
+
if (allowAgents && options.canDelegate?.() === false) throw new Error("This child cannot delegate at the current maximum depth")
|
|
193
|
+
validateInput(params.label, "label", MAX_AGENT_LABEL_BYTES)
|
|
194
|
+
validateInput(params.prompt, "prompt", MAX_AGENT_INPUT_BYTES)
|
|
195
|
+
const parentSessionId = ctx.sessionManager.getSessionId()
|
|
196
|
+
const coordinator = getAgentCoordinator(config.maxConcurrency)
|
|
197
|
+
const parentContext = coordinator.getSessionContext(parentSessionId)
|
|
198
|
+
if (parentContext && !parentContext.allowAgents) throw new Error("This parent agent is not allowed to delegate")
|
|
199
|
+
const parentDepth = parentContext?.depth ?? 0
|
|
200
|
+
resolveChildToolPolicy({
|
|
201
|
+
parentDepth,
|
|
202
|
+
maximumDepth: config.maxDepth,
|
|
203
|
+
allowAgents
|
|
204
|
+
})
|
|
205
|
+
const discovered = discoverAgentDefinitions({
|
|
206
|
+
cwd: ctx.cwd,
|
|
207
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
208
|
+
toolNames: pi.getAllTools().map(tool => tool.name),
|
|
209
|
+
models: ctx.modelRegistry.getAll(),
|
|
210
|
+
...(options.getAgentDir ? { agentDir: options.getAgentDir() } : {})
|
|
211
|
+
})
|
|
212
|
+
const definition = discovered.definitions.find(candidate => candidate.name === params.definition)
|
|
213
|
+
if (!definition) throw new Error(`Unknown or invalid Agent Definition: ${params.definition}`)
|
|
214
|
+
const configuredModels = await resolveConfiguredModels(config, ctx)
|
|
215
|
+
const selection = resolveChildSessionSelection({
|
|
216
|
+
...(params.model ? { callModel: params.model } : {}),
|
|
217
|
+
...(params.thinking ? { callThinking: params.thinking } : {}),
|
|
218
|
+
definition,
|
|
219
|
+
configuredModels: configuredModels.models,
|
|
220
|
+
aliases: configuredModels.aliases,
|
|
221
|
+
availableModels: ctx.modelRegistry.getAvailable(),
|
|
222
|
+
parentModel: ctx.model,
|
|
223
|
+
parentThinking: ctx.thinkingLevel ?? "medium"
|
|
224
|
+
})
|
|
225
|
+
await acquireParentLease(ctx.cwd, parentSessionId)
|
|
226
|
+
const paths = await reserveTaskStorage(await ensureParentStorage(ctx.cwd, parentSessionId))
|
|
227
|
+
let child: ChildSessionHandle | undefined
|
|
228
|
+
let accepted = false
|
|
229
|
+
try {
|
|
230
|
+
await initializeRetainedLogs(paths)
|
|
231
|
+
child = await (options.createChild ?? createChildSession)({
|
|
232
|
+
cwd: ctx.cwd,
|
|
233
|
+
paths,
|
|
234
|
+
definition,
|
|
235
|
+
selection,
|
|
236
|
+
scopedModels: configuredModels.models,
|
|
237
|
+
parentDepth,
|
|
238
|
+
maximumDepth: config.maxDepth,
|
|
239
|
+
allowAgents,
|
|
240
|
+
projectTrusted: ctx.isProjectTrusted()
|
|
241
|
+
})
|
|
242
|
+
if (signal?.aborted) throw abortError(signal)
|
|
243
|
+
const acceptedAt = Date.now()
|
|
244
|
+
const runId = createRunId()
|
|
245
|
+
const acceptanceOrder = coordinator.nextAcceptanceOrder()
|
|
246
|
+
const metadata: TaskMetadata = {
|
|
247
|
+
version: TASK_METADATA_VERSION,
|
|
248
|
+
kind: "agent",
|
|
249
|
+
taskRef: paths.taskRef,
|
|
250
|
+
parentSessionId,
|
|
251
|
+
childSessionId: child.session.sessionId,
|
|
252
|
+
definitionName: definition.name,
|
|
253
|
+
label: params.label.trim(),
|
|
254
|
+
model: { provider: child.session.model?.provider ?? selection.model.provider, id: child.session.model?.id ?? selection.model.id },
|
|
255
|
+
thinking: child.session.thinkingLevel,
|
|
256
|
+
depth: child.depth,
|
|
257
|
+
allowAgents: child.allowAgents,
|
|
258
|
+
sessionConfig: {
|
|
259
|
+
systemPrompt: definition.systemPrompt,
|
|
260
|
+
tools: definition.tools ?? null,
|
|
261
|
+
excludeAgentsMd: definition.excludeAgentsMd ?? false,
|
|
262
|
+
scopedModels: configuredModels.models.map(choice => ({
|
|
263
|
+
provider: choice.model.provider,
|
|
264
|
+
id: choice.model.id,
|
|
265
|
+
...(choice.thinkingLevel ? { thinkingLevel: choice.thinkingLevel } : {})
|
|
266
|
+
}))
|
|
267
|
+
},
|
|
268
|
+
state: "queued",
|
|
269
|
+
latestOutcome: null,
|
|
270
|
+
latestReply: null,
|
|
271
|
+
lastActivity: { at: acceptedAt, action: "queued" },
|
|
272
|
+
lastRunSequence: 1,
|
|
273
|
+
activeRun: {
|
|
274
|
+
id: runId,
|
|
275
|
+
sequence: 1,
|
|
276
|
+
acceptanceOrder,
|
|
277
|
+
background,
|
|
278
|
+
kind: "initial",
|
|
279
|
+
state: "queued",
|
|
280
|
+
input: params.prompt,
|
|
281
|
+
acceptedAt
|
|
282
|
+
},
|
|
283
|
+
queuedFollowUps: [],
|
|
284
|
+
notifications: [],
|
|
285
|
+
discardedAt: null,
|
|
286
|
+
createdAt: acceptedAt,
|
|
287
|
+
updatedAt: acceptedAt
|
|
288
|
+
}
|
|
289
|
+
await appendHistoryLog(paths, { type: "run-start", sequence: 1, kind: "initial", timestamp: acceptedAt })
|
|
290
|
+
await appendHistoryLog(paths, { type: "input", delivery: "initial", timestamp: acceptedAt, content: params.prompt })
|
|
291
|
+
await writeTaskMetadata(paths, metadata)
|
|
292
|
+
accepted = true
|
|
293
|
+
|
|
294
|
+
const runtime = new AgentRuntime(paths, child, metadata, config.expandPromptTemplates)
|
|
295
|
+
child = undefined
|
|
296
|
+
runtime.start()
|
|
297
|
+
if (!background) {
|
|
298
|
+
const completed = await runtime.waitForRun(runId, signal)
|
|
299
|
+
const tasks = (await loadTaskList(ctx.cwd, parentSessionId)).details
|
|
300
|
+
return buildAgentCreationToolResult(completed, false, retainedOutputSnapshot(paths, completed), tasks)
|
|
301
|
+
}
|
|
302
|
+
const waitMs = (params.waitMs as number | undefined) ?? config.waitMs
|
|
303
|
+
const wait = () => runtime.wait(waitMs, signal)
|
|
304
|
+
const detached = waitMs === 0 ? await wait() : await coordinator.withLentPermit(wait, signal)
|
|
305
|
+
const loaded = await readTaskMetadata(paths)
|
|
306
|
+
if (loaded.status !== "ok") throw new Error(`Could not read accepted task ${paths.taskRef}`)
|
|
307
|
+
const output = await readRetainedOutput(paths)
|
|
308
|
+
const tasks = (await loadTaskList(ctx.cwd, parentSessionId)).details
|
|
309
|
+
return buildAgentCreationToolResult(loaded.metadata, detached, output, tasks)
|
|
310
|
+
} finally {
|
|
311
|
+
child?.dispose()
|
|
312
|
+
if (!accepted) await rm(paths.taskDirectory, { recursive: true, force: true })
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function registerTaskInputTool(pi: ExtensionAPI, options: AgentToolOptions): void {
|
|
319
|
+
const background = options.getConfig().backgroundAgents
|
|
320
|
+
// Foreground agents remain a producer by default; the parent knows allowed producers and owned kinds.
|
|
321
|
+
const agentInput = options.agentInputEnabled?.() ?? true
|
|
322
|
+
const bashInput = options.bashInputEnabled?.() ?? options.getConfig().backgroundBash
|
|
323
|
+
pi.registerTool({
|
|
324
|
+
name: "task_input",
|
|
325
|
+
label: "Task Input",
|
|
326
|
+
description:
|
|
327
|
+
[
|
|
328
|
+
...(agentInput
|
|
329
|
+
? [
|
|
330
|
+
background
|
|
331
|
+
? "Send a durable Follow-up or live Steer to an owned Lovely Agent task."
|
|
332
|
+
: "Run a Follow-up on an idle owned task and wait for its terminal reply, or send a run-scoped live Steer. Busy tasks reject Follow-ups."
|
|
333
|
+
]
|
|
334
|
+
: []),
|
|
335
|
+
...(bashInput ? ["Write literal stdin to a running owned Bash task; eof closes stdin. Empty content requires eof:true."] : [])
|
|
336
|
+
].join(" ") || "Send input to an owned task.",
|
|
337
|
+
promptSnippet: agentInput
|
|
338
|
+
? `Send Follow-up work or a live Steer${bashInput ? ", or literal Bash stdin" : ""} to a durable task`
|
|
339
|
+
: bashInput
|
|
340
|
+
? "Write literal stdin to a running Bash task"
|
|
341
|
+
: "Send input to an owned task",
|
|
342
|
+
promptGuidelines: [
|
|
343
|
+
...(agentInput ? ["Use Follow-up for later work; use Steer only to redirect a currently running agent."] : []),
|
|
344
|
+
...(bashInput ? ["For Bash, omit delivery and write literal stdin; eof closes stdin without restarting the command."] : [])
|
|
345
|
+
],
|
|
346
|
+
parameters: Type.Object(
|
|
347
|
+
{
|
|
348
|
+
id: Type.String({ pattern: TASK_REFERENCE_PATTERN.source, description: "Task Reference" }),
|
|
349
|
+
content: Type.String({ minLength: bashInput ? 0 : 1, description: "Input text" }),
|
|
350
|
+
...(agentInput ? { delivery: Type.Optional(Type.Union([Type.Literal("followup"), Type.Literal("steer")])) } : {}),
|
|
351
|
+
...(bashInput ? { eof: Type.Optional(Type.Boolean({ description: "Close Bash stdin after writing content" })) } : {})
|
|
352
|
+
},
|
|
353
|
+
{ additionalProperties: false }
|
|
354
|
+
),
|
|
355
|
+
renderCall(args, theme) {
|
|
356
|
+
const delivery = args.delivery ?? (args.id?.startsWith("b_") ? "stdin" : "followup")
|
|
357
|
+
const content = args.content ?? ""
|
|
358
|
+
const preview = content.length > 60 ? `${content.slice(0, 57)}...` : content
|
|
359
|
+
return new Text(
|
|
360
|
+
`${theme.fg("toolTitle", theme.bold("task_input"))}${args.id ? ` ${theme.fg("muted", args.id)}` : ""} ${theme.fg("dim", `${delivery}${preview ? ` ${JSON.stringify(preview)}` : ""}`)}`,
|
|
361
|
+
0,
|
|
362
|
+
0
|
|
363
|
+
)
|
|
364
|
+
},
|
|
365
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
366
|
+
if ("waitMs" in params) throw new Error("task_input does not accept waitMs")
|
|
367
|
+
if (params.delivery !== undefined && params.delivery !== "followup" && params.delivery !== "steer") {
|
|
368
|
+
throw new Error("delivery must be followup or steer")
|
|
369
|
+
}
|
|
370
|
+
if (params.eof !== undefined && typeof params.eof !== "boolean") throw new Error("eof must be boolean")
|
|
371
|
+
const result = await sendTaskInput(
|
|
372
|
+
ctx,
|
|
373
|
+
options,
|
|
374
|
+
params.id,
|
|
375
|
+
params.content,
|
|
376
|
+
params.delivery,
|
|
377
|
+
signal,
|
|
378
|
+
params.eof !== undefined ? { eof: params.eof } : {}
|
|
379
|
+
)
|
|
380
|
+
return {
|
|
381
|
+
content: [
|
|
382
|
+
{
|
|
383
|
+
type: "text",
|
|
384
|
+
text:
|
|
385
|
+
result.effectiveDelivery === "stdin"
|
|
386
|
+
? `${result.id}: stdin delivered${params.eof ? " (EOF)" : ""}`
|
|
387
|
+
: result.effectiveDelivery === "steer"
|
|
388
|
+
? `${result.id}: steer delivered (${result.state}; ${result.queuedFollowUps} Follow-ups queued)`
|
|
389
|
+
: result.output
|
|
390
|
+
? `${result.id}: followup ${result.latestOutcome}\n${result.output.text}`
|
|
391
|
+
: `${result.id}: followup accepted (position ${result.queuePosition}; ${result.state}; ${result.queuedFollowUps} queued)`
|
|
392
|
+
}
|
|
393
|
+
],
|
|
394
|
+
details: result
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
for (const action of ["stop", "discard"] as const) {
|
|
400
|
+
pi.registerTool({
|
|
401
|
+
name: `task_${action}`,
|
|
402
|
+
label: action === "stop" ? "Task Stop" : "Task Discard",
|
|
403
|
+
description:
|
|
404
|
+
action === "stop"
|
|
405
|
+
? "Stop active or queued work for an owned Agent or Bash task while preserving its files."
|
|
406
|
+
: "Stop and permanently discard an owned task subtree, archiving its files. Unsupported metadata versions can also be archived.",
|
|
407
|
+
promptSnippet: action === "stop" ? "Stop work for a durable task" : "Discard a durable task",
|
|
408
|
+
promptGuidelines:
|
|
409
|
+
action === "discard"
|
|
410
|
+
? [
|
|
411
|
+
"After consuming an agent's results, use task_discard if no Follow-up is expected. Keep reusable specialists; do not discard agents whose results are still needed."
|
|
412
|
+
]
|
|
413
|
+
: [],
|
|
414
|
+
parameters: Type.Object(
|
|
415
|
+
{ id: Type.String({ pattern: TASK_REFERENCE_PATTERN.source, description: "Task Reference" }) },
|
|
416
|
+
{ additionalProperties: false }
|
|
417
|
+
),
|
|
418
|
+
renderCall(args, theme) {
|
|
419
|
+
return new Text(`${theme.fg("toolTitle", theme.bold(`task_${action}`))}${args.id ? ` ${theme.fg("muted", args.id)}` : ""}`, 0, 0)
|
|
420
|
+
},
|
|
421
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
422
|
+
const details = await controlTaskLifecycle(ctx, params.id, action)
|
|
423
|
+
return {
|
|
424
|
+
content: [
|
|
425
|
+
{
|
|
426
|
+
type: "text",
|
|
427
|
+
text:
|
|
428
|
+
action === "discard"
|
|
429
|
+
? `${details.id}: discarded (files archived at ${details.archiveDirectory})`
|
|
430
|
+
: `${details.id}: stop complete (${details.state}; outcome ${details.latestOutcome ?? "none"}; ${details.queuedFollowUps} queued)`
|
|
431
|
+
}
|
|
432
|
+
],
|
|
433
|
+
details
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function sendTaskInput(
|
|
441
|
+
ctx: ExtensionContext,
|
|
442
|
+
options: AgentToolOptions,
|
|
443
|
+
id: string,
|
|
444
|
+
content: string,
|
|
445
|
+
requestedDelivery: "followup" | "steer" | undefined = undefined,
|
|
446
|
+
signal?: AbortSignal,
|
|
447
|
+
inputOptions: { eof?: boolean } = {}
|
|
448
|
+
): Promise<TaskInputResult> {
|
|
449
|
+
if (signal?.aborted) throw abortError(signal)
|
|
450
|
+
if (typeof content !== "string") throw new Error("content must be a string")
|
|
451
|
+
if (inputOptions.eof !== undefined && typeof inputOptions.eof !== "boolean") throw new Error("eof must be boolean")
|
|
452
|
+
if (requestedDelivery !== undefined && requestedDelivery !== "followup" && requestedDelivery !== "steer") {
|
|
453
|
+
throw new Error("delivery must be followup or steer")
|
|
454
|
+
}
|
|
455
|
+
if (!id.startsWith("b_")) validateInput(content, "content", MAX_AGENT_INPUT_BYTES)
|
|
456
|
+
const lease = await acquireParentLease(ctx.cwd, ctx.sessionManager.getSessionId())
|
|
457
|
+
const paths = taskStoragePaths(lease.paths, id)
|
|
458
|
+
const loaded = await readTaskMetadata(paths)
|
|
459
|
+
if (loaded.status === "missing") throw new Error(`Unknown Task Reference: ${id}`)
|
|
460
|
+
if (loaded.status === "invalid") throw new Error(loaded.diagnostic.message)
|
|
461
|
+
if (loaded.metadata.discardedAt !== null) throw new Error(`Task ${id} has been discarded`)
|
|
462
|
+
if (loaded.metadata.kind === "bash") {
|
|
463
|
+
if (requestedDelivery !== undefined) throw new Error("Bash input does not accept delivery; omit it to write stdin")
|
|
464
|
+
if (Buffer.byteLength(content, "utf8") > MAX_AGENT_INPUT_BYTES)
|
|
465
|
+
throw new Error(`content must be at most ${MAX_AGENT_INPUT_BYTES} UTF-8 bytes`)
|
|
466
|
+
if (content.length === 0 && !inputOptions.eof) throw new Error("Empty Bash input requires eof")
|
|
467
|
+
const resident = getAgentCoordinator().getResident(paths.taskDirectory)
|
|
468
|
+
if (loaded.metadata.state !== "running" || !resident?.input)
|
|
469
|
+
throw new Error(`Bash task ${id} requires a live running resident for stdin`)
|
|
470
|
+
const accepted = await resident.input(content, "stdin", { ...inputOptions, ...(signal ? { signal } : {}) })
|
|
471
|
+
const current = await readTaskMetadata(paths)
|
|
472
|
+
if (current.status !== "ok") throw new Error(`Could not read accepted task ${id}`)
|
|
473
|
+
return {
|
|
474
|
+
id,
|
|
475
|
+
requestedDelivery,
|
|
476
|
+
effectiveDelivery: accepted.delivery,
|
|
477
|
+
queuePosition: accepted.queuePosition,
|
|
478
|
+
state: current.metadata.state,
|
|
479
|
+
latestOutcome: current.metadata.latestOutcome,
|
|
480
|
+
queuedFollowUps: accepted.queuedFollowUps
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (inputOptions.eof !== undefined) throw new Error("eof is only supported for Bash stdin")
|
|
484
|
+
const delivery = requestedDelivery ?? "followup"
|
|
485
|
+
let accepted: ResidentInputResult | undefined
|
|
486
|
+
while (!accepted) {
|
|
487
|
+
if (signal?.aborted) throw abortError(signal)
|
|
488
|
+
const runtime = await controllableRuntime(ctx, paths, options)
|
|
489
|
+
try {
|
|
490
|
+
accepted = await runtime.input(content, delivery, {
|
|
491
|
+
background: options.getConfig().backgroundAgents,
|
|
492
|
+
...(signal ? { signal } : {})
|
|
493
|
+
})
|
|
494
|
+
} catch (error) {
|
|
495
|
+
if (!isRuntimeClosingError(error)) throw error
|
|
496
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const current = await readTaskMetadata(paths)
|
|
500
|
+
if (current.status !== "ok") throw new Error(`Could not read accepted task ${id}`)
|
|
501
|
+
return {
|
|
502
|
+
id,
|
|
503
|
+
requestedDelivery: delivery,
|
|
504
|
+
effectiveDelivery: accepted.delivery,
|
|
505
|
+
queuePosition: accepted.queuePosition,
|
|
506
|
+
state: accepted.completed?.state ?? current.metadata.state,
|
|
507
|
+
latestOutcome: accepted.completed?.latestOutcome ?? current.metadata.latestOutcome,
|
|
508
|
+
queuedFollowUps: accepted.completed?.queuedFollowUps.length ?? current.metadata.queuedFollowUps.length,
|
|
509
|
+
...(accepted.completed ? { output: retainedOutputSnapshot(paths, accepted.completed) } : {})
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export async function controlTaskLifecycle(ctx: ExtensionContext, id: string, action: "stop" | "discard") {
|
|
514
|
+
const lease = await acquireParentLease(ctx.cwd, ctx.sessionManager.getSessionId())
|
|
515
|
+
const paths = taskStoragePaths(lease.paths, id)
|
|
516
|
+
if (action === "discard") {
|
|
517
|
+
await discardTask(paths)
|
|
518
|
+
const archived = archivedTaskStoragePaths(paths)
|
|
519
|
+
return {
|
|
520
|
+
id,
|
|
521
|
+
action,
|
|
522
|
+
state: "archived",
|
|
523
|
+
latestOutcome: null,
|
|
524
|
+
discarded: true,
|
|
525
|
+
queuedFollowUps: 0,
|
|
526
|
+
archiveDirectory: displayWorkspacePath(ctx.cwd, archived.taskDirectory)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
let loaded = await readTaskMetadata(paths)
|
|
530
|
+
if (loaded.status === "missing") throw new Error(`Unknown Task Reference: ${id}`)
|
|
531
|
+
if (loaded.status === "invalid") throw new Error(loaded.diagnostic.message)
|
|
532
|
+
if (loaded.metadata.discardedAt !== null) throw new Error(`Task ${id} has been discarded`)
|
|
533
|
+
await stopTask(paths)
|
|
534
|
+
if (loaded.metadata.kind === "agent") await stopOwnedTaskTree(ctx.cwd, loaded.metadata.childSessionId)
|
|
535
|
+
loaded = await readTaskMetadata(paths)
|
|
536
|
+
if (loaded.status !== "ok") throw new Error(`Could not read task ${id}`)
|
|
537
|
+
const metadata = loaded.metadata
|
|
538
|
+
return {
|
|
539
|
+
id: metadata.taskRef,
|
|
540
|
+
action,
|
|
541
|
+
state: metadata.state,
|
|
542
|
+
latestOutcome: metadata.latestOutcome,
|
|
543
|
+
discarded: metadata.discardedAt !== null,
|
|
544
|
+
queuedFollowUps: metadata.queuedFollowUps.length,
|
|
545
|
+
archiveDirectory: undefined,
|
|
546
|
+
paths: retainedPaths(paths)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
class AgentRuntime implements ResidentAgent {
|
|
551
|
+
readonly #paths: TaskStoragePaths
|
|
552
|
+
readonly #child: ChildSessionHandle
|
|
553
|
+
readonly #expandPromptTemplates: boolean
|
|
554
|
+
readonly #tuple: ModelTuple
|
|
555
|
+
readonly #scheduleAbort = new AbortController()
|
|
556
|
+
readonly #initialRunId: string | undefined
|
|
557
|
+
readonly #initialCompletion = deferred<void>()
|
|
558
|
+
readonly #runtimeCompletion = deferred<void>()
|
|
559
|
+
readonly #toolArguments = new Map<string, { name: string; arguments: string }>()
|
|
560
|
+
readonly #pendingSteers: Array<{ content: string }> = []
|
|
561
|
+
readonly #reservations = new Map<string, AgentReservation>()
|
|
562
|
+
readonly #completions = new Map<string, ReturnType<typeof deferred<TaskMetadata | undefined>>>()
|
|
563
|
+
#unbindResident: (() => void) | undefined
|
|
564
|
+
#unbindSuspended: (() => void) | undefined
|
|
565
|
+
#unsubscribe: (() => void) | undefined
|
|
566
|
+
#eventWrites: Promise<void> = Promise.resolve()
|
|
567
|
+
#eventWriteFailed = false
|
|
568
|
+
#recordingRunId: string | undefined
|
|
569
|
+
#pendingProgress: ({ runId: string } & Partial<Pick<TaskMetadata, "latestReply" | "lastActivity">>) | undefined
|
|
570
|
+
#lastHeartbeatAt = 0
|
|
571
|
+
#progressWriteQueued = false
|
|
572
|
+
#started = false
|
|
573
|
+
#stopRequested = false
|
|
574
|
+
#accepting = true
|
|
575
|
+
#disposed = false
|
|
576
|
+
#awaitingPrimaryInput = false
|
|
577
|
+
#lastAssistantOutcome: NonNullable<TaskMetadata["latestOutcome"]> | undefined
|
|
578
|
+
#providerLimitReached = false
|
|
579
|
+
#recoveryWait: (ReturnType<typeof deferred<RecoveryMode>> & { mode: RecoveryMode | undefined }) | undefined
|
|
580
|
+
#recoveryMode: Exclude<RecoveryMode, "stop"> | undefined
|
|
581
|
+
|
|
582
|
+
constructor(paths: TaskStoragePaths, child: ChildSessionHandle, metadata: AgentTaskMetadata, expandPromptTemplates: boolean) {
|
|
583
|
+
this.#paths = paths
|
|
584
|
+
this.#child = child
|
|
585
|
+
this.#initialRunId = metadata.activeRun?.kind === "initial" ? metadata.activeRun.id : undefined
|
|
586
|
+
this.#expandPromptTemplates = expandPromptTemplates
|
|
587
|
+
this.#tuple = { provider: metadata.model.provider, model: metadata.model.id }
|
|
588
|
+
if (metadata.activeRun && !metadata.activeRun.background) this.#completions.set(metadata.activeRun.id, deferred())
|
|
589
|
+
this.#unbindResident = getAgentCoordinator().bindResident(paths.taskDirectory, this)
|
|
590
|
+
this.#unsubscribe = child.session.subscribe(event => this.recordEvent(event))
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
start(): void {
|
|
594
|
+
if (this.#started) return
|
|
595
|
+
this.#started = true
|
|
596
|
+
void this.run().catch(() => {})
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Run-specific synchronous wait; cancellation owns the accepted run and its descendants. */
|
|
600
|
+
async waitForRun(runId: string, signal?: AbortSignal): Promise<TaskMetadata> {
|
|
601
|
+
const completion = this.#completions.get(runId)
|
|
602
|
+
if (!completion) throw new Error(`Missing completion for run ${runId}`)
|
|
603
|
+
return getAgentCoordinator().withLentPermit(async () => {
|
|
604
|
+
const aborted = deferred<void>()
|
|
605
|
+
const onAbort = () => aborted.resolve(undefined)
|
|
606
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
607
|
+
if (signal?.aborted) onAbort()
|
|
608
|
+
try {
|
|
609
|
+
const result = await Promise.race([completion.promise, aborted.promise.then(() => undefined)])
|
|
610
|
+
if (signal?.aborted) {
|
|
611
|
+
await this.stop()
|
|
612
|
+
await stopOwnedTaskTree(this.#paths.workspace, this.#child.session.sessionId)
|
|
613
|
+
throw abortError(signal)
|
|
614
|
+
}
|
|
615
|
+
if (!result) throw new Error(`Agent run ${runId} ended without a retained terminal result`)
|
|
616
|
+
return result
|
|
617
|
+
} finally {
|
|
618
|
+
signal?.removeEventListener("abort", onAbort)
|
|
619
|
+
this.#completions.delete(runId)
|
|
620
|
+
}
|
|
621
|
+
}, signal)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async wait(waitMs: number, signal?: AbortSignal): Promise<boolean> {
|
|
625
|
+
if (waitMs === 0) {
|
|
626
|
+
await this.detach()
|
|
627
|
+
return true
|
|
628
|
+
}
|
|
629
|
+
if (signal?.aborted) await this.stop()
|
|
630
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
631
|
+
let onAbort: (() => void) | undefined
|
|
632
|
+
const timeout = new Promise<"timeout">(resolve => {
|
|
633
|
+
timer = setTimeout(() => resolve("timeout"), waitMs)
|
|
634
|
+
timer.unref()
|
|
635
|
+
})
|
|
636
|
+
const aborted = signal
|
|
637
|
+
? new Promise<"aborted">(resolve => {
|
|
638
|
+
onAbort = () => resolve("aborted")
|
|
639
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
640
|
+
})
|
|
641
|
+
: undefined
|
|
642
|
+
const result = await Promise.race([
|
|
643
|
+
this.#initialCompletion.promise.then(() => "completed" as const),
|
|
644
|
+
timeout,
|
|
645
|
+
...(aborted ? [aborted] : [])
|
|
646
|
+
])
|
|
647
|
+
if (timer) clearTimeout(timer)
|
|
648
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort)
|
|
649
|
+
if (result === "aborted") {
|
|
650
|
+
await this.stop()
|
|
651
|
+
return false
|
|
652
|
+
}
|
|
653
|
+
if (result === "timeout") {
|
|
654
|
+
await this.detach()
|
|
655
|
+
return true
|
|
656
|
+
}
|
|
657
|
+
return false
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async stop(): Promise<void> {
|
|
661
|
+
if (this.#stopRequested) return this.#runtimeCompletion.promise
|
|
662
|
+
this.#stopRequested = true
|
|
663
|
+
this.#accepting = false
|
|
664
|
+
this.wakeProviderRecovery("stop")
|
|
665
|
+
this.#scheduleAbort.abort(new Error("Agent run stopped"))
|
|
666
|
+
await this.#child.session.abort()
|
|
667
|
+
const loaded = await readTaskMetadata(this.#paths)
|
|
668
|
+
if (loaded.status === "ok" && loaded.metadata.activeRun) {
|
|
669
|
+
await this.settle(loaded.metadata.activeRun, "stopped", true)
|
|
670
|
+
} else if (loaded.status === "ok" && loaded.metadata.queuedFollowUps.length > 0) {
|
|
671
|
+
await mutateTaskMetadata(this.#paths, metadata => ({
|
|
672
|
+
...metadata,
|
|
673
|
+
queuedFollowUps: [],
|
|
674
|
+
updatedAt: Date.now()
|
|
675
|
+
}))
|
|
676
|
+
}
|
|
677
|
+
if (!this.#started) {
|
|
678
|
+
this.dispose()
|
|
679
|
+
this.#runtimeCompletion.resolve(undefined)
|
|
680
|
+
this.#initialCompletion.resolve(undefined)
|
|
681
|
+
}
|
|
682
|
+
await this.#runtimeCompletion.promise
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
dispose(): void {
|
|
686
|
+
if (this.#disposed) return
|
|
687
|
+
this.#disposed = true
|
|
688
|
+
this.#unsubscribe?.()
|
|
689
|
+
this.#unsubscribe = undefined
|
|
690
|
+
this.#unbindResident?.()
|
|
691
|
+
this.#unbindResident = undefined
|
|
692
|
+
this.#unbindSuspended?.()
|
|
693
|
+
this.#unbindSuspended = undefined
|
|
694
|
+
this.#child.dispose()
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async input(content: string, delivery: "followup" | "steer" | "stdin", options: ResidentInputOptions = {}): Promise<ResidentInputResult> {
|
|
698
|
+
if (delivery === "stdin" || options.eof !== undefined) throw new Error("Agent tasks do not accept stdin or eof")
|
|
699
|
+
if (!this.#accepting || this.#disposed) throw new RuntimeClosingError()
|
|
700
|
+
let result: ResidentInputResult | undefined
|
|
701
|
+
let acceptedRun: NonNullable<TaskMetadata["activeRun"]> | undefined
|
|
702
|
+
try {
|
|
703
|
+
await mutateTaskMetadata(this.#paths, async metadata => {
|
|
704
|
+
if (options.signal?.aborted) throw abortError(options.signal)
|
|
705
|
+
if (!this.#accepting || this.#disposed) throw new RuntimeClosingError()
|
|
706
|
+
if (metadata.discardedAt !== null) throw new Error(`Task ${metadata.taskRef} has been discarded`)
|
|
707
|
+
if (delivery === "steer" && metadata.state === "running" && metadata.activeRun && this.#child.session.isStreaming) {
|
|
708
|
+
const pending = { content }
|
|
709
|
+
this.#pendingSteers.push(pending)
|
|
710
|
+
try {
|
|
711
|
+
// prompt() can yield in input hooks and start a new run after streaming ends.
|
|
712
|
+
if (this.#expandPromptTemplates) await this.#child.session.steer(content)
|
|
713
|
+
else this.#child.session.agent.steer({ role: "user", content, timestamp: Date.now() })
|
|
714
|
+
} catch (error) {
|
|
715
|
+
const index = this.#pendingSteers.indexOf(pending)
|
|
716
|
+
if (index >= 0) this.#pendingSteers.splice(index, 1)
|
|
717
|
+
throw error
|
|
718
|
+
}
|
|
719
|
+
result = { delivery: "steer", queuePosition: null, queuedFollowUps: metadata.queuedFollowUps.length }
|
|
720
|
+
return metadata
|
|
721
|
+
}
|
|
722
|
+
if (
|
|
723
|
+
(!options.background && (metadata.activeRun || metadata.queuedFollowUps.length)) ||
|
|
724
|
+
(metadata.activeRun && !metadata.activeRun.background)
|
|
725
|
+
) {
|
|
726
|
+
throw new Error(`Task ${metadata.taskRef} is busy; foreground Follow-ups require an idle task`)
|
|
727
|
+
}
|
|
728
|
+
if (metadata.queuedFollowUps.length >= MAX_QUEUED_FOLLOWUPS) {
|
|
729
|
+
throw new Error(`Task ${metadata.taskRef} already has ${MAX_QUEUED_FOLLOWUPS} queued Follow-ups`)
|
|
730
|
+
}
|
|
731
|
+
const acceptedAt = Date.now()
|
|
732
|
+
const sequence = metadata.lastRunSequence + 1
|
|
733
|
+
const runId = createRunId()
|
|
734
|
+
const acceptanceOrder = getAgentCoordinator().nextAcceptanceOrder()
|
|
735
|
+
const queuePosition = metadata.activeRun ? metadata.queuedFollowUps.length + 1 : 1
|
|
736
|
+
result = { delivery: "followup", queuePosition, queuedFollowUps: metadata.queuedFollowUps.length + 1 }
|
|
737
|
+
acceptedRun = {
|
|
738
|
+
id: runId,
|
|
739
|
+
sequence,
|
|
740
|
+
acceptanceOrder,
|
|
741
|
+
background: options.background ?? false,
|
|
742
|
+
kind: "followup",
|
|
743
|
+
state: "queued",
|
|
744
|
+
input: content,
|
|
745
|
+
acceptedAt
|
|
746
|
+
}
|
|
747
|
+
if (!metadata.activeRun) {
|
|
748
|
+
result.queuedFollowUps = 0
|
|
749
|
+
return {
|
|
750
|
+
...metadata,
|
|
751
|
+
state: "queued",
|
|
752
|
+
activeRun: acceptedRun,
|
|
753
|
+
lastRunSequence: sequence,
|
|
754
|
+
updatedAt: acceptedAt
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
...metadata,
|
|
759
|
+
lastRunSequence: sequence,
|
|
760
|
+
queuedFollowUps: [
|
|
761
|
+
...metadata.queuedFollowUps,
|
|
762
|
+
{ id: runId, sequence, acceptanceOrder, background: options.background ?? false, content, acceptedAt }
|
|
763
|
+
],
|
|
764
|
+
updatedAt: acceptedAt
|
|
765
|
+
}
|
|
766
|
+
})
|
|
767
|
+
} catch (error) {
|
|
768
|
+
if (!this.#started) {
|
|
769
|
+
this.#accepting = false
|
|
770
|
+
this.dispose()
|
|
771
|
+
this.#runtimeCompletion.resolve(undefined)
|
|
772
|
+
this.#initialCompletion.resolve(undefined)
|
|
773
|
+
}
|
|
774
|
+
throw error
|
|
775
|
+
}
|
|
776
|
+
if (!result) throw new Error("Task input was not accepted")
|
|
777
|
+
if (acceptedRun) {
|
|
778
|
+
if (!acceptedRun.background) this.#completions.set(acceptedRun.id, deferred())
|
|
779
|
+
// Inactive background reservations preserve acceptance order across promotion.
|
|
780
|
+
if (acceptedRun.background) this.reserveRun(acceptedRun)
|
|
781
|
+
}
|
|
782
|
+
this.start()
|
|
783
|
+
if (acceptedRun && !acceptedRun.background) result.completed = await this.waitForRun(acceptedRun.id, options.signal)
|
|
784
|
+
return result
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
recover(): boolean {
|
|
788
|
+
return this.wakeProviderRecovery("manual")
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
wakeProviderRecovery(mode: RecoveryMode): boolean {
|
|
792
|
+
const wait = this.#recoveryWait
|
|
793
|
+
if (!wait || wait.mode || this.#disposed || (this.#stopRequested && mode !== "stop")) return false
|
|
794
|
+
wait.mode = mode
|
|
795
|
+
this.#unbindSuspended?.()
|
|
796
|
+
this.#unbindSuspended = undefined
|
|
797
|
+
wait.resolve(mode)
|
|
798
|
+
return true
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private async run(): Promise<void> {
|
|
802
|
+
try {
|
|
803
|
+
while (!this.#stopRequested) {
|
|
804
|
+
const activeRun = await this.nextRun()
|
|
805
|
+
if (!activeRun) break
|
|
806
|
+
if (await this.executeRun(activeRun)) {
|
|
807
|
+
const wait = this.#recoveryWait
|
|
808
|
+
if (!wait) throw new Error("Suspended run has no recovery wait")
|
|
809
|
+
const mode = await wait.promise
|
|
810
|
+
if (this.#recoveryWait === wait) this.#recoveryWait = undefined
|
|
811
|
+
if (mode === "stop") break
|
|
812
|
+
this.#recoveryMode = mode
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
} finally {
|
|
816
|
+
this.dispose()
|
|
817
|
+
for (const completion of this.#completions.values()) completion.resolve(undefined)
|
|
818
|
+
this.#initialCompletion.resolve(undefined)
|
|
819
|
+
this.#runtimeCompletion.resolve(undefined)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
private async nextRun(): Promise<NonNullable<TaskMetadata["activeRun"]> | null> {
|
|
824
|
+
const selected: { run: NonNullable<TaskMetadata["activeRun"]> | null } = { run: null }
|
|
825
|
+
await mutateTaskMetadata(this.#paths, metadata => {
|
|
826
|
+
if (metadata.activeRun) {
|
|
827
|
+
selected.run = metadata.activeRun
|
|
828
|
+
return metadata
|
|
829
|
+
}
|
|
830
|
+
const [next, ...remaining] = metadata.queuedFollowUps
|
|
831
|
+
if (!next) {
|
|
832
|
+
this.#accepting = false
|
|
833
|
+
return metadata
|
|
834
|
+
}
|
|
835
|
+
selected.run = {
|
|
836
|
+
id: next.id,
|
|
837
|
+
sequence: next.sequence,
|
|
838
|
+
...(next.acceptanceOrder ? { acceptanceOrder: next.acceptanceOrder } : {}),
|
|
839
|
+
background: next.background ?? false,
|
|
840
|
+
kind: "followup",
|
|
841
|
+
state: "queued",
|
|
842
|
+
input: next.content,
|
|
843
|
+
acceptedAt: next.acceptedAt
|
|
844
|
+
}
|
|
845
|
+
return { ...metadata, state: "queued", activeRun: selected.run, queuedFollowUps: remaining, updatedAt: Date.now() }
|
|
846
|
+
})
|
|
847
|
+
return selected.run
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
private async executeRun(run: NonNullable<TaskMetadata["activeRun"]>): Promise<boolean> {
|
|
851
|
+
this.#recordingRunId = run.id
|
|
852
|
+
this.#lastHeartbeatAt = 0
|
|
853
|
+
let outcome: NonNullable<TaskMetadata["latestOutcome"]> = "failed"
|
|
854
|
+
const recovering = run.state === "suspended"
|
|
855
|
+
this.#lastAssistantOutcome = undefined
|
|
856
|
+
this.#providerLimitReached = false
|
|
857
|
+
let settled = false
|
|
858
|
+
let suspended = false
|
|
859
|
+
try {
|
|
860
|
+
await this.reserveRun(run).run(async () => {
|
|
861
|
+
if (this.#stopRequested) return
|
|
862
|
+
let promptCompleted = false
|
|
863
|
+
try {
|
|
864
|
+
let won = false
|
|
865
|
+
const startedAt = Date.now()
|
|
866
|
+
await mutateTaskMetadata(this.#paths, metadata => {
|
|
867
|
+
const activeRun = metadata.activeRun
|
|
868
|
+
if (!activeRun || activeRun.id !== run.id) return metadata
|
|
869
|
+
won = true
|
|
870
|
+
return {
|
|
871
|
+
...metadata,
|
|
872
|
+
state: "running",
|
|
873
|
+
activeRun: { ...activeRun, state: "running", startedAt },
|
|
874
|
+
lastActivity: { at: startedAt, action: "started" },
|
|
875
|
+
updatedAt: startedAt
|
|
876
|
+
}
|
|
877
|
+
})
|
|
878
|
+
if (!won) return
|
|
879
|
+
this.#recoveryMode = undefined
|
|
880
|
+
if (run.kind === "followup" && !recovering) {
|
|
881
|
+
await appendHistoryLog(this.#paths, {
|
|
882
|
+
type: "run-start",
|
|
883
|
+
sequence: run.sequence,
|
|
884
|
+
kind: "followup",
|
|
885
|
+
timestamp: startedAt
|
|
886
|
+
})
|
|
887
|
+
await appendHistoryLog(this.#paths, {
|
|
888
|
+
type: "input",
|
|
889
|
+
delivery: "followup",
|
|
890
|
+
timestamp: startedAt,
|
|
891
|
+
content: run.input
|
|
892
|
+
})
|
|
893
|
+
}
|
|
894
|
+
this.#awaitingPrimaryInput = true
|
|
895
|
+
await this.#child.session.prompt(
|
|
896
|
+
recovering ? "Continue." : run.input,
|
|
897
|
+
childPromptOptions(recovering ? false : this.#expandPromptTemplates)
|
|
898
|
+
)
|
|
899
|
+
promptCompleted = true
|
|
900
|
+
await this.#eventWrites
|
|
901
|
+
if (this.#eventWriteFailed) throw new Error("Could not retain one or more child session events")
|
|
902
|
+
outcome = this.#stopRequested ? "stopped" : (this.#lastAssistantOutcome ?? "failed")
|
|
903
|
+
} catch {
|
|
904
|
+
if (!promptCompleted) this.#providerLimitReached = false
|
|
905
|
+
await this.#eventWrites
|
|
906
|
+
outcome = this.#stopRequested ? "stopped" : "failed"
|
|
907
|
+
}
|
|
908
|
+
if (!this.#stopRequested && this.#providerLimitReached) {
|
|
909
|
+
suspended = await this.suspend(run)
|
|
910
|
+
if (suspended) return
|
|
911
|
+
}
|
|
912
|
+
const promoted = await this.settle(run, outcome, this.#stopRequested)
|
|
913
|
+
settled = true
|
|
914
|
+
if (promoted) this.reserveRun(promoted).activate()
|
|
915
|
+
})
|
|
916
|
+
} catch (error) {
|
|
917
|
+
await this.#eventWrites
|
|
918
|
+
outcome = this.#stopRequested ? "stopped" : "failed"
|
|
919
|
+
if (!run.background && !this.#stopRequested) {
|
|
920
|
+
await mutateTaskMetadata(this.#paths, metadata =>
|
|
921
|
+
metadata.activeRun?.id === run.id
|
|
922
|
+
? {
|
|
923
|
+
...metadata,
|
|
924
|
+
latestReply: { text: error instanceof Error ? error.message : String(error), streaming: false },
|
|
925
|
+
updatedAt: Date.now()
|
|
926
|
+
}
|
|
927
|
+
: metadata
|
|
928
|
+
)
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
this.#child.session.clearQueue()
|
|
932
|
+
this.#pendingSteers.length = 0
|
|
933
|
+
this.#reservations.delete(run.id)
|
|
934
|
+
if (!settled && !suspended) {
|
|
935
|
+
const promoted = await this.settle(run, outcome, this.#stopRequested)
|
|
936
|
+
if (promoted) this.reserveRun(promoted).activate()
|
|
937
|
+
}
|
|
938
|
+
return suspended
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private async suspend(run: NonNullable<TaskMetadata["activeRun"]>): Promise<boolean> {
|
|
942
|
+
// Provider evidence is tuple-global even if another task mutation wins.
|
|
943
|
+
getAgentCoordinator().closeTuple(this.#tuple)
|
|
944
|
+
if (!run.background) {
|
|
945
|
+
await writeTaskProgress(this.#paths, run.id, {
|
|
946
|
+
latestReply: { text: "Provider limit reached; foreground run failed and will not restart automatically.", streaming: false }
|
|
947
|
+
})
|
|
948
|
+
return false
|
|
949
|
+
}
|
|
950
|
+
const wait = Object.assign(deferred<RecoveryMode>(), { mode: undefined as RecoveryMode | undefined })
|
|
951
|
+
const unbind = bindSuspendedRuntime(this.#tuple, this)
|
|
952
|
+
this.#recoveryWait = wait
|
|
953
|
+
this.#unbindSuspended = unbind
|
|
954
|
+
let won = false
|
|
955
|
+
let notification: TaskMetadata["notifications"][number] | undefined
|
|
956
|
+
const timestamp = Date.now()
|
|
957
|
+
await mutateTaskMetadata(this.#paths, async metadata => {
|
|
958
|
+
const activeRun = metadata.activeRun
|
|
959
|
+
if (!activeRun || activeRun.id !== run.id) return metadata
|
|
960
|
+
won = true
|
|
961
|
+
if (run.kind === "followup" || activeRun.detachedAt !== undefined) {
|
|
962
|
+
notification = await prepareTaskNotification(this.#paths, metadata, activeRun, "suspension")
|
|
963
|
+
}
|
|
964
|
+
return {
|
|
965
|
+
...metadata,
|
|
966
|
+
state: "suspended",
|
|
967
|
+
activeRun: { ...activeRun, state: "suspended" },
|
|
968
|
+
...(notification ? { notifications: appendTaskNotification(metadata.notifications, notification) } : {}),
|
|
969
|
+
updatedAt: timestamp
|
|
970
|
+
}
|
|
971
|
+
})
|
|
972
|
+
if (won) {
|
|
973
|
+
if (run.id === this.#initialRunId) this.#initialCompletion.resolve(undefined)
|
|
974
|
+
if (notification) await deliverTaskNotifications(this.#paths).catch(() => {})
|
|
975
|
+
} else {
|
|
976
|
+
unbind()
|
|
977
|
+
if (this.#recoveryWait === wait) this.#recoveryWait = undefined
|
|
978
|
+
if (this.#unbindSuspended === unbind) this.#unbindSuspended = undefined
|
|
979
|
+
}
|
|
980
|
+
return won
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
private async detach(): Promise<void> {
|
|
984
|
+
const detachedAt = Date.now()
|
|
985
|
+
await mutateTaskMetadata(this.#paths, metadata => {
|
|
986
|
+
const activeRun = metadata.activeRun
|
|
987
|
+
if (!activeRun?.background || activeRun.id !== this.#initialRunId || activeRun.detachedAt !== undefined) return metadata
|
|
988
|
+
return { ...metadata, activeRun: { ...activeRun, detachedAt }, updatedAt: detachedAt }
|
|
989
|
+
})
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
private async settle(
|
|
993
|
+
run: NonNullable<TaskMetadata["activeRun"]>,
|
|
994
|
+
outcome: NonNullable<TaskMetadata["latestOutcome"]>,
|
|
995
|
+
clearFollowUps = false
|
|
996
|
+
): Promise<NonNullable<TaskMetadata["activeRun"]> | null> {
|
|
997
|
+
let won = false
|
|
998
|
+
const promoted: { run: NonNullable<TaskMetadata["activeRun"]> | null } = { run: null }
|
|
999
|
+
let notification: TaskMetadata["notifications"][number] | undefined
|
|
1000
|
+
let completed: TaskMetadata | undefined
|
|
1001
|
+
const timestamp = Date.now()
|
|
1002
|
+
await mutateTaskMetadata(this.#paths, async metadata => {
|
|
1003
|
+
const activeRun = metadata.activeRun
|
|
1004
|
+
if (activeRun?.id !== run.id) return clearFollowUps ? { ...metadata, queuedFollowUps: [], updatedAt: timestamp } : metadata
|
|
1005
|
+
won = true
|
|
1006
|
+
completed = { ...metadata, state: "idle", latestOutcome: outcome, activeRun: null, queuedFollowUps: [], updatedAt: timestamp }
|
|
1007
|
+
if (
|
|
1008
|
+
activeRun.background &&
|
|
1009
|
+
(outcome === "succeeded" || outcome === "failed") &&
|
|
1010
|
+
(run.kind === "followup" || activeRun.detachedAt !== undefined)
|
|
1011
|
+
) {
|
|
1012
|
+
notification = await prepareTaskNotification(this.#paths, metadata, activeRun, "completion", outcome)
|
|
1013
|
+
}
|
|
1014
|
+
const [next, ...remaining] = clearFollowUps ? [] : metadata.queuedFollowUps
|
|
1015
|
+
if (next) {
|
|
1016
|
+
promoted.run = {
|
|
1017
|
+
id: next.id,
|
|
1018
|
+
sequence: next.sequence,
|
|
1019
|
+
...(next.acceptanceOrder ? { acceptanceOrder: next.acceptanceOrder } : {}),
|
|
1020
|
+
background: next.background ?? false,
|
|
1021
|
+
kind: "followup",
|
|
1022
|
+
state: "queued",
|
|
1023
|
+
input: next.content,
|
|
1024
|
+
acceptedAt: next.acceptedAt
|
|
1025
|
+
}
|
|
1026
|
+
return {
|
|
1027
|
+
...metadata,
|
|
1028
|
+
state: "queued",
|
|
1029
|
+
latestOutcome: outcome,
|
|
1030
|
+
activeRun: promoted.run,
|
|
1031
|
+
queuedFollowUps: remaining,
|
|
1032
|
+
...(notification ? { notifications: appendTaskNotification(metadata.notifications, notification) } : {}),
|
|
1033
|
+
updatedAt: timestamp
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
...metadata,
|
|
1038
|
+
state: "idle",
|
|
1039
|
+
latestOutcome: outcome,
|
|
1040
|
+
activeRun: null,
|
|
1041
|
+
...(clearFollowUps ? { queuedFollowUps: [] } : {}),
|
|
1042
|
+
...(notification ? { notifications: appendTaskNotification(metadata.notifications, notification) } : {}),
|
|
1043
|
+
updatedAt: timestamp
|
|
1044
|
+
}
|
|
1045
|
+
})
|
|
1046
|
+
if (won) await appendHistoryLog(this.#paths, { type: "run-end", sequence: run.sequence, outcome, timestamp })
|
|
1047
|
+
if (won && notification) await deliverTaskNotifications(this.#paths).catch(() => {})
|
|
1048
|
+
if (won) this.#completions.get(run.id)?.resolve(completed)
|
|
1049
|
+
if (run.id === this.#initialRunId) this.#initialCompletion.resolve(undefined)
|
|
1050
|
+
return promoted.run
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
private reserveRun(run: NonNullable<TaskMetadata["activeRun"]>): AgentReservation {
|
|
1054
|
+
const existing = this.#reservations.get(run.id)
|
|
1055
|
+
if (existing) return existing
|
|
1056
|
+
const tuple =
|
|
1057
|
+
run.state === "suspended" && this.#recoveryMode === "manual"
|
|
1058
|
+
? { provider: `${this.#tuple.provider}#manual#${this.#paths.taskRef}`, model: this.#tuple.model }
|
|
1059
|
+
: this.#tuple
|
|
1060
|
+
const reservation = getAgentCoordinator().reserve({
|
|
1061
|
+
tuple,
|
|
1062
|
+
signal: this.#scheduleAbort.signal,
|
|
1063
|
+
rejectOnClosedTuple: !run.background,
|
|
1064
|
+
...(run.acceptanceOrder ? { acceptanceOrder: run.acceptanceOrder } : {})
|
|
1065
|
+
})
|
|
1066
|
+
this.#reservations.set(run.id, reservation)
|
|
1067
|
+
return reservation
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
private recordEvent(event: AgentSessionEvent): void {
|
|
1071
|
+
const at = Date.now()
|
|
1072
|
+
const runId = this.#recordingRunId
|
|
1073
|
+
if (runId && event.type === "agent_start") {
|
|
1074
|
+
// before_agent_start hooks have finished; capture now, not when the queued write runs.
|
|
1075
|
+
const effectiveSystemPrompt = this.#child.session.systemPrompt
|
|
1076
|
+
this.queueEventWrite(() => writeTaskProgress(this.#paths, runId, { effectiveSystemPrompt }))
|
|
1077
|
+
}
|
|
1078
|
+
let action: string | undefined
|
|
1079
|
+
let latestReply: TaskMetadata["latestReply"] | undefined
|
|
1080
|
+
if (
|
|
1081
|
+
runId &&
|
|
1082
|
+
((event.type === "message_update" && event.assistantMessageEvent.type === "thinking_delta") ||
|
|
1083
|
+
event.type === "tool_execution_update") &&
|
|
1084
|
+
at - this.#lastHeartbeatAt >= 1_000
|
|
1085
|
+
) {
|
|
1086
|
+
// Non-reply heartbeats are event-driven and capped at one durable write per second.
|
|
1087
|
+
this.#lastHeartbeatAt = at
|
|
1088
|
+
action = event.type === "tool_execution_update" ? `tool ${event.toolName.slice(0, 160)}` : "thinking"
|
|
1089
|
+
}
|
|
1090
|
+
if (runId && (event.type === "tool_execution_start" || event.type === "tool_execution_end")) {
|
|
1091
|
+
action = `tool ${event.toolName.slice(0, 160)}${event.type === "tool_execution_end" ? (event.isError ? " failed" : " complete") : ""}`
|
|
1092
|
+
}
|
|
1093
|
+
if (
|
|
1094
|
+
(event.type === "message_start" || event.type === "message_update" || event.type === "message_end") &&
|
|
1095
|
+
event.message.role === "assistant" &&
|
|
1096
|
+
(event.type !== "message_update" || event.assistantMessageEvent.type === "text_delta")
|
|
1097
|
+
) {
|
|
1098
|
+
const text = event.message.content
|
|
1099
|
+
.filter(part => part.type === "text")
|
|
1100
|
+
.map(part => part.text)
|
|
1101
|
+
.join("")
|
|
1102
|
+
latestReply = { text, streaming: event.type !== "message_end" }
|
|
1103
|
+
action = latestReply.streaming ? "responding" : "reply complete"
|
|
1104
|
+
}
|
|
1105
|
+
if (runId && action) {
|
|
1106
|
+
this.#pendingProgress = {
|
|
1107
|
+
...this.#pendingProgress,
|
|
1108
|
+
runId,
|
|
1109
|
+
...(latestReply ? { latestReply } : {}),
|
|
1110
|
+
lastActivity: { at, action }
|
|
1111
|
+
}
|
|
1112
|
+
if (!this.#progressWriteQueued) {
|
|
1113
|
+
this.#progressWriteQueued = true
|
|
1114
|
+
this.queueEventWrite(async () => {
|
|
1115
|
+
try {
|
|
1116
|
+
// Coalesce replies and activity together so bursts cannot reorder their last action.
|
|
1117
|
+
while (this.#pendingProgress) {
|
|
1118
|
+
const { runId, ...progress } = this.#pendingProgress
|
|
1119
|
+
this.#pendingProgress = undefined
|
|
1120
|
+
await writeTaskProgress(this.#paths, runId, progress)
|
|
1121
|
+
}
|
|
1122
|
+
} finally {
|
|
1123
|
+
this.#progressWriteQueued = false
|
|
1124
|
+
}
|
|
1125
|
+
})
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
if (event.type === "tool_execution_start") {
|
|
1129
|
+
this.#toolArguments.set(event.toolCallId, { name: event.toolName, arguments: renderUnknown(event.args) })
|
|
1130
|
+
return
|
|
1131
|
+
}
|
|
1132
|
+
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
1133
|
+
this.#lastAssistantOutcome = event.message.stopReason === "error" || event.message.stopReason === "aborted" ? "failed" : "succeeded"
|
|
1134
|
+
this.#providerLimitReached = event.message.stopReason === "error" && isProviderLimitError(event.message.errorMessage)
|
|
1135
|
+
const content = event.message.content
|
|
1136
|
+
.filter(part => part.type === "text")
|
|
1137
|
+
.map(part => part.text)
|
|
1138
|
+
.join("")
|
|
1139
|
+
if (content) this.queueEventWrite(() => appendHistoryLog(this.#paths, { type: "assistant", content }))
|
|
1140
|
+
return
|
|
1141
|
+
}
|
|
1142
|
+
if (event.type === "message_start" && event.message.role === "user" && this.#awaitingPrimaryInput) {
|
|
1143
|
+
this.#awaitingPrimaryInput = false
|
|
1144
|
+
return
|
|
1145
|
+
}
|
|
1146
|
+
if (event.type === "message_start" && event.message.role === "user" && this.#pendingSteers.length > 0) {
|
|
1147
|
+
const delivered = this.#pendingSteers.shift()
|
|
1148
|
+
if (delivered) {
|
|
1149
|
+
const content =
|
|
1150
|
+
typeof event.message.content === "string"
|
|
1151
|
+
? event.message.content
|
|
1152
|
+
: event.message.content
|
|
1153
|
+
.filter(part => part.type === "text")
|
|
1154
|
+
.map(part => part.text)
|
|
1155
|
+
.join("")
|
|
1156
|
+
this.queueEventWrite(() =>
|
|
1157
|
+
appendHistoryLog(this.#paths, {
|
|
1158
|
+
type: "input",
|
|
1159
|
+
delivery: "steer",
|
|
1160
|
+
timestamp: Date.now(),
|
|
1161
|
+
content: content || delivered.content
|
|
1162
|
+
})
|
|
1163
|
+
)
|
|
1164
|
+
}
|
|
1165
|
+
return
|
|
1166
|
+
}
|
|
1167
|
+
if (event.type === "tool_execution_end") {
|
|
1168
|
+
const started = this.#toolArguments.get(event.toolCallId)
|
|
1169
|
+
this.#toolArguments.delete(event.toolCallId)
|
|
1170
|
+
this.queueEventWrite(() =>
|
|
1171
|
+
appendHistoryLog(this.#paths, {
|
|
1172
|
+
type: "tool",
|
|
1173
|
+
tool: started?.name ?? event.toolName,
|
|
1174
|
+
arguments: started?.arguments ?? "(unavailable)",
|
|
1175
|
+
result: renderUnknown(event.result),
|
|
1176
|
+
isError: event.isError
|
|
1177
|
+
})
|
|
1178
|
+
)
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
private queueEventWrite(write: () => Promise<void>): void {
|
|
1183
|
+
this.#eventWrites = this.#eventWrites.then(write).catch(() => {
|
|
1184
|
+
this.#eventWriteFailed = true
|
|
1185
|
+
})
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
class RuntimeClosingError extends Error {
|
|
1190
|
+
constructor() {
|
|
1191
|
+
super("Agent runtime is closing")
|
|
1192
|
+
this.name = "LovelyAgentRuntimeClosingError"
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function isRuntimeClosingError(error: unknown): boolean {
|
|
1197
|
+
return error instanceof RuntimeClosingError || (error instanceof Error && error.name === "LovelyAgentRuntimeClosingError")
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
type ControllableResident = ResidentAgent & {
|
|
1201
|
+
input(content: string, delivery: "followup" | "steer", options?: ResidentInputOptions): Promise<ResidentInputResult>
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const COLD_RUNTIME_LOADS = Symbol.for("@xl0/pi-lovely-agents/cold-runtime-loads/v1")
|
|
1205
|
+
|
|
1206
|
+
async function controllableRuntime(
|
|
1207
|
+
ctx: ExtensionContext,
|
|
1208
|
+
paths: TaskStoragePaths,
|
|
1209
|
+
options: AgentToolOptions
|
|
1210
|
+
): Promise<ControllableResident> {
|
|
1211
|
+
const resident = getAgentCoordinator().getResident(paths.taskDirectory)
|
|
1212
|
+
if (resident) {
|
|
1213
|
+
if (!resident.input) throw new Error(`Task ${paths.taskRef} does not accept input`)
|
|
1214
|
+
return resident as ControllableResident
|
|
1215
|
+
}
|
|
1216
|
+
const loads = coldRuntimeLoads()
|
|
1217
|
+
let pending = loads.get(paths.taskDirectory)
|
|
1218
|
+
if (pending) return pending
|
|
1219
|
+
pending = openColdRuntime(ctx, paths, options)
|
|
1220
|
+
loads.set(paths.taskDirectory, pending)
|
|
1221
|
+
try {
|
|
1222
|
+
return await pending
|
|
1223
|
+
} finally {
|
|
1224
|
+
if (loads.get(paths.taskDirectory) === pending) loads.delete(paths.taskDirectory)
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
async function openColdRuntime(ctx: ExtensionContext, paths: TaskStoragePaths, options: AgentToolOptions): Promise<AgentRuntime> {
|
|
1229
|
+
const loaded = await readTaskMetadata(paths)
|
|
1230
|
+
if (loaded.status !== "ok") throw new Error(`Could not load task ${paths.taskRef}`)
|
|
1231
|
+
const metadata = loaded.metadata
|
|
1232
|
+
if (metadata.kind !== "agent") throw new Error("Bash tasks cannot be cold reopened")
|
|
1233
|
+
if (metadata.discardedAt !== null) throw new Error(`Task ${paths.taskRef} has been discarded`)
|
|
1234
|
+
if (metadata.state !== "idle" && metadata.state !== "interrupted") {
|
|
1235
|
+
throw new Error(`Task ${paths.taskRef} has active state ${metadata.state} but no resident runtime`)
|
|
1236
|
+
}
|
|
1237
|
+
const definition: AgentDefinition = {
|
|
1238
|
+
name: metadata.definitionName,
|
|
1239
|
+
description: `Retained configuration for ${metadata.definitionName}`,
|
|
1240
|
+
systemPrompt: metadata.sessionConfig.systemPrompt,
|
|
1241
|
+
source: "project",
|
|
1242
|
+
filePath: paths.metadata,
|
|
1243
|
+
displayPath: paths.metadata,
|
|
1244
|
+
...(metadata.sessionConfig.tools ? { tools: [...metadata.sessionConfig.tools] } : {}),
|
|
1245
|
+
excludeAgentsMd: metadata.sessionConfig.excludeAgentsMd
|
|
1246
|
+
}
|
|
1247
|
+
const model = ctx.modelRegistry
|
|
1248
|
+
.getAvailable()
|
|
1249
|
+
.find(candidate => candidate.provider === metadata.model.provider && candidate.id === metadata.model.id)
|
|
1250
|
+
if (!model) throw new Error(`Task model "${metadata.model.provider}/${metadata.model.id}" is not authenticated`)
|
|
1251
|
+
const config = options.getConfig()
|
|
1252
|
+
const scopedModels: ScopedModel[] = metadata.sessionConfig.scopedModels.map(saved => {
|
|
1253
|
+
const savedModel = ctx.modelRegistry.getAll().find(candidate => candidate.provider === saved.provider && candidate.id === saved.id)
|
|
1254
|
+
if (!savedModel) throw new Error(`Retained scoped model "${saved.provider}/${saved.id}" is no longer available`)
|
|
1255
|
+
return { model: savedModel, ...(saved.thinkingLevel ? { thinkingLevel: saved.thinkingLevel } : {}) }
|
|
1256
|
+
})
|
|
1257
|
+
const child = await (options.createChild ?? createChildSession)({
|
|
1258
|
+
cwd: ctx.cwd,
|
|
1259
|
+
paths,
|
|
1260
|
+
definition,
|
|
1261
|
+
selection: { model, thinking: metadata.thinking },
|
|
1262
|
+
scopedModels,
|
|
1263
|
+
parentDepth: metadata.depth - 1,
|
|
1264
|
+
maximumDepth: metadata.depth + (metadata.allowAgents ? 1 : 0),
|
|
1265
|
+
allowAgents: metadata.allowAgents,
|
|
1266
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
1267
|
+
expectedSessionId: metadata.childSessionId,
|
|
1268
|
+
...(options.getAgentDir ? { agentDir: options.getAgentDir() } : {})
|
|
1269
|
+
})
|
|
1270
|
+
return new AgentRuntime(paths, child, metadata, config.expandPromptTemplates)
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function coldRuntimeLoads(): Map<string, Promise<AgentRuntime>> {
|
|
1274
|
+
const global = globalThis as typeof globalThis & { [COLD_RUNTIME_LOADS]?: Map<string, Promise<AgentRuntime>> }
|
|
1275
|
+
global[COLD_RUNTIME_LOADS] ??= new Map()
|
|
1276
|
+
return global[COLD_RUNTIME_LOADS]
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function buildAgentCreationToolResult(
|
|
1280
|
+
metadata: TaskMetadata,
|
|
1281
|
+
detached: boolean,
|
|
1282
|
+
output: Awaited<ReturnType<typeof readRetainedOutput>>,
|
|
1283
|
+
tasks: Awaited<ReturnType<typeof loadTaskList>>["details"]
|
|
1284
|
+
): { content: [{ type: "text"; text: string }]; details: AgentCreationResult } {
|
|
1285
|
+
if (metadata.kind !== "agent") throw new Error("Expected an Agent task")
|
|
1286
|
+
const result: AgentCreationResult = {
|
|
1287
|
+
id: metadata.taskRef,
|
|
1288
|
+
label: metadata.label,
|
|
1289
|
+
definition: metadata.definitionName,
|
|
1290
|
+
state: metadata.state,
|
|
1291
|
+
latestOutcome: metadata.latestOutcome,
|
|
1292
|
+
model: `${metadata.model.provider}/${metadata.model.id}`,
|
|
1293
|
+
thinking: metadata.thinking,
|
|
1294
|
+
depth: metadata.depth,
|
|
1295
|
+
allowAgents: metadata.allowAgents,
|
|
1296
|
+
detached,
|
|
1297
|
+
queuedFollowUps: metadata.queuedFollowUps.length,
|
|
1298
|
+
output,
|
|
1299
|
+
tasks
|
|
1300
|
+
}
|
|
1301
|
+
const pending = detached && (result.state === "queued" || result.state === "running" || result.state === "suspended")
|
|
1302
|
+
return {
|
|
1303
|
+
content: [
|
|
1304
|
+
{
|
|
1305
|
+
type: "text",
|
|
1306
|
+
text: [
|
|
1307
|
+
`task: ${result.id}`,
|
|
1308
|
+
`state: ${result.state}`,
|
|
1309
|
+
...(output.queueReason
|
|
1310
|
+
? [`waiting: ${output.queueReason} (${output.capacity.active}/${output.capacity.limit} execution permits)`]
|
|
1311
|
+
: []),
|
|
1312
|
+
`outcome: ${result.latestOutcome ?? "none"}`,
|
|
1313
|
+
`detached: ${result.detached}`,
|
|
1314
|
+
`queued_followups: ${result.queuedFollowUps}`,
|
|
1315
|
+
"output:",
|
|
1316
|
+
pending ? "(pending; use task_output)" : result.output.text || "(no output)"
|
|
1317
|
+
].join("\n")
|
|
1318
|
+
}
|
|
1319
|
+
],
|
|
1320
|
+
details: result
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function validateInput(value: string, name: string, maximumBytes: number): void {
|
|
1325
|
+
if (!value.trim()) throw new Error(`${name} must be nonblank`)
|
|
1326
|
+
if (Buffer.byteLength(value, "utf8") > maximumBytes) throw new Error(`${name} must be at most ${maximumBytes} UTF-8 bytes`)
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function createRunId(): string {
|
|
1330
|
+
return `r_${randomBytes(8).toString("hex")}`
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function renderUnknown(value: unknown): string {
|
|
1334
|
+
if (typeof value === "string") return value
|
|
1335
|
+
try {
|
|
1336
|
+
return JSON.stringify(value) ?? String(value)
|
|
1337
|
+
} catch {
|
|
1338
|
+
return String(value)
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function suspendedRuntimes(): Map<string, Set<SuspendedRuntime>> {
|
|
1343
|
+
const global = globalThis as typeof globalThis & { [SUSPENDED_RUNTIMES_SYMBOL]?: Map<string, Set<SuspendedRuntime>> }
|
|
1344
|
+
global[SUSPENDED_RUNTIMES_SYMBOL] ??= new Map()
|
|
1345
|
+
return global[SUSPENDED_RUNTIMES_SYMBOL]
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function bindSuspendedRuntime(tuple: ModelTuple, runtime: SuspendedRuntime): () => void {
|
|
1349
|
+
const registry = suspendedRuntimes()
|
|
1350
|
+
const key = modelTupleKey(tuple)
|
|
1351
|
+
const runtimes = registry.get(key) ?? new Set()
|
|
1352
|
+
runtimes.add(runtime)
|
|
1353
|
+
registry.set(key, runtimes)
|
|
1354
|
+
return () => {
|
|
1355
|
+
runtimes.delete(runtime)
|
|
1356
|
+
if (runtimes.size === 0 && registry.get(key) === runtimes) registry.delete(key)
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function modelTupleKey(tuple: ModelTuple): string {
|
|
1361
|
+
return `${tuple.provider}\0${tuple.model}`
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function abortError(signal: AbortSignal): Error {
|
|
1365
|
+
return signal.reason instanceof Error ? signal.reason : new Error("Agent creation aborted")
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function deferred<T>(): { promise: Promise<T>; resolve(value: T | PromiseLike<T>): void } {
|
|
1369
|
+
let resolve!: (value: T | PromiseLike<T>) => void
|
|
1370
|
+
const promise = new Promise<T>(resolvePromise => {
|
|
1371
|
+
resolve = resolvePromise
|
|
1372
|
+
})
|
|
1373
|
+
return { promise, resolve }
|
|
1374
|
+
}
|