pantheon-opencode 1.5.0-beta.15 → 1.5.0-beta.17
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/AGENTS.md +14 -63
- package/CHANGELOG.md +55 -0
- package/README.md +14 -27
- package/README.pt-BR.md +5 -6
- package/commands/pantheon.md +1 -35
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/mcp_persistence_server.py +1 -1
- package/src/agents/apollo.md +1 -0
- package/src/agents/gaia.md +1 -0
- package/src/agents/zeus.md +8 -29
- package/src/instructions/memory-protocol.instructions.md +6 -3
- package/src/instructions/zeus-anti-stall.instructions.md +2 -4
- package/src/instructions/zeus-council-synthesis.instructions.md +3 -28
- package/src/instructions/zeus-timeout-retry.instructions.md +3 -28
- package/src/mcp/mcp_persistence_server.py +1 -1
- package/src/mcp/toon_codec.py +2 -4
- package/src/pantheon/{delegation-compaction.ts → compaction-context.ts} +10 -100
- package/src/pantheon/cost-command.ts +1 -2
- package/src/pantheon/delegation-enforce.ts +59 -14
- package/src/pantheon/goal-loop.ts +1 -1
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/idle-continuation.ts +4 -6
- package/src/pantheon/model-command.ts +1 -2
- package/src/pantheon/native-task-status.ts +2 -2
- package/src/pantheon/session-id.ts +30 -0
- package/src/pantheon/task-result-guard.ts +2 -3
- package/src/pantheon/todo-enforcer.ts +3 -3
- package/src/pantheon/tool-context.ts +23 -0
- package/src/pantheon/v2-bridge.ts +1 -4
- package/src/plugin-v2.ts +4 -15
- package/src/plugin.ts +61 -245
- package/src/plugins/tui/dist/tui.d.ts +33 -64
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +30 -177
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +67 -277
- package/src/skills/context-compression/SKILL.md +1 -1
- package/scripts/dev-tui.sh +0 -48
- package/scripts/generate-preset-docs.mjs +0 -300
- package/scripts/generate-prompts.sh +0 -222
- package/scripts/install/component.mjs +0 -24
- package/scripts/install/migrate-config.mjs +0 -302
- package/src/pantheon/delegate-manager.ts +0 -851
- package/src/pantheon/delegation-classifier.ts +0 -306
- package/src/pantheon/delegation-finalize.ts +0 -468
- package/src/pantheon/delegation-notify.ts +0 -212
- package/src/pantheon/delegation-toast.ts +0 -73
- package/src/pantheon/delegation.ts +0 -1349
- package/src/pantheon/native-probe.ts +0 -234
- package/src/pantheon/step-cap.ts +0 -110
- package/src/pantheon/toon-codec.ts +0 -481
|
@@ -1,1349 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Delegation Core (Phase 2) — the three background-delegation tools plus the
|
|
3
|
-
* finalize lifecycle hook, built on the BackgroundJobBoard.
|
|
4
|
-
*
|
|
5
|
-
* Tools (STRUCTURAL shape — matches what opencode expects: `{description,
|
|
6
|
-
* args (zod shape), execute(args, ctx)}`; zod is imported directly — the
|
|
7
|
-
* `@opencode-ai/plugin` package is a devDep and is NEVER runtime-imported):
|
|
8
|
-
* - `pantheon_delegate({prompt, agent, description?})` — create a child
|
|
9
|
-
* session (parentID = caller), register it on the board, arm a timeout,
|
|
10
|
-
* and fire-and-forget `session.promptAsync` on the child (NO noReply —
|
|
11
|
-
* the spike refuted noReply as a push mechanism). Completion is observed
|
|
12
|
-
* via `session.idle` on the child and finalized through
|
|
13
|
-
* `finalizeDelegation` (exposed on the toolset for the Phase 3 event hook).
|
|
14
|
-
* - `pantheon_delegation_read({id})` — block via `board.waitForTerminal`,
|
|
15
|
-
* return the report markdown, mark the job reconciled.
|
|
16
|
-
* - `pantheon_delegation_list()` — job list with `[unread]` for terminal-
|
|
17
|
-
* unreconciled jobs.
|
|
18
|
-
*
|
|
19
|
-
* Depth guard (root-session detection): the ToolContext carries NO parentID
|
|
20
|
-
* (spike), so root detection is configurable. Resolution order:
|
|
21
|
-
* 1. `options.isRootSession(sessionID)` custom predicate;
|
|
22
|
-
* 2. `options.rootSessions` explicit allowlist, with `options.isChildSession`
|
|
23
|
-
* authoritative when supplied by the plugin. SDK session metadata is
|
|
24
|
-
* registered from parentID; no prompt/name inference is used.
|
|
25
|
-
* 3. default (no rootSessions): any session WE created via
|
|
26
|
-
* `session.create` is a sub-session; everything else is treated as
|
|
27
|
-
* root for standalone compatibility. The plugin supplies the SDK-backed
|
|
28
|
-
* hierarchy at runtime.
|
|
29
|
-
*
|
|
30
|
-
* Read-only enforcement is Phase 4 — `read_only` is exposed on the delegate
|
|
31
|
-
* args and the child session is registered in the read-only registry when the
|
|
32
|
-
* delegate is read-only (explicit flag or agent ∈ readOnlyAgents), which the
|
|
33
|
-
* plugin's `tool.execute.before` guard enforces.
|
|
34
|
-
*
|
|
35
|
-
* @module delegation
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
import { join } from 'node:path'
|
|
39
|
-
import { z } from 'zod'
|
|
40
|
-
|
|
41
|
-
import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
|
|
42
|
-
import {
|
|
43
|
-
classifyStuckAgent,
|
|
44
|
-
type DelegationResult,
|
|
45
|
-
formatDelegationResult,
|
|
46
|
-
} from './delegation-classifier.ts'
|
|
47
|
-
import {
|
|
48
|
-
isDelegationAllowed,
|
|
49
|
-
normalizeDelegationAgent,
|
|
50
|
-
readOnlyRegistry,
|
|
51
|
-
} from './delegation-enforce.ts'
|
|
52
|
-
import {
|
|
53
|
-
capReportOutput,
|
|
54
|
-
DELEGATION_DEFAULTS,
|
|
55
|
-
type DelegationClient,
|
|
56
|
-
type DelegationDeps,
|
|
57
|
-
type DelegationMessageBundle,
|
|
58
|
-
type DelegationOptions,
|
|
59
|
-
type FinalizeInput,
|
|
60
|
-
finalizeDelegation as finalizeDelegationReport,
|
|
61
|
-
readDelegationReport,
|
|
62
|
-
resolveReadMaxChars,
|
|
63
|
-
} from './delegation-finalize.ts'
|
|
64
|
-
import { finalizeIdleChildrenWithoutMd } from './delegation-notify.ts'
|
|
65
|
-
import { createPantheonLogger } from './logger.ts'
|
|
66
|
-
import { buildAgentListDescription } from './permission-globs.ts'
|
|
67
|
-
import { safeSessionPath } from './session-guard.ts'
|
|
68
|
-
import { buildStopInstruction, cappedSummary, DEFAULT_MAX_STEPS } from './step-cap.ts'
|
|
69
|
-
|
|
70
|
-
export type {
|
|
71
|
-
DelegationClient,
|
|
72
|
-
DelegationClientSession,
|
|
73
|
-
DelegationMessageBundle,
|
|
74
|
-
DelegationOptions,
|
|
75
|
-
FinalizeInput,
|
|
76
|
-
} from './delegation-finalize.ts'
|
|
77
|
-
export { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
|
|
78
|
-
export type { IdleChildScanDeps } from './delegation-notify.ts'
|
|
79
|
-
export { finalizeIdleChildrenWithoutMd, startIdleChildScan } from './delegation-notify.ts'
|
|
80
|
-
|
|
81
|
-
// Fase B3: these are runtime controls, not routing/frontmatter fields. The
|
|
82
|
-
// plugin reads them from the environment, while tests and embedders can pass
|
|
83
|
-
// the same values through DelegationOptions.
|
|
84
|
-
export const B3_DEFAULT_EXCEPTION_BUDGET = 5
|
|
85
|
-
|
|
86
|
-
/** Maximum time spent proving that an accepted prompt actually booted. */
|
|
87
|
-
export const BOOTSTRAP_TIMEOUT_MS = 5_000
|
|
88
|
-
/** Default cadence for the bounded bootstrap probe. */
|
|
89
|
-
export const BOOTSTRAP_POLL_INTERVAL_MS = 250
|
|
90
|
-
|
|
91
|
-
function readIntegerEnv(
|
|
92
|
-
env: Record<string, string | undefined>,
|
|
93
|
-
name: string,
|
|
94
|
-
fallback: number,
|
|
95
|
-
minimum: number,
|
|
96
|
-
): number {
|
|
97
|
-
const raw = env[name]?.trim()
|
|
98
|
-
if (raw === undefined || raw === '') return fallback
|
|
99
|
-
const value = Number(raw)
|
|
100
|
-
return Number.isSafeInteger(value) && value >= minimum ? value : fallback
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// ─── B1: unresolved session template guard ─────────────────────────────
|
|
104
|
-
//
|
|
105
|
-
// The TUI/runtime can hand a plugin surface the UNSUBSTITUTED template literal
|
|
106
|
-
// `{sessionID}` (URL-encoded `%7BsessionID%7D`) when no session is focused.
|
|
107
|
-
// Forwarding it to a session API makes the opencode server reject every call
|
|
108
|
-
// (`Expected a string starting with "ses", got "%7BsessionID%7D"`), which the
|
|
109
|
-
// beta.7 E2E recorded at ~1k errors/s. The delegation layer is a defensive
|
|
110
|
-
// choke point too: it is downstream of the plugin adapter in production, but
|
|
111
|
-
// tests, older hosts, and direct embedders can inject a raw client.
|
|
112
|
-
//
|
|
113
|
-
// This guard targets the placeholder ONLY. It is deliberately NOT the full
|
|
114
|
-
// `isValidSessionId` contract: structural test hosts use non-"ses" ids on
|
|
115
|
-
// purpose (delegation-bootstrap.test.ts), and rejecting those would be a
|
|
116
|
-
// behavior change unrelated to this bug. `session-guard.ts` remains the
|
|
117
|
-
// strict choke point for the plugin adapters.
|
|
118
|
-
const UNRESOLVED_SESSION_TEMPLATE = /(?:\{sessionID\}|%7BsessionID%7D)/i
|
|
119
|
-
|
|
120
|
-
/** True when a session id is an unresolved `{sessionID}` template placeholder. */
|
|
121
|
-
export function isUnresolvedSessionTemplate(id: unknown): boolean {
|
|
122
|
-
return typeof id === 'string' && UNRESOLVED_SESSION_TEMPLATE.test(id)
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Refuse a placeholder session id before it reaches a session API. Returns
|
|
127
|
-
* true (and logs a clear warning) when the caller must skip the operation.
|
|
128
|
-
*/
|
|
129
|
-
function refuseUnresolvedSessionTemplate(id: unknown, operation: string): boolean {
|
|
130
|
-
if (!isUnresolvedSessionTemplate(id)) return false
|
|
131
|
-
log.warn(
|
|
132
|
-
`[pantheon-delegate] ${operation} skipped: unresolved session template "${String(id)}" — the host did not substitute the session id`,
|
|
133
|
-
)
|
|
134
|
-
return true
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
type BootstrapOutcome =
|
|
138
|
-
| { status: 'started'; elapsedMs: number; reason: string }
|
|
139
|
-
| { status: 'startup_failed' | 'bootstrap_unknown'; elapsedMs: number; reason: string }
|
|
140
|
-
|
|
141
|
-
type PromptOutcome =
|
|
142
|
-
| { status: 'accepted'; value: unknown }
|
|
143
|
-
| { status: 'rejected'; reason: string }
|
|
144
|
-
| { status: 'timeout'; reason: string }
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Bound the SDK request itself, not just the board watchdog. The AbortSignal
|
|
148
|
-
* is passed to hosts that support it; the race also protects older hosts that
|
|
149
|
-
* ignore the signal and never settle their promise.
|
|
150
|
-
*/
|
|
151
|
-
async function promptWithTimeout(
|
|
152
|
-
client: DelegationClient,
|
|
153
|
-
input: Parameters<DelegationClient['session']['promptAsync']>[0],
|
|
154
|
-
timeoutMs: number,
|
|
155
|
-
): Promise<PromptOutcome> {
|
|
156
|
-
if (refuseUnresolvedSessionTemplate(input.path.id, 'promptAsync')) {
|
|
157
|
-
return { status: 'rejected', reason: `invalid sessionID: ${input.path.id}` }
|
|
158
|
-
}
|
|
159
|
-
const controller = new AbortController()
|
|
160
|
-
let timer: NodeJS.Timeout | undefined
|
|
161
|
-
const operation = client.session.promptAsync({ ...input, signal: controller.signal }).then(
|
|
162
|
-
(value) => ({ status: 'accepted' as const, value }),
|
|
163
|
-
(error: unknown) => ({
|
|
164
|
-
status: 'rejected' as const,
|
|
165
|
-
reason: error instanceof Error ? error.message : String(error),
|
|
166
|
-
}),
|
|
167
|
-
)
|
|
168
|
-
try {
|
|
169
|
-
return await Promise.race([
|
|
170
|
-
operation,
|
|
171
|
-
new Promise<PromptOutcome>((resolve) => {
|
|
172
|
-
timer = setTimeout(() => {
|
|
173
|
-
controller.abort()
|
|
174
|
-
resolve({ status: 'timeout', reason: `promptAsync timed out after ${timeoutMs}ms` })
|
|
175
|
-
}, timeoutMs)
|
|
176
|
-
timer.unref()
|
|
177
|
-
}),
|
|
178
|
-
])
|
|
179
|
-
} finally {
|
|
180
|
-
if (timer !== undefined) clearTimeout(timer)
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const RUNNING_SESSION_STATES = new Set(['busy', 'running', 'processing', 'retry', 'pending'])
|
|
185
|
-
|
|
186
|
-
async function boundedProbe<T>(operation: Promise<T>, timeoutMs: number): Promise<T | undefined> {
|
|
187
|
-
if (timeoutMs <= 0) return undefined
|
|
188
|
-
let timer: NodeJS.Timeout | undefined
|
|
189
|
-
try {
|
|
190
|
-
return await Promise.race([
|
|
191
|
-
operation,
|
|
192
|
-
new Promise<undefined>((resolve) => {
|
|
193
|
-
timer = setTimeout(() => resolve(undefined), timeoutMs)
|
|
194
|
-
timer.unref()
|
|
195
|
-
}),
|
|
196
|
-
])
|
|
197
|
-
} finally {
|
|
198
|
-
if (timer !== undefined) clearTimeout(timer)
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function isRunningSession(value: unknown): boolean {
|
|
203
|
-
if (typeof value !== 'object' || value === null) return false
|
|
204
|
-
const record = value as Record<string, unknown>
|
|
205
|
-
const state = record.status ?? record.state
|
|
206
|
-
return typeof state === 'string' && RUNNING_SESSION_STATES.has(state.toLowerCase())
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function hasDurableMessage(value: unknown): boolean {
|
|
210
|
-
if (!Array.isArray(value)) return false
|
|
211
|
-
return value.some((bundle) => {
|
|
212
|
-
if (typeof bundle !== 'object' || bundle === null) return false
|
|
213
|
-
const info = (bundle as { info?: { role?: unknown } }).info
|
|
214
|
-
return info?.role === 'user' || info?.role === 'assistant'
|
|
215
|
-
})
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
async function bootstrapChild(
|
|
219
|
-
client: DelegationClient,
|
|
220
|
-
childSessionID: string,
|
|
221
|
-
accepted: unknown,
|
|
222
|
-
options: DelegationOptions,
|
|
223
|
-
): Promise<BootstrapOutcome> {
|
|
224
|
-
const now = options.now ?? Date.now
|
|
225
|
-
const sleep =
|
|
226
|
-
options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
|
|
227
|
-
const timeoutMs = options.bootstrapTimeoutMs ?? BOOTSTRAP_TIMEOUT_MS
|
|
228
|
-
const intervalMs = options.bootstrapPollIntervalMs ?? BOOTSTRAP_POLL_INTERVAL_MS
|
|
229
|
-
const startedAt = now()
|
|
230
|
-
const hasMessagesAPI = typeof client.session.messages === 'function'
|
|
231
|
-
const hasStatusAPI = typeof client.session.get === 'function'
|
|
232
|
-
const acceptedRunning = isRunningSession(accepted)
|
|
233
|
-
|
|
234
|
-
if (refuseUnresolvedSessionTemplate(childSessionID, 'bootstrap probe')) {
|
|
235
|
-
return {
|
|
236
|
-
status: 'bootstrap_unknown',
|
|
237
|
-
elapsedMs: 0,
|
|
238
|
-
reason: `invalid child sessionID "${childSessionID}"`,
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (!hasMessagesAPI && !hasStatusAPI) {
|
|
243
|
-
return acceptedRunning
|
|
244
|
-
? {
|
|
245
|
-
status: 'started',
|
|
246
|
-
elapsedMs: now() - startedAt,
|
|
247
|
-
reason: 'prompt accepted by legacy host',
|
|
248
|
-
}
|
|
249
|
-
: {
|
|
250
|
-
status: 'bootstrap_unknown',
|
|
251
|
-
elapsedMs: now() - startedAt,
|
|
252
|
-
reason:
|
|
253
|
-
'host exposes neither session.messages nor session.get and prompt had no running evidence',
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
let sawProbe = false
|
|
258
|
-
while (now() - startedAt < timeoutMs) {
|
|
259
|
-
let durable = false
|
|
260
|
-
let running = acceptedRunning
|
|
261
|
-
const remainingMs = timeoutMs - (now() - startedAt)
|
|
262
|
-
if (hasMessagesAPI) {
|
|
263
|
-
try {
|
|
264
|
-
const messagesAPI = client.session.messages
|
|
265
|
-
if (messagesAPI === undefined) continue
|
|
266
|
-
const messages = await boundedProbe(
|
|
267
|
-
messagesAPI({ path: { id: childSessionID } }),
|
|
268
|
-
remainingMs,
|
|
269
|
-
)
|
|
270
|
-
if (messages !== undefined) {
|
|
271
|
-
sawProbe = true
|
|
272
|
-
durable = hasDurableMessage(messages)
|
|
273
|
-
}
|
|
274
|
-
} catch {
|
|
275
|
-
// A transient endpoint failure is diagnosed after the bounded window.
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
if (hasStatusAPI) {
|
|
279
|
-
try {
|
|
280
|
-
const statusAPI = client.session.get
|
|
281
|
-
if (statusAPI === undefined) continue
|
|
282
|
-
const status = await boundedProbe(
|
|
283
|
-
statusAPI({ path: { id: childSessionID } }),
|
|
284
|
-
timeoutMs - (now() - startedAt),
|
|
285
|
-
)
|
|
286
|
-
if (status !== undefined) {
|
|
287
|
-
running = running || isRunningSession(status)
|
|
288
|
-
sawProbe = true
|
|
289
|
-
}
|
|
290
|
-
} catch {
|
|
291
|
-
// Fail closed only after the bounded window; never create an unbounded poll.
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
if (durable)
|
|
295
|
-
return {
|
|
296
|
-
status: 'started',
|
|
297
|
-
elapsedMs: now() - startedAt,
|
|
298
|
-
reason: 'durable child message observed',
|
|
299
|
-
}
|
|
300
|
-
if (running)
|
|
301
|
-
return {
|
|
302
|
-
status: 'started',
|
|
303
|
-
elapsedMs: now() - startedAt,
|
|
304
|
-
reason: 'child session reports running/processing',
|
|
305
|
-
}
|
|
306
|
-
await sleep(Math.min(intervalMs, Math.max(0, timeoutMs - (now() - startedAt))))
|
|
307
|
-
}
|
|
308
|
-
return sawProbe
|
|
309
|
-
? {
|
|
310
|
-
status: 'startup_failed',
|
|
311
|
-
elapsedMs: now() - startedAt,
|
|
312
|
-
reason: `no durable message or running state after ${timeoutMs}ms`,
|
|
313
|
-
}
|
|
314
|
-
: {
|
|
315
|
-
status: 'bootstrap_unknown',
|
|
316
|
-
elapsedMs: now() - startedAt,
|
|
317
|
-
reason: `bootstrap probes were unavailable after ${timeoutMs}ms`,
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/** Resolve the two supported read-only delegation exception budgets. */
|
|
322
|
-
export function resolveDelegationBudgets(
|
|
323
|
-
env: Record<string, string | undefined> = process.env,
|
|
324
|
-
): ReadonlyMap<string, number> {
|
|
325
|
-
return new Map([
|
|
326
|
-
[
|
|
327
|
-
'athena->apollo',
|
|
328
|
-
readIntegerEnv(env, 'PANTHEON_ATHENA_APOLLO_BUDGET', B3_DEFAULT_EXCEPTION_BUDGET, 0),
|
|
329
|
-
],
|
|
330
|
-
[
|
|
331
|
-
'hermes->apollo',
|
|
332
|
-
readIntegerEnv(env, 'PANTHEON_HERMES_APOLLO_BUDGET', B3_DEFAULT_EXCEPTION_BUDGET, 0),
|
|
333
|
-
],
|
|
334
|
-
])
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/** Resolve an optional positive wall-clock timeout from the environment. */
|
|
338
|
-
export function resolveDelegationTimeoutMs(
|
|
339
|
-
env: Record<string, string | undefined> = process.env,
|
|
340
|
-
): number | undefined {
|
|
341
|
-
const raw = env.PANTHEON_DELEGATION_TIMEOUT_MS?.trim()
|
|
342
|
-
if (raw === undefined || raw === '') return undefined
|
|
343
|
-
const value = Number(raw)
|
|
344
|
-
return Number.isSafeInteger(value) && value > 0 ? value : undefined
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// ─── Types ─────────────────────────────────────────────────────────────
|
|
348
|
-
|
|
349
|
-
// Silence-by-default TUI policy (pantheon-hooks L42-58): console output in a
|
|
350
|
-
// plugin renders into the opencode TUI — the "lixo". Errors go to
|
|
351
|
-
// .pantheon/logs/hooks.log; console echo is opt-in via PANTHEON_HOOKS_LOG=1.
|
|
352
|
-
const log = createPantheonLogger({ module: 'pantheon-delegate' })
|
|
353
|
-
|
|
354
|
-
/** Structural view of the tool context opencode passes to execute(). */
|
|
355
|
-
export interface ToolContextLike {
|
|
356
|
-
sessionID: string
|
|
357
|
-
directory?: string
|
|
358
|
-
worktree?: string
|
|
359
|
-
/**
|
|
360
|
-
* The OpenCode SDK's ToolContext declares this as required. It remains
|
|
361
|
-
* optional here because the structural test/embedding surface can be
|
|
362
|
-
* supplied by older hosts; B3 must skip enforcement when it is absent.
|
|
363
|
-
*/
|
|
364
|
-
agent?: string
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/** A delegation tool: description + zod args shape + execute. */
|
|
368
|
-
export interface DelegationTool<Args extends z.ZodRawShape = z.ZodRawShape> {
|
|
369
|
-
description: string
|
|
370
|
-
args: Args
|
|
371
|
-
execute(args: z.infer<z.ZodObject<Args>>, ctx: ToolContextLike): Promise<string>
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/** The full toolset returned by createDelegationTools(). */
|
|
375
|
-
export interface DelegationToolset {
|
|
376
|
-
pantheon_delegate: DelegationTool<typeof delegateArgs>
|
|
377
|
-
pantheon_delegation_read: DelegationTool<typeof readArgs>
|
|
378
|
-
pantheon_delegation_list: DelegationTool<typeof listArgs>
|
|
379
|
-
/** Lifecycle hook for the Phase 3 event wiring (session.idle on the child). */
|
|
380
|
-
finalizeDelegation: (
|
|
381
|
-
childSessionID: string,
|
|
382
|
-
opts: FinalizeInput,
|
|
383
|
-
) => Promise<BackgroundJobRecord | undefined>
|
|
384
|
-
/** Proactive finalize for idle children without MD reports (Fix 2). */
|
|
385
|
-
finalizeIdleChildrenWithoutMd: typeof finalizeIdleChildrenWithoutMd
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** Input for createDelegationTools(). */
|
|
389
|
-
export interface CreateDelegationToolsInput {
|
|
390
|
-
board: BackgroundJobBoard
|
|
391
|
-
client: DelegationClient
|
|
392
|
-
options?: DelegationOptions
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// ─── Args schemas ──────────────────────────────────────────────────────
|
|
396
|
-
|
|
397
|
-
const delegateArgs = {
|
|
398
|
-
prompt: z.string().min(1).describe('Task prompt delivered to the background agent.'),
|
|
399
|
-
agent: z.string().min(1).describe('Agent name, e.g. "apollo" or "hermes".'),
|
|
400
|
-
description: z.string().optional().describe('Human-readable description shown on the job board.'),
|
|
401
|
-
read_only: z.boolean().optional().describe('Advisory flag for Phase 4 read-only enforcement.'),
|
|
402
|
-
model: z
|
|
403
|
-
.string()
|
|
404
|
-
.optional()
|
|
405
|
-
.describe(
|
|
406
|
-
'Explicit model for the child session (provider/model, e.g. "opencode/deepseek-v4-flash-free").',
|
|
407
|
-
),
|
|
408
|
-
} satisfies z.ZodRawShape
|
|
409
|
-
|
|
410
|
-
const readArgs = {
|
|
411
|
-
id: z.string().min(1).describe('Job alias (e.g. "apo-1") or task ID to read.'),
|
|
412
|
-
} satisfies z.ZodRawShape
|
|
413
|
-
|
|
414
|
-
const listArgs = {} satisfies z.ZodRawShape
|
|
415
|
-
|
|
416
|
-
// ─── State labels ──────────────────────────────────────────────────────
|
|
417
|
-
|
|
418
|
-
/** Canonical 14-agent roster (O5 tool-description default). */
|
|
419
|
-
export const CANONICAL_AGENTS: readonly string[] = [
|
|
420
|
-
'zeus',
|
|
421
|
-
'athena',
|
|
422
|
-
'apollo',
|
|
423
|
-
'hermes',
|
|
424
|
-
'aphrodite',
|
|
425
|
-
'demeter',
|
|
426
|
-
'themis',
|
|
427
|
-
'prometheus',
|
|
428
|
-
'hephaestus',
|
|
429
|
-
'nyx',
|
|
430
|
-
'gaia',
|
|
431
|
-
'iris',
|
|
432
|
-
'mnemosyne',
|
|
433
|
-
'talos',
|
|
434
|
-
]
|
|
435
|
-
|
|
436
|
-
function stateLabel(state: BackgroundJobRecord['state']): string {
|
|
437
|
-
switch (state) {
|
|
438
|
-
case 'running':
|
|
439
|
-
return 'RUNNING'
|
|
440
|
-
case 'completed':
|
|
441
|
-
return 'OK'
|
|
442
|
-
case 'error':
|
|
443
|
-
return 'ERR'
|
|
444
|
-
case 'startup_failed':
|
|
445
|
-
return 'STARTUP FAILED'
|
|
446
|
-
case 'startup_unknown':
|
|
447
|
-
return 'STARTUP UNKNOWN'
|
|
448
|
-
case 'cancelled':
|
|
449
|
-
return 'CANCELLED'
|
|
450
|
-
case 'reconciled':
|
|
451
|
-
return 'RECONCILED'
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// ─── B2: session-scoped delegation target resolution ───────────────────
|
|
456
|
-
//
|
|
457
|
-
// Board aliases restart per parent session (apo-1 in every root), so an alias
|
|
458
|
-
// lookup MUST be scoped to the calling session. `pantheon_delegation_read`
|
|
459
|
-
// previously delegated to the board's first-match resolver and, when the id
|
|
460
|
-
// was not found in the current session, the generic "Unknown delegation"
|
|
461
|
-
// message hid the real cause (the alias belonged to another session). This
|
|
462
|
-
// helper makes the scoping explicit and returns a CLEAR error for each
|
|
463
|
-
// cross-session / ambiguous case.
|
|
464
|
-
|
|
465
|
-
/** Resolution outcome for a delegation read by task ID or alias. */
|
|
466
|
-
export type DelegationTargetResolution =
|
|
467
|
-
| { kind: 'found'; job: BackgroundJobRecord }
|
|
468
|
-
| { kind: 'error'; message: string }
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* Resolve a `pantheon_delegation_read` target with strict session scoping:
|
|
472
|
-
* - exact task-ID match first (must belong to `sessionID`);
|
|
473
|
-
* - then alias matches WITHIN `sessionID` (exactly one required);
|
|
474
|
-
* - an alias that exists only in another session → clear cross-session
|
|
475
|
-
* error (never another session's job);
|
|
476
|
-
* - two matches in this session → clear ambiguity error.
|
|
477
|
-
*/
|
|
478
|
-
export function resolveDelegationTarget(
|
|
479
|
-
board: Pick<BackgroundJobBoard, 'list'>,
|
|
480
|
-
sessionID: string,
|
|
481
|
-
idOrAlias: string,
|
|
482
|
-
): DelegationTargetResolution {
|
|
483
|
-
const jobs = board.list()
|
|
484
|
-
|
|
485
|
-
const byTaskID = jobs.find((job) => job.taskID === idOrAlias)
|
|
486
|
-
if (byTaskID !== undefined) {
|
|
487
|
-
if (byTaskID.parentSessionID === sessionID) return { kind: 'found', job: byTaskID }
|
|
488
|
-
return {
|
|
489
|
-
kind: 'error',
|
|
490
|
-
message:
|
|
491
|
-
`Delegation "${idOrAlias}" belongs to a different session ` +
|
|
492
|
-
`(${byTaskID.parentSessionID}) and cannot be read from ${sessionID}.`,
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
const aliasMatches = jobs.filter((job) => job.alias === idOrAlias)
|
|
497
|
-
const inSession = aliasMatches.filter((job) => job.parentSessionID === sessionID)
|
|
498
|
-
if (inSession.length === 1) return { kind: 'found', job: inSession[0]! }
|
|
499
|
-
if (inSession.length > 1) {
|
|
500
|
-
return {
|
|
501
|
-
kind: 'error',
|
|
502
|
-
message:
|
|
503
|
-
`Delegation alias "${idOrAlias}" is ambiguous in session ${sessionID} ` +
|
|
504
|
-
`(${inSession.length} matches); read it by task ID instead.`,
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
if (aliasMatches.length > 0) {
|
|
508
|
-
return {
|
|
509
|
-
kind: 'error',
|
|
510
|
-
message:
|
|
511
|
-
`Delegation alias "${idOrAlias}" belongs to a different session — ` +
|
|
512
|
-
'aliases are session-scoped. Use pantheon_delegation_list in the session that created it.',
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
return {
|
|
516
|
-
kind: 'error',
|
|
517
|
-
message: `Unknown delegation "${idOrAlias}" for this session. Use pantheon_delegation_list to see active delegations.`,
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/** Normalize the SDK-provided caller agent without inventing an identity. */
|
|
522
|
-
const normalizeToolAgent = normalizeDelegationAgent
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* O5: the delegate tool description. When permission.task rules are
|
|
526
|
-
* configured, denied agents are REMOVED from the description entirely (not
|
|
527
|
-
* just blocked at call time) — the caller never sees them as invocable.
|
|
528
|
-
*/
|
|
529
|
-
function buildDelegateDescription(options: DelegationOptions): string {
|
|
530
|
-
const base =
|
|
531
|
-
'Dispatch a background agent as a child session and register it on the job board. ' +
|
|
532
|
-
'Returns the readable alias (e.g. "apo-1"); read the result with ' +
|
|
533
|
-
'pantheon_delegation_read. Only root sessions may delegate.'
|
|
534
|
-
if (options.permissionTask === undefined) return base
|
|
535
|
-
const roster = options.agentNames ?? CANONICAL_AGENTS
|
|
536
|
-
return `${base} Allowed subagents: ${buildAgentListDescription(options.permissionTask, roster)}.`
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// ─── Agent activity sampling ────────────────────────────────────────────
|
|
540
|
-
//
|
|
541
|
-
// Visibility for `pantheon_delegation_read` / `pantheon_delegation_list`
|
|
542
|
-
// (issue: the read blocks silently while the child works). While a read waits
|
|
543
|
-
// on `waitForTerminal` it periodically samples the CHILD session's messages
|
|
544
|
-
// via the same `client.session.messages` the finalize path uses, and the
|
|
545
|
-
// collected lines are appended to the report as `## Agent Activity`.
|
|
546
|
-
//
|
|
547
|
-
// Fail-open by design: a missing/throwing `session.messages` (or empty
|
|
548
|
-
// response) degrades to the current behavior — report without a section —
|
|
549
|
-
// and NEVER breaks the read. No streaming/SSE, no persistent timers: the
|
|
550
|
-
// poll lives inside the read's existing wait and is cleared when it settles.
|
|
551
|
-
|
|
552
|
-
/** Cap for one activity line (~200 chars, spec). */
|
|
553
|
-
const ACTIVITY_LINE_MAX = 200
|
|
554
|
-
/** How often the read re-samples child activity while waiting (ms). */
|
|
555
|
-
const ACTIVITY_POLL_MS = 2000
|
|
556
|
-
/** Keep only the latest N readable entries per sample. */
|
|
557
|
-
const ACTIVITY_LINES = 3
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* Mutable collector shared between the wait loop and the read result.
|
|
561
|
-
* `sampled` distinguishes "messages unavailable/never succeeded" (fail-open
|
|
562
|
-
* → NO activity section, current behavior) from "messages OK but nothing
|
|
563
|
-
* readable" (→ `_no activity captured_` section).
|
|
564
|
-
*/
|
|
565
|
-
interface ActivityCollector {
|
|
566
|
-
/** Readable activity lines captured so far (latest window). */
|
|
567
|
-
lines: string[]
|
|
568
|
-
/** True once session.messages returned a bundle list at least once. */
|
|
569
|
-
sampled: boolean
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/** Collapse whitespace and cap a single line at ~200 chars. */
|
|
573
|
-
function truncateActivityLine(text: string, max = ACTIVITY_LINE_MAX): string {
|
|
574
|
-
const clean = text.replace(/\s+/g, ' ').trim()
|
|
575
|
-
if (clean.length <= max) return clean
|
|
576
|
-
return `${clean.slice(0, max - 1)}…`
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
/** Readable tool args from a tool part (SDK ToolPart.metadata.input), or ''. */
|
|
580
|
-
function toolArgsFromPart(part: { metadata?: { input?: unknown } }): string {
|
|
581
|
-
const meta = part.metadata
|
|
582
|
-
const raw = meta !== undefined && 'input' in meta ? meta.input : meta
|
|
583
|
-
if (raw === undefined) return ''
|
|
584
|
-
try {
|
|
585
|
-
const s = typeof raw === 'string' ? raw : JSON.stringify(raw)
|
|
586
|
-
return s === '' || s === '{}' ? '' : s
|
|
587
|
-
} catch {
|
|
588
|
-
return ''
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/**
|
|
593
|
-
* One readable activity line per message bundle — tool calls first (highest
|
|
594
|
-
* signal), then the first non-empty text part. Image/attachment/reasoning
|
|
595
|
-
* parts are skipped (only `text` and `tool` parts are user-visible).
|
|
596
|
-
*/
|
|
597
|
-
function activityLineFromBundle(bundle: DelegationMessageBundle): string | undefined {
|
|
598
|
-
const role = bundle.info?.role
|
|
599
|
-
const parts = bundle.parts ?? []
|
|
600
|
-
for (const part of parts) {
|
|
601
|
-
if (part.type === 'tool' && typeof part.tool === 'string' && part.tool !== '') {
|
|
602
|
-
const args = toolArgsFromPart(part)
|
|
603
|
-
return truncateActivityLine(`tool: ${part.tool}${args !== '' ? ` ${args}` : ''}`)
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
for (const part of parts) {
|
|
607
|
-
if (part.type === 'text' && typeof part.text === 'string' && part.text.trim() !== '') {
|
|
608
|
-
const label = role === 'user' ? 'user' : 'assistant'
|
|
609
|
-
return truncateActivityLine(`${label}: ${part.text.trim()}`)
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
return undefined
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* Sample the child's latest readable activity into `collector` (replace
|
|
617
|
-
* window). Fail-open: unavailable/throwing messages leaves the collector
|
|
618
|
-
* untouched (previous lines, or none → `_no activity captured_`), while a
|
|
619
|
-
* successful (even empty) response marks the collector as sampled.
|
|
620
|
-
*/
|
|
621
|
-
async function sampleChildActivity(
|
|
622
|
-
client: DelegationClient,
|
|
623
|
-
childSessionID: string,
|
|
624
|
-
collector: ActivityCollector,
|
|
625
|
-
): Promise<void> {
|
|
626
|
-
if (typeof client.session.messages !== 'function') return
|
|
627
|
-
if (refuseUnresolvedSessionTemplate(childSessionID, 'activity sample')) return
|
|
628
|
-
try {
|
|
629
|
-
const bundles = await client.session.messages({ path: { id: childSessionID } })
|
|
630
|
-
if (!Array.isArray(bundles)) return
|
|
631
|
-
collector.sampled = true
|
|
632
|
-
if (bundles.length === 0) return
|
|
633
|
-
const fresh = bundles
|
|
634
|
-
.map((b) => activityLineFromBundle(b))
|
|
635
|
-
.filter((l): l is string => l !== undefined)
|
|
636
|
-
.slice(-ACTIVITY_LINES)
|
|
637
|
-
if (fresh.length > 0) {
|
|
638
|
-
collector.lines.length = 0
|
|
639
|
-
collector.lines.push(...fresh)
|
|
640
|
-
}
|
|
641
|
-
} catch (err: unknown) {
|
|
642
|
-
const reason = err instanceof Error ? err.message : String(err)
|
|
643
|
-
log.warn(`[pantheon-delegate] activity sample failed (fail-open): ${reason}`)
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
/** Last readable activity line for a child, or undefined when unavailable. */
|
|
648
|
-
async function lastChildActivity(
|
|
649
|
-
client: DelegationClient,
|
|
650
|
-
childSessionID: string,
|
|
651
|
-
): Promise<string | undefined> {
|
|
652
|
-
if (typeof client.session.messages !== 'function') return undefined
|
|
653
|
-
if (refuseUnresolvedSessionTemplate(childSessionID, 'last activity fetch')) return undefined
|
|
654
|
-
try {
|
|
655
|
-
const bundles = await client.session.messages({ path: { id: childSessionID } })
|
|
656
|
-
if (!Array.isArray(bundles)) return undefined
|
|
657
|
-
for (let i = bundles.length - 1; i >= 0; i--) {
|
|
658
|
-
const bundle = bundles[i]
|
|
659
|
-
if (bundle === undefined) continue
|
|
660
|
-
const line = activityLineFromBundle(bundle)
|
|
661
|
-
if (line !== undefined) return line
|
|
662
|
-
}
|
|
663
|
-
return undefined
|
|
664
|
-
} catch (err: unknown) {
|
|
665
|
-
const reason = err instanceof Error ? err.message : String(err)
|
|
666
|
-
log.warn(`[pantheon-delegate] last activity fetch failed (fail-open): ${reason}`)
|
|
667
|
-
return undefined
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/**
|
|
672
|
-
* Wait for a terminal board state while sampling the child's activity every
|
|
673
|
-
* ACTIVITY_POLL_MS (first sample fires immediately — a quick finalize can
|
|
674
|
-
* still capture something). The interval is cleared when the wait settles;
|
|
675
|
-
* collection never delays the terminal resolution.
|
|
676
|
-
*/
|
|
677
|
-
async function waitForTerminalWithActivity(
|
|
678
|
-
board: BackgroundJobBoard,
|
|
679
|
-
client: DelegationClient,
|
|
680
|
-
taskID: string,
|
|
681
|
-
timeoutMs: number,
|
|
682
|
-
collector: ActivityCollector,
|
|
683
|
-
): Promise<BackgroundJobRecord> {
|
|
684
|
-
void sampleChildActivity(client, taskID, collector)
|
|
685
|
-
const timer = setInterval(() => {
|
|
686
|
-
void sampleChildActivity(client, taskID, collector)
|
|
687
|
-
}, ACTIVITY_POLL_MS)
|
|
688
|
-
timer.unref()
|
|
689
|
-
try {
|
|
690
|
-
return await board.waitForTerminal(taskID, timeoutMs)
|
|
691
|
-
} finally {
|
|
692
|
-
clearInterval(timer)
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/** Render the trailing activity section appended to the read result. */
|
|
697
|
-
export function formatActivitySection(lines: string[]): string {
|
|
698
|
-
if (lines.length === 0) return '## Agent Activity\n\n_no activity captured_'
|
|
699
|
-
return `## Agent Activity\n\n${lines.map((l) => `- ${l}`).join('\n')}`
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// ─── Model resolution ──────────────────────────────────────────────────
|
|
703
|
-
|
|
704
|
-
/** Session model ref accepted by the opencode server's session.create. */
|
|
705
|
-
export interface ChildSessionModelRef {
|
|
706
|
-
id: string
|
|
707
|
-
providerID: string
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
const MODEL_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:+-]*$/
|
|
711
|
-
const MODEL_REF_HINT =
|
|
712
|
-
'Expected a non-empty "provider/model-id" reference with exactly one slash, no whitespace/control characters, backslashes, or path traversal. Remove the override or use that format.'
|
|
713
|
-
|
|
714
|
-
type ModelOverrideSource = 'explicit' | 'agentModels'
|
|
715
|
-
|
|
716
|
-
function invalidModelOverride(source: ModelOverrideSource): string {
|
|
717
|
-
return `pantheon_delegate rejected: invalid ${source} model override. ${MODEL_REF_HINT}`
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
/**
|
|
721
|
-
* Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
|
|
722
|
-
* into the `{ id, providerID }` ref the session.create body expects.
|
|
723
|
-
* Returns undefined for malformed or unsafe IDs.
|
|
724
|
-
*/
|
|
725
|
-
function splitModelRef(model: unknown): ChildSessionModelRef | undefined {
|
|
726
|
-
if (typeof model !== 'string' || !MODEL_REF_PATTERN.test(model)) return undefined
|
|
727
|
-
const slash = model.indexOf('/')
|
|
728
|
-
return { providerID: model.slice(0, slash), id: model.slice(slash + 1) }
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
/**
|
|
732
|
-
* Resolve the child session model from explicit or agent-specific overrides.
|
|
733
|
-
* When neither override is present, return undefined so OpenCode can apply
|
|
734
|
-
* native model inheritance.
|
|
735
|
-
*/
|
|
736
|
-
function resolveChildModel(
|
|
737
|
-
options: DelegationOptions,
|
|
738
|
-
agent: string,
|
|
739
|
-
explicitModel?: string,
|
|
740
|
-
): { model: ChildSessionModelRef | undefined; error: string | undefined } {
|
|
741
|
-
// (a) explicit model option on the delegate tool call
|
|
742
|
-
if (explicitModel !== undefined) {
|
|
743
|
-
const ref = splitModelRef(explicitModel)
|
|
744
|
-
if (ref !== undefined) return { model: ref, error: undefined }
|
|
745
|
-
return { model: undefined, error: invalidModelOverride('explicit') }
|
|
746
|
-
}
|
|
747
|
-
const key = agent.toLowerCase()
|
|
748
|
-
// (b) agent-specific model override (routing.yml agent entry via agentModels)
|
|
749
|
-
const mapped = options.agentModels?.[key]
|
|
750
|
-
if (mapped !== undefined) {
|
|
751
|
-
const ref = splitModelRef(mapped)
|
|
752
|
-
if (ref !== undefined) return { model: ref, error: undefined }
|
|
753
|
-
return { model: undefined, error: invalidModelOverride('agentModels') }
|
|
754
|
-
}
|
|
755
|
-
return { model: undefined, error: undefined }
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
/**
|
|
759
|
-
* Resolve a child model without selecting a provider or model on the caller's
|
|
760
|
-
* behalf. OpenCode owns native inheritance when this returns no model; if the
|
|
761
|
-
* host requires one, its session.create/promptAsync error is returned with
|
|
762
|
-
* the normal diagnostic rather than silently choosing a fallback.
|
|
763
|
-
*
|
|
764
|
-
* @returns `{ model, error }` — at most one of `model` / `error` is set;
|
|
765
|
-
* both may be unset (no model resolved, warn emitted).
|
|
766
|
-
*/
|
|
767
|
-
function resolveUsableChildModel(
|
|
768
|
-
options: DelegationOptions,
|
|
769
|
-
agent: string,
|
|
770
|
-
explicitModel: string | undefined,
|
|
771
|
-
): { model: ChildSessionModelRef | undefined; error: string | undefined } {
|
|
772
|
-
const resolved = resolveChildModel(options, agent, explicitModel)
|
|
773
|
-
if (resolved.error !== undefined || resolved.model !== undefined) return resolved
|
|
774
|
-
options.logger?.warn?.(
|
|
775
|
-
`[pantheon-delegate] no model resolved for agent "${agent}" — ` +
|
|
776
|
-
'omitting model so OpenCode can inherit it from the parent session',
|
|
777
|
-
)
|
|
778
|
-
return resolved
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// ─── Factory ───────────────────────────────────────────────────────────
|
|
782
|
-
|
|
783
|
-
/**
|
|
784
|
-
* Pure helper: collect the IDs of root sessions (parentID === undefined)
|
|
785
|
-
* from a session list. Used to SEED the depth-guard allowlist at startup —
|
|
786
|
-
* opencode does not replay `session.created` events for sessions that exist
|
|
787
|
-
* before plugin load, so a resumed root would never enter `rootSessions`
|
|
788
|
-
* and the depth guard would reject its pantheon_delegate calls after a
|
|
789
|
-
* restart. Fail-open contract lives at the call site (plugin.ts): a failed
|
|
790
|
-
* `session.list()` leaves the registry untouched, never crashes startup.
|
|
791
|
-
*/
|
|
792
|
-
export function collectRootSessionIDs(
|
|
793
|
-
sessions: ReadonlyArray<{ id: string; parentID?: string }>,
|
|
794
|
-
): Set<string> {
|
|
795
|
-
const roots = new Set<string>()
|
|
796
|
-
for (const session of sessions) {
|
|
797
|
-
if (session.parentID === undefined) roots.add(session.id)
|
|
798
|
-
}
|
|
799
|
-
return roots
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
/**
|
|
803
|
-
* Build the delegation toolset. Keeps per-instance state: the set of child
|
|
804
|
-
* sessions WE created (depth-guard default) and the per-job timeout timers
|
|
805
|
-
* (cleared on finalize, `.unref()`'d so they never hold the process open).
|
|
806
|
-
*/
|
|
807
|
-
export function createDelegationTools(input: CreateDelegationToolsInput): DelegationToolset {
|
|
808
|
-
const { board, client } = input
|
|
809
|
-
// Runtime matrix enforcement is the safe default for every host. A legacy
|
|
810
|
-
// structural embedder must opt out explicitly with `false`; omission must
|
|
811
|
-
// never silently widen the delegation surface.
|
|
812
|
-
const options: DelegationOptions = { enforceRuntimeMatrix: true, ...(input.options ?? {}) }
|
|
813
|
-
const deps: DelegationDeps = { board, client, options }
|
|
814
|
-
const outputDir = options.outputDir ?? DELEGATION_DEFAULTS.outputDir
|
|
815
|
-
const timeoutMs = options.wallClockTimeoutMs ?? options.timeoutMs ?? DELEGATION_DEFAULTS.timeoutMs
|
|
816
|
-
const readTimeoutMs = options.readTimeoutMs ?? DELEGATION_DEFAULTS.readTimeoutMs
|
|
817
|
-
|
|
818
|
-
const knownChildren = new Set<string>()
|
|
819
|
-
const timers = new Map<string, NodeJS.Timeout>()
|
|
820
|
-
// Inline cap for report content returned by pantheon_delegation_read —
|
|
821
|
-
// oversized reports keep head+tail with an explicit marker pointing at the
|
|
822
|
-
// full report file (never a silent mid-content cut).
|
|
823
|
-
const inlineMaxChars = options.readMaxChars ?? resolveReadMaxChars()
|
|
824
|
-
// Fase B3: per-parent-session delegation budget tracking, scoped to this
|
|
825
|
-
// factory/process instance. Keyed by `${parentSessionID}:${parentAgent}->${targetAgent}`;
|
|
826
|
-
// it is not restart-persistent because the factory has no reliable store.
|
|
827
|
-
const budgetUsage = new Map<string, number>()
|
|
828
|
-
|
|
829
|
-
function isRootSession(sessionID: string): boolean {
|
|
830
|
-
if (options.isChildSession?.(sessionID) === true) return false
|
|
831
|
-
if (options.isRootSession !== undefined) return options.isRootSession(sessionID)
|
|
832
|
-
if (options.rootSessions !== undefined) {
|
|
833
|
-
// compaction-134 (fix layer 2): the allowlist alone is NOT
|
|
834
|
-
// authoritative. opencode does not replay `session.created` events for
|
|
835
|
-
// pre-existing sessions, so after a restart the resumed root never
|
|
836
|
-
// entered rootSessions (the plugin seeds it from session.list() at
|
|
837
|
-
// startup, but that seed is fail-open). A session is a sub-session
|
|
838
|
-
// ONLY if WE created it as a child (knownChildren — filled on every
|
|
839
|
-
// pantheon_delegate dispatch). Everything unknown is treated as root:
|
|
840
|
-
// resumed/unknown sessions can delegate, while the real depth guard
|
|
841
|
-
// (a child of a delegate cannot re-delegate) is preserved.
|
|
842
|
-
return options.rootSessions.has(sessionID) || !knownChildren.has(sessionID)
|
|
843
|
-
}
|
|
844
|
-
return !knownChildren.has(sessionID)
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
function clearTimer(childSessionID: string): void {
|
|
848
|
-
const timer = timers.get(childSessionID)
|
|
849
|
-
if (timer !== undefined) {
|
|
850
|
-
clearTimeout(timer)
|
|
851
|
-
timers.delete(childSessionID)
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
/** Bound finalize: clear the job timer, then run the report + transition. */
|
|
856
|
-
async function finalize(
|
|
857
|
-
childSessionID: string,
|
|
858
|
-
opts: FinalizeInput,
|
|
859
|
-
): Promise<BackgroundJobRecord | undefined> {
|
|
860
|
-
clearTimer(childSessionID)
|
|
861
|
-
return finalizeDelegationReport(deps, childSessionID, opts)
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
// Kill-switch gate: when delegationEnabled is explicitly false (mirrors
|
|
865
|
-
// PANTHEON_DELEGATION=off from the native manager), all legacy tools throw.
|
|
866
|
-
function guardEnabled(): void {
|
|
867
|
-
if (options.delegationEnabled === false) {
|
|
868
|
-
throw new Error('pantheon delegation disabled (PANTHEON_DELEGATION=off)')
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
/**
|
|
873
|
-
* Best-effort teardown of a child session whose `registerLaunch` was refused
|
|
874
|
-
* because the per-agent concurrency limit was hit between the pre-check and
|
|
875
|
-
* the registration (a race with another concurrent dispatch). Removes every
|
|
876
|
-
* side effect of the aborted dispatch — known-child marker, read-only
|
|
877
|
-
* registry entry and any reserved budget slot — then deletes the session when
|
|
878
|
-
* the host client exposes `session.delete`. Never throws: cleanup must not
|
|
879
|
-
* mask the original rejection.
|
|
880
|
-
*/
|
|
881
|
-
async function abortRejectedLaunch(
|
|
882
|
-
childSessionID: string,
|
|
883
|
-
wasReadOnly: boolean,
|
|
884
|
-
refund?: { key: string | undefined; reserved: boolean },
|
|
885
|
-
): Promise<void> {
|
|
886
|
-
knownChildren.delete(childSessionID)
|
|
887
|
-
if (wasReadOnly) readOnlyRegistry.unregister(childSessionID)
|
|
888
|
-
if (refund?.reserved && refund.key !== undefined) {
|
|
889
|
-
budgetUsage.set(refund.key, Math.max(0, (budgetUsage.get(refund.key) ?? 1) - 1))
|
|
890
|
-
}
|
|
891
|
-
// The plugin's adapted client (adaptDelegationClient) does not expose
|
|
892
|
-
// session.delete, and older hosts may not either. Delete best-effort so the
|
|
893
|
-
// abandoned session is not left behind; the registry cleanup above already
|
|
894
|
-
// removes the side effects that matter for read-only enforcement.
|
|
895
|
-
const session = client.session as {
|
|
896
|
-
delete?: (input: { path: { id: string } }) => Promise<unknown>
|
|
897
|
-
}
|
|
898
|
-
if (typeof session.delete !== 'function') return
|
|
899
|
-
const sessionPath = safeSessionPath(childSessionID)
|
|
900
|
-
if (sessionPath === null) {
|
|
901
|
-
log.warn(`[pantheon-delegate] session.delete skipped: invalid sessionID "${childSessionID}"`)
|
|
902
|
-
return
|
|
903
|
-
}
|
|
904
|
-
try {
|
|
905
|
-
await session.delete(sessionPath)
|
|
906
|
-
} catch (err: unknown) {
|
|
907
|
-
const reason = err instanceof Error ? err.message : String(err)
|
|
908
|
-
log.warn(`[pantheon-delegate] failed to delete orphan session ${childSessionID}: ${reason}`)
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
const pantheon_delegate: DelegationTool<typeof delegateArgs> = {
|
|
913
|
-
description: buildDelegateDescription(options),
|
|
914
|
-
args: delegateArgs,
|
|
915
|
-
execute: async (args, ctx) => {
|
|
916
|
-
guardEnabled()
|
|
917
|
-
if (!isRootSession(ctx.sessionID)) {
|
|
918
|
-
throw new Error(
|
|
919
|
-
`pantheon_delegate rejected: session ${ctx.sessionID} is a sub-session — only root sessions can delegate`,
|
|
920
|
-
)
|
|
921
|
-
}
|
|
922
|
-
if (!board.canDispatch(args.agent)) {
|
|
923
|
-
throw new Error(
|
|
924
|
-
`pantheon_delegate rejected: concurrency limit reached for agent "${args.agent}" ` +
|
|
925
|
-
`(${board.getRunningCount(args.agent)} running)`,
|
|
926
|
-
)
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
// R4: per-agent step caps. An agent already at max_steps is forced to
|
|
930
|
-
// summarize-and-stop — the delegation is skipped with a capped summary
|
|
931
|
-
// (no session, no board job, no budget consumed). Otherwise record the
|
|
932
|
-
// step; when THIS step hits the cap, append the stop instruction to
|
|
933
|
-
// the prompt so the agent summarizes and stops instead of continuing.
|
|
934
|
-
let effectivePrompt = args.prompt
|
|
935
|
-
const stepCap = options.stepCapTracker
|
|
936
|
-
if (stepCap !== undefined) {
|
|
937
|
-
const maxSteps = stepCap.maxStepsFor(args.agent)
|
|
938
|
-
if (stepCap.isCapped(args.agent)) {
|
|
939
|
-
return cappedSummary(args.agent, maxSteps ?? DEFAULT_MAX_STEPS)
|
|
940
|
-
}
|
|
941
|
-
const rec = stepCap.recordStep(args.agent)
|
|
942
|
-
if (rec.capped && rec.maxSteps !== undefined) {
|
|
943
|
-
effectivePrompt = `${args.prompt}${buildStopInstruction(args.agent, rec.maxSteps)}`
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
// Fase B3: only the two read-only exceptions are budgeted. The official
|
|
948
|
-
// SDK provides the current agent on ToolContext. If a structural/older
|
|
949
|
-
// host omits it, do NOT infer identity from prompt, target, or process
|
|
950
|
-
// state: the budget is explicitly skipped and the delegation continues.
|
|
951
|
-
const parentAgent = normalizeToolAgent(ctx.agent)
|
|
952
|
-
const targetAgent = args.agent.toLowerCase()
|
|
953
|
-
if (
|
|
954
|
-
options.enforceRuntimeMatrix === true &&
|
|
955
|
-
!isDelegationAllowed(parentAgent, targetAgent, options.permissionTask)
|
|
956
|
-
) {
|
|
957
|
-
const reason =
|
|
958
|
-
parentAgent === undefined
|
|
959
|
-
? 'ToolContext.agent is unavailable'
|
|
960
|
-
: `${parentAgent}->${targetAgent} is not allowed`
|
|
961
|
-
options.logger?.warn?.(
|
|
962
|
-
`[pantheon-delegate] runtime delegation denied for session ${ctx.sessionID}: ${reason}`,
|
|
963
|
-
)
|
|
964
|
-
throw new Error(`pantheon_delegate rejected: runtime delegation matrix denied (${reason})`)
|
|
965
|
-
}
|
|
966
|
-
if (
|
|
967
|
-
parentAgent === undefined &&
|
|
968
|
-
!options.enforceRuntimeMatrix &&
|
|
969
|
-
(options.delegationBudgets?.size ?? 0) > 0
|
|
970
|
-
) {
|
|
971
|
-
const message =
|
|
972
|
-
`[pantheon-delegate] B3 budget skipped: current agent unavailable for session ${ctx.sessionID}; ` +
|
|
973
|
-
`no budget limit applied to ${targetAgent}. Runtime must provide ToolContext.agent ` +
|
|
974
|
-
'(the official SDK field) to enforce the configured exception budget.'
|
|
975
|
-
;(options.logger?.warn ?? ((msg: string) => log.warn(msg)))(message)
|
|
976
|
-
}
|
|
977
|
-
const budgetKey =
|
|
978
|
-
parentAgent === undefined ? undefined : `${ctx.sessionID}:${parentAgent}->${targetAgent}`
|
|
979
|
-
const budget =
|
|
980
|
-
parentAgent === undefined
|
|
981
|
-
? undefined
|
|
982
|
-
: options.delegationBudgets?.get(`${parentAgent}->${targetAgent}`)
|
|
983
|
-
let budgetReserved = false
|
|
984
|
-
if (budgetKey !== undefined && budget !== undefined) {
|
|
985
|
-
const used = budgetUsage.get(budgetKey) ?? 0
|
|
986
|
-
if (used >= budget) {
|
|
987
|
-
throw new Error(
|
|
988
|
-
`pantheon_delegate rejected: delegation budget exhausted for ${parentAgent}->${targetAgent} ` +
|
|
989
|
-
`(${used}/${budget} dispatches used in this session). ` +
|
|
990
|
-
'Configure the corresponding PANTHEON_*_APOLLO_BUDGET environment variable or DelegationOptions.',
|
|
991
|
-
)
|
|
992
|
-
}
|
|
993
|
-
// Reserve before the first await so concurrent calls cannot overshoot.
|
|
994
|
-
budgetUsage.set(budgetKey, used + 1)
|
|
995
|
-
budgetReserved = true
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
// session.create failure → return a clear error as TEXT (tools return
|
|
999
|
-
// errors as text, not thrown) and register NO job on the board — an
|
|
1000
|
-
// unhandled rejection here would otherwise lose the failure entirely.
|
|
1001
|
-
// Resolution order: explicit delegate model > active-preset agent model
|
|
1002
|
-
// > omit (native inheritance). small_model is never used for delegated
|
|
1003
|
-
// children.
|
|
1004
|
-
const resolved = resolveUsableChildModel(options, args.agent, args.model)
|
|
1005
|
-
if (resolved.error !== undefined) {
|
|
1006
|
-
if (budgetReserved && budgetKey !== undefined)
|
|
1007
|
-
budgetUsage.set(budgetKey, (budgetUsage.get(budgetKey) ?? 1) - 1)
|
|
1008
|
-
return resolved.error
|
|
1009
|
-
}
|
|
1010
|
-
const childModel = resolved.model
|
|
1011
|
-
let created: { id: string }
|
|
1012
|
-
try {
|
|
1013
|
-
created = await client.session.create({
|
|
1014
|
-
body: {
|
|
1015
|
-
parentID: ctx.sessionID,
|
|
1016
|
-
title: args.description ?? effectivePrompt.slice(0, 80),
|
|
1017
|
-
...(childModel !== undefined ? { model: childModel } : {}),
|
|
1018
|
-
},
|
|
1019
|
-
})
|
|
1020
|
-
} catch (err: unknown) {
|
|
1021
|
-
const reason = err instanceof Error ? err.message : String(err)
|
|
1022
|
-
if (budgetReserved && budgetKey !== undefined) {
|
|
1023
|
-
budgetUsage.set(budgetKey, Math.max(0, (budgetUsage.get(budgetKey) ?? 1) - 1))
|
|
1024
|
-
}
|
|
1025
|
-
return `pantheon_delegate failed to create child session: ${reason}`
|
|
1026
|
-
}
|
|
1027
|
-
const childSessionID = created.id
|
|
1028
|
-
// B1: a host that passes through the unresolved template must never
|
|
1029
|
-
// produce a board job / bootstrap poll — reject it here with a clear
|
|
1030
|
-
// message and refund the reserved budget (no child was started).
|
|
1031
|
-
if (isUnresolvedSessionTemplate(childSessionID)) {
|
|
1032
|
-
if (budgetReserved && budgetKey !== undefined) {
|
|
1033
|
-
budgetUsage.set(budgetKey, Math.max(0, (budgetUsage.get(budgetKey) ?? 1) - 1))
|
|
1034
|
-
}
|
|
1035
|
-
log.warn(
|
|
1036
|
-
`[pantheon-delegate] session.create returned unresolved session template "${childSessionID}" — child ignored`,
|
|
1037
|
-
)
|
|
1038
|
-
return `pantheon_delegate failed: host returned an unresolved child session template ("${childSessionID}"); expected an id starting with "ses".`
|
|
1039
|
-
}
|
|
1040
|
-
knownChildren.add(childSessionID)
|
|
1041
|
-
options.registerChildSession?.(childSessionID, ctx.sessionID)
|
|
1042
|
-
|
|
1043
|
-
// Phase 4 read-only enforcement: register the child session in the
|
|
1044
|
-
// read-only registry when the delegate is read-only — explicit
|
|
1045
|
-
// `read_only: true` on the call, or the agent ∈ readOnlyAgents
|
|
1046
|
-
// (case-insensitive). The plugin's tool.execute.before guard then
|
|
1047
|
-
// denies edit/write/bash/task inside that session.
|
|
1048
|
-
const readOnly =
|
|
1049
|
-
args.read_only === true || (options.readOnlyAgents?.has(args.agent.toLowerCase()) ?? false)
|
|
1050
|
-
if (readOnly) {
|
|
1051
|
-
const entry: { agent: string; readOnlyFlag?: boolean } = { agent: args.agent }
|
|
1052
|
-
if (args.read_only !== undefined) entry.readOnlyFlag = args.read_only
|
|
1053
|
-
readOnlyRegistry.register(childSessionID, entry)
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
let job: BackgroundJobRecord
|
|
1057
|
-
try {
|
|
1058
|
-
job = await board.registerLaunch({
|
|
1059
|
-
taskID: childSessionID,
|
|
1060
|
-
parentSessionID: ctx.sessionID,
|
|
1061
|
-
agent: args.agent,
|
|
1062
|
-
description: args.description ?? effectivePrompt,
|
|
1063
|
-
objective: effectivePrompt,
|
|
1064
|
-
})
|
|
1065
|
-
} catch (err: unknown) {
|
|
1066
|
-
// Concurrency race: another dispatch claimed the last slot after the
|
|
1067
|
-
// pre-check above but before this registration. Undo every side effect
|
|
1068
|
-
// of the aborted dispatch so no orphan session/registry survives, then
|
|
1069
|
-
// surface a clear rejection.
|
|
1070
|
-
const reason = err instanceof Error ? err.message : String(err)
|
|
1071
|
-
await abortRejectedLaunch(childSessionID, readOnly, {
|
|
1072
|
-
key: budgetKey,
|
|
1073
|
-
reserved: budgetReserved,
|
|
1074
|
-
})
|
|
1075
|
-
return `pantheon_delegate rejected: ${reason}`
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
// Timeout manager: cleared on finalize, unref'd so the timer never
|
|
1079
|
-
// keeps the process alive on its own.
|
|
1080
|
-
const timer = setTimeout(() => {
|
|
1081
|
-
timers.delete(childSessionID)
|
|
1082
|
-
void finalize(childSessionID, {
|
|
1083
|
-
state: 'error',
|
|
1084
|
-
error: `Delegation [${job.alias}] timed out after ${timeoutMs}ms without reaching a terminal state`,
|
|
1085
|
-
timedOut: true,
|
|
1086
|
-
}).catch((err: unknown) => log.error('[pantheon-delegate] timeout finalize failed:', err))
|
|
1087
|
-
}, timeoutMs)
|
|
1088
|
-
timer.unref()
|
|
1089
|
-
timers.set(childSessionID, timer)
|
|
1090
|
-
|
|
1091
|
-
const promptBody = {
|
|
1092
|
-
agent: args.agent,
|
|
1093
|
-
...(childModel !== undefined ? { model: childModel } : {}),
|
|
1094
|
-
parts: [{ type: 'text' as const, text: effectivePrompt }],
|
|
1095
|
-
}
|
|
1096
|
-
const promptOutcome = await promptWithTimeout(
|
|
1097
|
-
client,
|
|
1098
|
-
{ path: { id: childSessionID }, body: promptBody },
|
|
1099
|
-
options.promptTimeoutMs ?? options.bootstrapTimeoutMs ?? BOOTSTRAP_TIMEOUT_MS,
|
|
1100
|
-
)
|
|
1101
|
-
let bootstrap: BootstrapOutcome
|
|
1102
|
-
let safeRetry = false
|
|
1103
|
-
if (promptOutcome.status === 'rejected') {
|
|
1104
|
-
// A synchronous rejection is the only safe automatic retry signal: the
|
|
1105
|
-
// host explicitly told us that it did not accept the request.
|
|
1106
|
-
bootstrap = {
|
|
1107
|
-
status: 'startup_failed',
|
|
1108
|
-
elapsedMs: 0,
|
|
1109
|
-
reason: `promptAsync rejected: ${promptOutcome.reason}`,
|
|
1110
|
-
}
|
|
1111
|
-
safeRetry = true
|
|
1112
|
-
} else if (promptOutcome.status === 'timeout') {
|
|
1113
|
-
// A timeout is ambiguous: the server may have accepted the prompt even
|
|
1114
|
-
// though the client never received its response. Never resend it.
|
|
1115
|
-
bootstrap = { status: 'startup_failed', elapsedMs: 0, reason: promptOutcome.reason }
|
|
1116
|
-
} else {
|
|
1117
|
-
bootstrap = await bootstrapChild(client, childSessionID, promptOutcome.value, options)
|
|
1118
|
-
}
|
|
1119
|
-
log.info(
|
|
1120
|
-
`[pantheon-delegate] bootstrap child=${childSessionID} elapsed=${bootstrap.elapsedMs}ms reason=${bootstrap.reason}`,
|
|
1121
|
-
)
|
|
1122
|
-
if (bootstrap.status === 'started') {
|
|
1123
|
-
return (
|
|
1124
|
-
`Delegated to ${args.agent}: [${job.alias}] (task ${childSessionID}).\n` +
|
|
1125
|
-
`Read the result with pantheon_delegation_read({ id: "${job.alias}" }).`
|
|
1126
|
-
)
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
// Every startup diagnosis is terminal for this child. Only a prompt
|
|
1130
|
-
// rejection (known not accepted) permits one fresh-session retry;
|
|
1131
|
-
// accepted-empty, unavailable APIs, and request timeouts do not.
|
|
1132
|
-
const boardStartupState =
|
|
1133
|
-
bootstrap.status === 'bootstrap_unknown' ? 'startup_unknown' : 'startup_failed'
|
|
1134
|
-
await finalize(childSessionID, {
|
|
1135
|
-
state: boardStartupState,
|
|
1136
|
-
error: `${bootstrap.status}: ${bootstrap.reason}`,
|
|
1137
|
-
})
|
|
1138
|
-
if (!safeRetry) {
|
|
1139
|
-
const finalResult: DelegationResult = {
|
|
1140
|
-
status: bootstrap.status,
|
|
1141
|
-
content: `Child session ${childSessionID} did not start (${bootstrap.reason}).`,
|
|
1142
|
-
retryCount: 0,
|
|
1143
|
-
recommendation:
|
|
1144
|
-
bootstrap.status === 'bootstrap_unknown'
|
|
1145
|
-
? 'Inspect the child session in the board/TUI and retry manually only after confirming the prompt was not accepted.'
|
|
1146
|
-
: 'Inspect the child session/host promptAsync implementation; no automatic retry was attempted because acceptance was ambiguous.',
|
|
1147
|
-
}
|
|
1148
|
-
return formatDelegationResult(finalResult)
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
let retry: { id: string }
|
|
1152
|
-
try {
|
|
1153
|
-
retry = await client.session.create({
|
|
1154
|
-
body: {
|
|
1155
|
-
parentID: ctx.sessionID,
|
|
1156
|
-
title: args.description ?? effectivePrompt.slice(0, 80),
|
|
1157
|
-
...(childModel !== undefined ? { model: childModel } : {}),
|
|
1158
|
-
},
|
|
1159
|
-
})
|
|
1160
|
-
} catch (err: unknown) {
|
|
1161
|
-
const reason = err instanceof Error ? err.message : String(err)
|
|
1162
|
-
return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; retry creation failed: ${reason}`
|
|
1163
|
-
}
|
|
1164
|
-
const retryID = retry.id
|
|
1165
|
-
if (isUnresolvedSessionTemplate(retryID)) {
|
|
1166
|
-
log.warn(
|
|
1167
|
-
`[pantheon-delegate] retry session.create returned unresolved session template "${retryID}" — retry ignored`,
|
|
1168
|
-
)
|
|
1169
|
-
return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; host returned an unresolved retry session template ("${retryID}")`
|
|
1170
|
-
}
|
|
1171
|
-
knownChildren.add(retryID)
|
|
1172
|
-
options.registerChildSession?.(retryID, ctx.sessionID)
|
|
1173
|
-
|
|
1174
|
-
// Phase 4 read-only enforcement for the retry: recalculate readOnly
|
|
1175
|
-
// (same logic as the initial child at :866-872) and register the retry
|
|
1176
|
-
// session so the plugin's tool.execute.before guard enforces it.
|
|
1177
|
-
const retryReadOnly =
|
|
1178
|
-
args.read_only === true || (options.readOnlyAgents?.has(args.agent.toLowerCase()) ?? false)
|
|
1179
|
-
if (retryReadOnly) {
|
|
1180
|
-
const entry: { agent: string; readOnlyFlag?: boolean } = { agent: args.agent }
|
|
1181
|
-
if (args.read_only !== undefined) entry.readOnlyFlag = args.read_only
|
|
1182
|
-
readOnlyRegistry.register(retryID, entry)
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
let retryJob: BackgroundJobRecord
|
|
1186
|
-
try {
|
|
1187
|
-
retryJob = await board.registerLaunch({
|
|
1188
|
-
taskID: retryID,
|
|
1189
|
-
parentSessionID: ctx.sessionID,
|
|
1190
|
-
agent: args.agent,
|
|
1191
|
-
description: args.description ?? effectivePrompt,
|
|
1192
|
-
objective: effectivePrompt,
|
|
1193
|
-
})
|
|
1194
|
-
} catch (err: unknown) {
|
|
1195
|
-
// Same race as the initial child: the retry registration can be
|
|
1196
|
-
// refused. Clean up the retry session + registry entry before failing.
|
|
1197
|
-
const reason = err instanceof Error ? err.message : String(err)
|
|
1198
|
-
await abortRejectedLaunch(retryID, retryReadOnly)
|
|
1199
|
-
return `pantheon_delegate rejected: ${reason} (retry)`
|
|
1200
|
-
}
|
|
1201
|
-
const retryTimer = setTimeout(() => {
|
|
1202
|
-
timers.delete(retryID)
|
|
1203
|
-
void finalize(retryID, {
|
|
1204
|
-
state: 'error',
|
|
1205
|
-
error: `Delegation [${retryJob.alias}] timed out after ${timeoutMs}ms without reaching a terminal state`,
|
|
1206
|
-
timedOut: true,
|
|
1207
|
-
}).catch((err: unknown) =>
|
|
1208
|
-
log.error('[pantheon-delegate] retry timeout finalize failed:', err),
|
|
1209
|
-
)
|
|
1210
|
-
}, timeoutMs)
|
|
1211
|
-
retryTimer.unref()
|
|
1212
|
-
timers.set(retryID, retryTimer)
|
|
1213
|
-
const retryPrompt = await promptWithTimeout(
|
|
1214
|
-
client,
|
|
1215
|
-
{ path: { id: retryID }, body: promptBody },
|
|
1216
|
-
options.promptTimeoutMs ?? options.bootstrapTimeoutMs ?? BOOTSTRAP_TIMEOUT_MS,
|
|
1217
|
-
)
|
|
1218
|
-
let retryBootstrap: BootstrapOutcome
|
|
1219
|
-
if (retryPrompt.status === 'rejected') {
|
|
1220
|
-
retryBootstrap = {
|
|
1221
|
-
status: 'startup_failed',
|
|
1222
|
-
elapsedMs: 0,
|
|
1223
|
-
reason: `retry promptAsync rejected: ${retryPrompt.reason}`,
|
|
1224
|
-
}
|
|
1225
|
-
} else if (retryPrompt.status === 'timeout') {
|
|
1226
|
-
retryBootstrap = { status: 'startup_failed', elapsedMs: 0, reason: retryPrompt.reason }
|
|
1227
|
-
} else {
|
|
1228
|
-
retryBootstrap = await bootstrapChild(client, retryID, retryPrompt.value, options)
|
|
1229
|
-
}
|
|
1230
|
-
log.info(
|
|
1231
|
-
`[pantheon-delegate] bootstrap retry child=${retryID} elapsed=${retryBootstrap.elapsedMs}ms reason=${retryBootstrap.reason}`,
|
|
1232
|
-
)
|
|
1233
|
-
if (retryBootstrap.status === 'started') {
|
|
1234
|
-
return (
|
|
1235
|
-
`Delegated to ${args.agent}: [${retryJob.alias}] (task ${retryID}); retry=1.\n` +
|
|
1236
|
-
`Read the result with pantheon_delegation_read({ id: "${retryJob.alias}" }).`
|
|
1237
|
-
)
|
|
1238
|
-
}
|
|
1239
|
-
await finalize(retryID, {
|
|
1240
|
-
state: retryBootstrap.status === 'bootstrap_unknown' ? 'startup_unknown' : 'startup_failed',
|
|
1241
|
-
error: `startup retry exhausted: ${retryBootstrap.status}: ${retryBootstrap.reason}`,
|
|
1242
|
-
})
|
|
1243
|
-
const finalResult: DelegationResult = {
|
|
1244
|
-
status: retryBootstrap.status,
|
|
1245
|
-
content: `Child session ${retryID} did not start (${retryBootstrap.reason}).`,
|
|
1246
|
-
retryCount: 1,
|
|
1247
|
-
recommendation:
|
|
1248
|
-
'Inspect the child session/host promptAsync implementation; no further automatic retries will be attempted.',
|
|
1249
|
-
}
|
|
1250
|
-
return formatDelegationResult(finalResult)
|
|
1251
|
-
},
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
const pantheon_delegation_read: DelegationTool<typeof readArgs> = {
|
|
1255
|
-
description:
|
|
1256
|
-
'Block until a background delegation finishes (completed/error/cancelled), then return its ' +
|
|
1257
|
-
'report markdown (with a trailing agent-activity section) and mark the job reconciled. ' +
|
|
1258
|
-
'Resolves by alias or task ID.',
|
|
1259
|
-
args: readArgs,
|
|
1260
|
-
execute: async (args, ctx) => {
|
|
1261
|
-
guardEnabled()
|
|
1262
|
-
const resolution = resolveDelegationTarget(board, ctx.sessionID, args.id)
|
|
1263
|
-
if (resolution.kind === 'error') return resolution.message
|
|
1264
|
-
const job = resolution.job
|
|
1265
|
-
|
|
1266
|
-
// Sample the child's messages while waiting so the caller sees what the
|
|
1267
|
-
// agent is doing — fail-open: no messages support → report as before.
|
|
1268
|
-
const collector: ActivityCollector = { lines: [], sampled: false }
|
|
1269
|
-
let terminal: BackgroundJobRecord
|
|
1270
|
-
try {
|
|
1271
|
-
terminal = await waitForTerminalWithActivity(
|
|
1272
|
-
board,
|
|
1273
|
-
client,
|
|
1274
|
-
job.taskID,
|
|
1275
|
-
readTimeoutMs,
|
|
1276
|
-
collector,
|
|
1277
|
-
)
|
|
1278
|
-
} catch {
|
|
1279
|
-
return `Timed out after ${readTimeoutMs}ms waiting for delegation "${args.id}" ([${job.alias}]).`
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
const md = await readDelegationReport(outputDir, terminal)
|
|
1283
|
-
if (md === undefined) {
|
|
1284
|
-
return `Delegation [${terminal.alias}] reached state ${terminal.state} but no report file was found.`
|
|
1285
|
-
}
|
|
1286
|
-
// Classify on the FULL report (markers may sit at either end), but
|
|
1287
|
-
// return the inline-capped markdown so a huge child output cannot
|
|
1288
|
-
// flood the parent context — the marker points at the full file.
|
|
1289
|
-
const reportPath = join(outputDir, terminal.parentSessionID, `${terminal.alias}.md`)
|
|
1290
|
-
const cappedMd = capReportOutput(md, inlineMaxChars, reportPath)
|
|
1291
|
-
|
|
1292
|
-
// Classify the result for stuck-agent detection
|
|
1293
|
-
const classification = classifyStuckAgent(md)
|
|
1294
|
-
const hasActivity = collector.sampled || collector.lines.length > 0
|
|
1295
|
-
const activitySuffix = hasActivity ? `\n\n${formatActivitySection(collector.lines)}` : ''
|
|
1296
|
-
|
|
1297
|
-
if (classification.status === 'success') {
|
|
1298
|
-
await board.markReconciled(job.taskID)
|
|
1299
|
-
// Backward compatible: success reports returned as-is (capped)
|
|
1300
|
-
if (!hasActivity) return cappedMd
|
|
1301
|
-
return `${cappedMd.replace(/\n+$/, '')}${activitySuffix}`
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
// Non-success: build structured DelegationResult and format
|
|
1305
|
-
const delegationResult: DelegationResult = {
|
|
1306
|
-
status: classification.status === 'empty' ? 'empty' : classification.status,
|
|
1307
|
-
content: cappedMd,
|
|
1308
|
-
retryCount: 0,
|
|
1309
|
-
partialResult: classification.partialResult,
|
|
1310
|
-
recommendation: classification.recommendation,
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
await board.markReconciled(job.taskID)
|
|
1314
|
-
const formatted = formatDelegationResult(delegationResult)
|
|
1315
|
-
return `${formatted}${activitySuffix}`
|
|
1316
|
-
},
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
const pantheon_delegation_list: DelegationTool<typeof listArgs> = {
|
|
1320
|
-
description:
|
|
1321
|
-
'List background delegations for the current session, with [unread] for finished jobs.',
|
|
1322
|
-
args: listArgs,
|
|
1323
|
-
execute: async (_args, ctx) => {
|
|
1324
|
-
guardEnabled()
|
|
1325
|
-
const jobs = board.list(ctx.sessionID)
|
|
1326
|
-
if (jobs.length === 0) return 'No background delegations for this session.'
|
|
1327
|
-
|
|
1328
|
-
const lines = await Promise.all(
|
|
1329
|
-
jobs.map(async (j) => {
|
|
1330
|
-
const unread = j.terminalUnreconciled ? ' [unread]' : ''
|
|
1331
|
-
const base = ` [${j.alias}] ${j.agent} — ${j.description} — ${stateLabel(j.state)}${unread}`
|
|
1332
|
-
// Running jobs get a live `last activity:` line (fail-open fetch).
|
|
1333
|
-
if (j.state !== 'running') return base
|
|
1334
|
-
const last = await lastChildActivity(client, j.taskID)
|
|
1335
|
-
return last === undefined ? base : `${base}\n last activity: ${last}`
|
|
1336
|
-
}),
|
|
1337
|
-
)
|
|
1338
|
-
return `Background Delegations (${jobs.length}):\n${lines.join('\n')}`
|
|
1339
|
-
},
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
return {
|
|
1343
|
-
pantheon_delegate,
|
|
1344
|
-
pantheon_delegation_read,
|
|
1345
|
-
pantheon_delegation_list,
|
|
1346
|
-
finalizeDelegation: finalize,
|
|
1347
|
-
finalizeIdleChildrenWithoutMd,
|
|
1348
|
-
}
|
|
1349
|
-
}
|