pantheon-opencode 1.3.5 → 1.3.7
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/README.md +33 -1
- package/package.json +3 -3
- package/scripts/_pantheon_paths.py +0 -1
- package/scripts/build-agents-md.mjs +4 -2
- package/scripts/code_mode_server.py +307 -21
- package/scripts/doctor.mjs +26 -13
- package/scripts/hooks/format-multi-language.sh +6 -3
- package/scripts/hooks/log-session-start.sh +6 -0
- package/scripts/hooks/on-subagent-delegation-start.sh +22 -6
- package/scripts/hooks/validate-post-conditions.sh +1 -1
- package/scripts/hooks/validate-tool-safety.sh +5 -2
- package/scripts/install/agents-md.mjs +3 -1
- package/scripts/install/cli-ui.mjs +24 -4
- package/scripts/install/interactive.mjs +18 -7
- package/scripts/install/migrate-config.mjs +13 -19
- package/scripts/install/migrate.mjs +8 -5
- package/scripts/install/model-picker.mjs +4 -1
- package/scripts/install/opencode.mjs +2 -0
- package/scripts/install/shared.mjs +6 -4
- package/scripts/install/state.mjs +9 -6
- package/scripts/mcp_persistence_server.py +23 -22
- package/scripts/mcp_resources_server.py +60 -1
- package/scripts/memory_mcp_server.py +80 -4
- package/scripts/postinstall.mjs +27 -25
- package/scripts/prune.mjs +1 -1
- package/scripts/redaction-gate.mjs +3 -1
- package/scripts/sync-tui.mjs +148 -0
- package/scripts/uninstall.mjs +0 -2
- package/scripts/validation-policy.mjs +6 -1
- package/scripts/version-check.mjs +9 -4
- package/src/agents/aphrodite.md +1 -1
- package/src/agents/demeter.md +1 -1
- package/src/agents/hephaestus.md +1 -1
- package/src/agents/hermes.md +1 -1
- package/src/agents/themis.md +4 -0
- package/src/mcp/code_mode_server.py +304 -18
- package/src/mcp/eval_store.py +233 -0
- package/src/mcp/mcp_persistence_server.py +23 -22
- package/src/mcp/mcp_resources_server.py +58 -0
- package/src/mcp/memory_mcp_server.py +80 -3
- package/src/pantheon/auto-wake.ts +2 -4
- package/src/pantheon/background-job-board.ts +17 -4
- package/src/pantheon/comment-checker.ts +48 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +21 -21
- package/src/pantheon/delegation-finalize.ts +44 -4
- package/src/pantheon/delegation-notify.ts +97 -0
- package/src/pantheon/delegation.ts +413 -23
- package/src/pantheon/dispatch-guard.ts +2 -0
- package/src/pantheon/file-persistence.ts +7 -3
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/permission-globs.ts +86 -0
- package/src/pantheon/presets.d.mts +41 -0
- package/src/pantheon/presets.mjs +134 -0
- package/src/pantheon/retry-policy.ts +212 -0
- package/src/pantheon/session-guard.ts +1 -3
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/tui-version-check.ts +72 -0
- package/src/pantheon/vision.ts +7 -5
- package/src/pantheon/zeus-delegate-with-retry.ts +81 -12
- package/src/plugin.ts +57 -3
- package/src/plugins/hook-runner.ts +1 -1
- package/src/plugins/pantheon-hooks.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts +1 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +19 -14
- package/src/plugins/tui/dist/tui.tsx +52 -18
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +106 -24
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +42 -0
|
@@ -38,6 +38,11 @@
|
|
|
38
38
|
import { z } from 'zod'
|
|
39
39
|
|
|
40
40
|
import type { BackgroundJobBoard, BackgroundJobRecord } from './background-job-board.ts'
|
|
41
|
+
import {
|
|
42
|
+
classifyStuckAgent,
|
|
43
|
+
type DelegationResult,
|
|
44
|
+
formatDelegationResult,
|
|
45
|
+
} from './delegation-classifier.ts'
|
|
41
46
|
import {
|
|
42
47
|
isDelegationAllowed,
|
|
43
48
|
normalizeDelegationAgent,
|
|
@@ -53,8 +58,15 @@ import {
|
|
|
53
58
|
finalizeDelegation as finalizeDelegationReport,
|
|
54
59
|
readDelegationReport,
|
|
55
60
|
} from './delegation-finalize.ts'
|
|
61
|
+
import {
|
|
62
|
+
type IdleChildScanDeps,
|
|
63
|
+
finalizeIdleChildrenWithoutMd,
|
|
64
|
+
startIdleChildScan,
|
|
65
|
+
} from './delegation-notify.ts'
|
|
56
66
|
import { createPantheonLogger } from './logger.ts'
|
|
67
|
+
import { buildAgentListDescription } from './permission-globs.ts'
|
|
57
68
|
import { missingProviderKeyEnv, resolveActivePreset } from './presets.mjs'
|
|
69
|
+
import { buildStopInstruction, cappedSummary, DEFAULT_MAX_STEPS } from './step-cap.ts'
|
|
58
70
|
|
|
59
71
|
export type {
|
|
60
72
|
DelegationClient,
|
|
@@ -64,12 +76,19 @@ export type {
|
|
|
64
76
|
FinalizeInput,
|
|
65
77
|
} from './delegation-finalize.ts'
|
|
66
78
|
export { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
|
|
79
|
+
export type { IdleChildScanDeps } from './delegation-notify.ts'
|
|
80
|
+
export { finalizeIdleChildrenWithoutMd, startIdleChildScan } from './delegation-notify.ts'
|
|
67
81
|
|
|
68
82
|
// Fase B3: these are runtime controls, not routing/frontmatter fields. The
|
|
69
83
|
// plugin reads them from the environment, while tests and embedders can pass
|
|
70
84
|
// the same values through DelegationOptions.
|
|
71
85
|
export const B3_DEFAULT_EXCEPTION_BUDGET = 5
|
|
72
86
|
|
|
87
|
+
/** Maximum time spent proving that an accepted prompt actually booted. */
|
|
88
|
+
export const BOOTSTRAP_TIMEOUT_MS = 5_000
|
|
89
|
+
/** Default cadence for the bounded bootstrap probe. */
|
|
90
|
+
export const BOOTSTRAP_POLL_INTERVAL_MS = 250
|
|
91
|
+
|
|
73
92
|
function readIntegerEnv(
|
|
74
93
|
env: Record<string, string | undefined>,
|
|
75
94
|
name: string,
|
|
@@ -82,6 +101,175 @@ function readIntegerEnv(
|
|
|
82
101
|
return Number.isSafeInteger(value) && value >= minimum ? value : fallback
|
|
83
102
|
}
|
|
84
103
|
|
|
104
|
+
type BootstrapOutcome =
|
|
105
|
+
| { status: 'started'; elapsedMs: number; reason: string }
|
|
106
|
+
| { status: 'startup_failed' | 'bootstrap_unknown'; elapsedMs: number; reason: string }
|
|
107
|
+
|
|
108
|
+
type PromptOutcome =
|
|
109
|
+
| { status: 'accepted'; value: unknown }
|
|
110
|
+
| { status: 'rejected'; reason: string }
|
|
111
|
+
| { status: 'timeout'; reason: string }
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Bound the SDK request itself, not just the board watchdog. The AbortSignal
|
|
115
|
+
* is passed to hosts that support it; the race also protects older hosts that
|
|
116
|
+
* ignore the signal and never settle their promise.
|
|
117
|
+
*/
|
|
118
|
+
async function promptWithTimeout(
|
|
119
|
+
client: DelegationClient,
|
|
120
|
+
input: Parameters<DelegationClient['session']['promptAsync']>[0],
|
|
121
|
+
timeoutMs: number,
|
|
122
|
+
): Promise<PromptOutcome> {
|
|
123
|
+
const controller = new AbortController()
|
|
124
|
+
let timer: NodeJS.Timeout | undefined
|
|
125
|
+
const operation = client.session.promptAsync({ ...input, signal: controller.signal }).then(
|
|
126
|
+
(value) => ({ status: 'accepted' as const, value }),
|
|
127
|
+
(error: unknown) => ({
|
|
128
|
+
status: 'rejected' as const,
|
|
129
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
130
|
+
}),
|
|
131
|
+
)
|
|
132
|
+
try {
|
|
133
|
+
return await Promise.race([
|
|
134
|
+
operation,
|
|
135
|
+
new Promise<PromptOutcome>((resolve) => {
|
|
136
|
+
timer = setTimeout(() => {
|
|
137
|
+
controller.abort()
|
|
138
|
+
resolve({ status: 'timeout', reason: `promptAsync timed out after ${timeoutMs}ms` })
|
|
139
|
+
}, timeoutMs)
|
|
140
|
+
timer.unref()
|
|
141
|
+
}),
|
|
142
|
+
])
|
|
143
|
+
} finally {
|
|
144
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const RUNNING_SESSION_STATES = new Set(['busy', 'running', 'processing', 'retry', 'pending'])
|
|
149
|
+
|
|
150
|
+
async function boundedProbe<T>(operation: Promise<T>, timeoutMs: number): Promise<T | undefined> {
|
|
151
|
+
if (timeoutMs <= 0) return undefined
|
|
152
|
+
let timer: NodeJS.Timeout | undefined
|
|
153
|
+
try {
|
|
154
|
+
return await Promise.race([
|
|
155
|
+
operation,
|
|
156
|
+
new Promise<undefined>((resolve) => {
|
|
157
|
+
timer = setTimeout(() => resolve(undefined), timeoutMs)
|
|
158
|
+
timer.unref()
|
|
159
|
+
}),
|
|
160
|
+
])
|
|
161
|
+
} finally {
|
|
162
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isRunningSession(value: unknown): boolean {
|
|
167
|
+
if (typeof value !== 'object' || value === null) return false
|
|
168
|
+
const record = value as Record<string, unknown>
|
|
169
|
+
const state = record.status ?? record.state
|
|
170
|
+
return typeof state === 'string' && RUNNING_SESSION_STATES.has(state.toLowerCase())
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function hasDurableMessage(value: unknown): boolean {
|
|
174
|
+
if (!Array.isArray(value)) return false
|
|
175
|
+
return value.some((bundle) => {
|
|
176
|
+
if (typeof bundle !== 'object' || bundle === null) return false
|
|
177
|
+
const info = (bundle as { info?: { role?: unknown } }).info
|
|
178
|
+
return info?.role === 'user' || info?.role === 'assistant'
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function bootstrapChild(
|
|
183
|
+
client: DelegationClient,
|
|
184
|
+
childSessionID: string,
|
|
185
|
+
accepted: unknown,
|
|
186
|
+
options: DelegationOptions,
|
|
187
|
+
): Promise<BootstrapOutcome> {
|
|
188
|
+
const now = options.now ?? Date.now
|
|
189
|
+
const sleep =
|
|
190
|
+
options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
|
|
191
|
+
const timeoutMs = options.bootstrapTimeoutMs ?? BOOTSTRAP_TIMEOUT_MS
|
|
192
|
+
const intervalMs = options.bootstrapPollIntervalMs ?? BOOTSTRAP_POLL_INTERVAL_MS
|
|
193
|
+
const startedAt = now()
|
|
194
|
+
const hasMessagesAPI = typeof client.session.messages === 'function'
|
|
195
|
+
const hasStatusAPI = typeof client.session.get === 'function'
|
|
196
|
+
const acceptedRunning = isRunningSession(accepted)
|
|
197
|
+
|
|
198
|
+
if (!hasMessagesAPI && !hasStatusAPI) {
|
|
199
|
+
return acceptedRunning
|
|
200
|
+
? {
|
|
201
|
+
status: 'started',
|
|
202
|
+
elapsedMs: now() - startedAt,
|
|
203
|
+
reason: 'prompt accepted by legacy host',
|
|
204
|
+
}
|
|
205
|
+
: {
|
|
206
|
+
status: 'bootstrap_unknown',
|
|
207
|
+
elapsedMs: now() - startedAt,
|
|
208
|
+
reason:
|
|
209
|
+
'host exposes neither session.messages nor session.get and prompt had no running evidence',
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let sawProbe = false
|
|
214
|
+
while (now() - startedAt < timeoutMs) {
|
|
215
|
+
let durable = false
|
|
216
|
+
let running = acceptedRunning
|
|
217
|
+
const remainingMs = timeoutMs - (now() - startedAt)
|
|
218
|
+
if (hasMessagesAPI) {
|
|
219
|
+
try {
|
|
220
|
+
const messages = await boundedProbe(
|
|
221
|
+
client.session.messages!({ path: { id: childSessionID } }),
|
|
222
|
+
remainingMs,
|
|
223
|
+
)
|
|
224
|
+
if (messages !== undefined) {
|
|
225
|
+
sawProbe = true
|
|
226
|
+
durable = hasDurableMessage(messages)
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// A transient endpoint failure is diagnosed after the bounded window.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (hasStatusAPI) {
|
|
233
|
+
try {
|
|
234
|
+
const status = await boundedProbe(
|
|
235
|
+
client.session.get!({ path: { id: childSessionID } }),
|
|
236
|
+
timeoutMs - (now() - startedAt),
|
|
237
|
+
)
|
|
238
|
+
if (status !== undefined) {
|
|
239
|
+
running = running || isRunningSession(status)
|
|
240
|
+
sawProbe = true
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
// Fail closed only after the bounded window; never create an unbounded poll.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (durable)
|
|
247
|
+
return {
|
|
248
|
+
status: 'started',
|
|
249
|
+
elapsedMs: now() - startedAt,
|
|
250
|
+
reason: 'durable child message observed',
|
|
251
|
+
}
|
|
252
|
+
if (running)
|
|
253
|
+
return {
|
|
254
|
+
status: 'started',
|
|
255
|
+
elapsedMs: now() - startedAt,
|
|
256
|
+
reason: 'child session reports running/processing',
|
|
257
|
+
}
|
|
258
|
+
await sleep(Math.min(intervalMs, Math.max(0, timeoutMs - (now() - startedAt))))
|
|
259
|
+
}
|
|
260
|
+
return sawProbe
|
|
261
|
+
? {
|
|
262
|
+
status: 'startup_failed',
|
|
263
|
+
elapsedMs: now() - startedAt,
|
|
264
|
+
reason: `no durable message or running state after ${timeoutMs}ms`,
|
|
265
|
+
}
|
|
266
|
+
: {
|
|
267
|
+
status: 'bootstrap_unknown',
|
|
268
|
+
elapsedMs: now() - startedAt,
|
|
269
|
+
reason: `bootstrap probes were unavailable after ${timeoutMs}ms`,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
85
273
|
/** Resolve the two supported read-only delegation exception budgets. */
|
|
86
274
|
export function resolveDelegationBudgets(
|
|
87
275
|
env: Record<string, string | undefined> = process.env,
|
|
@@ -145,6 +333,8 @@ export interface DelegationToolset {
|
|
|
145
333
|
childSessionID: string,
|
|
146
334
|
opts: FinalizeInput,
|
|
147
335
|
) => Promise<BackgroundJobRecord | undefined>
|
|
336
|
+
/** Proactive finalize for idle children without MD reports (Fix 2). */
|
|
337
|
+
finalizeIdleChildrenWithoutMd: typeof finalizeIdleChildrenWithoutMd
|
|
148
338
|
}
|
|
149
339
|
|
|
150
340
|
/** Input for createDelegationTools(). */
|
|
@@ -177,6 +367,24 @@ const listArgs = {} satisfies z.ZodRawShape
|
|
|
177
367
|
|
|
178
368
|
// ─── State labels ──────────────────────────────────────────────────────
|
|
179
369
|
|
|
370
|
+
/** Canonical 14-agent roster (O5 tool-description default). */
|
|
371
|
+
export const CANONICAL_AGENTS: readonly string[] = [
|
|
372
|
+
'zeus',
|
|
373
|
+
'athena',
|
|
374
|
+
'apollo',
|
|
375
|
+
'hermes',
|
|
376
|
+
'aphrodite',
|
|
377
|
+
'demeter',
|
|
378
|
+
'themis',
|
|
379
|
+
'prometheus',
|
|
380
|
+
'hephaestus',
|
|
381
|
+
'nyx',
|
|
382
|
+
'gaia',
|
|
383
|
+
'iris',
|
|
384
|
+
'mnemosyne',
|
|
385
|
+
'talos',
|
|
386
|
+
]
|
|
387
|
+
|
|
180
388
|
function stateLabel(state: BackgroundJobRecord['state']): string {
|
|
181
389
|
switch (state) {
|
|
182
390
|
case 'running':
|
|
@@ -185,6 +393,10 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
|
|
|
185
393
|
return 'OK'
|
|
186
394
|
case 'error':
|
|
187
395
|
return 'ERR'
|
|
396
|
+
case 'startup_failed':
|
|
397
|
+
return 'STARTUP FAILED'
|
|
398
|
+
case 'startup_unknown':
|
|
399
|
+
return 'STARTUP UNKNOWN'
|
|
188
400
|
case 'cancelled':
|
|
189
401
|
return 'CANCELLED'
|
|
190
402
|
case 'reconciled':
|
|
@@ -195,6 +407,21 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
|
|
|
195
407
|
/** Normalize the SDK-provided caller agent without inventing an identity. */
|
|
196
408
|
const normalizeToolAgent = normalizeDelegationAgent
|
|
197
409
|
|
|
410
|
+
/**
|
|
411
|
+
* O5: the delegate tool description. When permission.task rules are
|
|
412
|
+
* configured, denied agents are REMOVED from the description entirely (not
|
|
413
|
+
* just blocked at call time) — the caller never sees them as invocable.
|
|
414
|
+
*/
|
|
415
|
+
function buildDelegateDescription(options: DelegationOptions): string {
|
|
416
|
+
const base =
|
|
417
|
+
'Dispatch a background agent as a child session and register it on the job board. ' +
|
|
418
|
+
'Returns the readable alias (e.g. "apo-1"); read the result with ' +
|
|
419
|
+
'pantheon_delegation_read. Only root sessions may delegate.'
|
|
420
|
+
if (options.permissionTask === undefined) return base
|
|
421
|
+
const roster = options.agentNames ?? CANONICAL_AGENTS
|
|
422
|
+
return `${base} Allowed subagents: ${buildAgentListDescription(options.permissionTask, roster)}.`
|
|
423
|
+
}
|
|
424
|
+
|
|
198
425
|
// ─── Agent activity sampling ────────────────────────────────────────────
|
|
199
426
|
//
|
|
200
427
|
// Visibility for `pantheon_delegation_read` / `pantheon_delegation_list`
|
|
@@ -487,7 +714,11 @@ function resolveUsableChildModel(
|
|
|
487
714
|
|
|
488
715
|
// Auto-resolved (agentModels / preset) → try the known-good fallback.
|
|
489
716
|
const fallback = splitModelRef(FALLBACK_MODEL)
|
|
490
|
-
if (
|
|
717
|
+
if (
|
|
718
|
+
fallback !== undefined &&
|
|
719
|
+
(fallback.providerID === 'opencode-go' ||
|
|
720
|
+
missingProviderKeyEnv(fallback.providerID, { env }) === undefined)
|
|
721
|
+
) {
|
|
491
722
|
warn(
|
|
492
723
|
`[pantheon-delegate] provider "${model.providerID}" requires API key ${missingVar} ` +
|
|
493
724
|
`(unset) — falling back to ${FALLBACK_MODEL}`,
|
|
@@ -582,10 +813,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
582
813
|
}
|
|
583
814
|
|
|
584
815
|
const pantheon_delegate: DelegationTool<typeof delegateArgs> = {
|
|
585
|
-
description:
|
|
586
|
-
'Dispatch a background agent as a child session and register it on the job board. ' +
|
|
587
|
-
'Returns the readable alias (e.g. "apo-1"); read the result with ' +
|
|
588
|
-
'pantheon_delegation_read. Only root sessions may delegate.',
|
|
816
|
+
description: buildDelegateDescription(options),
|
|
589
817
|
args: delegateArgs,
|
|
590
818
|
execute: async (args, ctx) => {
|
|
591
819
|
if (!isRootSession(ctx.sessionID)) {
|
|
@@ -600,13 +828,34 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
600
828
|
)
|
|
601
829
|
}
|
|
602
830
|
|
|
831
|
+
// R4: per-agent step caps. An agent already at max_steps is forced to
|
|
832
|
+
// summarize-and-stop — the delegation is skipped with a capped summary
|
|
833
|
+
// (no session, no board job, no budget consumed). Otherwise record the
|
|
834
|
+
// step; when THIS step hits the cap, append the stop instruction to
|
|
835
|
+
// the prompt so the agent summarizes and stops instead of continuing.
|
|
836
|
+
let effectivePrompt = args.prompt
|
|
837
|
+
const stepCap = options.stepCapTracker
|
|
838
|
+
if (stepCap !== undefined) {
|
|
839
|
+
const maxSteps = stepCap.maxStepsFor(args.agent)
|
|
840
|
+
if (stepCap.isCapped(args.agent)) {
|
|
841
|
+
return cappedSummary(args.agent, maxSteps ?? DEFAULT_MAX_STEPS)
|
|
842
|
+
}
|
|
843
|
+
const rec = stepCap.recordStep(args.agent)
|
|
844
|
+
if (rec.capped && rec.maxSteps !== undefined) {
|
|
845
|
+
effectivePrompt = `${args.prompt}${buildStopInstruction(args.agent, rec.maxSteps)}`
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
603
849
|
// Fase B3: only the two read-only exceptions are budgeted. The official
|
|
604
850
|
// SDK provides the current agent on ToolContext. If a structural/older
|
|
605
851
|
// host omits it, do NOT infer identity from prompt, target, or process
|
|
606
852
|
// state: the budget is explicitly skipped and the delegation continues.
|
|
607
853
|
const parentAgent = normalizeToolAgent(ctx.agent)
|
|
608
854
|
const targetAgent = args.agent.toLowerCase()
|
|
609
|
-
if (
|
|
855
|
+
if (
|
|
856
|
+
options.enforceRuntimeMatrix === true &&
|
|
857
|
+
!isDelegationAllowed(parentAgent, targetAgent, options.permissionTask)
|
|
858
|
+
) {
|
|
610
859
|
const reason =
|
|
611
860
|
parentAgent === undefined
|
|
612
861
|
? 'ToolContext.agent is unavailable'
|
|
@@ -670,7 +919,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
670
919
|
created = await client.session.create({
|
|
671
920
|
body: {
|
|
672
921
|
parentID: ctx.sessionID,
|
|
673
|
-
title: args.description ??
|
|
922
|
+
title: args.description ?? effectivePrompt.slice(0, 80),
|
|
674
923
|
...(childModel !== undefined ? { model: childModel } : {}),
|
|
675
924
|
},
|
|
676
925
|
})
|
|
@@ -702,8 +951,8 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
702
951
|
taskID: childSessionID,
|
|
703
952
|
parentSessionID: ctx.sessionID,
|
|
704
953
|
agent: args.agent,
|
|
705
|
-
description: args.description ??
|
|
706
|
-
objective:
|
|
954
|
+
description: args.description ?? effectivePrompt,
|
|
955
|
+
objective: effectivePrompt,
|
|
707
956
|
})
|
|
708
957
|
|
|
709
958
|
// Timeout manager: cleared on finalize, unref'd so the timer never
|
|
@@ -719,19 +968,139 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
719
968
|
timer.unref()
|
|
720
969
|
timers.set(childSessionID, timer)
|
|
721
970
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
971
|
+
const promptBody = {
|
|
972
|
+
agent: args.agent,
|
|
973
|
+
...(childModel !== undefined ? { model: childModel } : {}),
|
|
974
|
+
parts: [{ type: 'text' as const, text: effectivePrompt }],
|
|
975
|
+
}
|
|
976
|
+
const promptOutcome = await promptWithTimeout(
|
|
977
|
+
client,
|
|
978
|
+
{ path: { id: childSessionID }, body: promptBody },
|
|
979
|
+
options.promptTimeoutMs ?? options.bootstrapTimeoutMs ?? BOOTSTRAP_TIMEOUT_MS,
|
|
980
|
+
)
|
|
981
|
+
let bootstrap: BootstrapOutcome
|
|
982
|
+
let safeRetry = false
|
|
983
|
+
if (promptOutcome.status === 'rejected') {
|
|
984
|
+
// A synchronous rejection is the only safe automatic retry signal: the
|
|
985
|
+
// host explicitly told us that it did not accept the request.
|
|
986
|
+
bootstrap = {
|
|
987
|
+
status: 'startup_failed',
|
|
988
|
+
elapsedMs: 0,
|
|
989
|
+
reason: `promptAsync rejected: ${promptOutcome.reason}`,
|
|
990
|
+
}
|
|
991
|
+
safeRetry = true
|
|
992
|
+
} else if (promptOutcome.status === 'timeout') {
|
|
993
|
+
// A timeout is ambiguous: the server may have accepted the prompt even
|
|
994
|
+
// though the client never received its response. Never resend it.
|
|
995
|
+
bootstrap = { status: 'startup_failed', elapsedMs: 0, reason: promptOutcome.reason }
|
|
996
|
+
} else {
|
|
997
|
+
bootstrap = await bootstrapChild(client, childSessionID, promptOutcome.value, options)
|
|
998
|
+
}
|
|
999
|
+
log.info(
|
|
1000
|
+
`[pantheon-delegate] bootstrap child=${childSessionID} elapsed=${bootstrap.elapsedMs}ms reason=${bootstrap.reason}`,
|
|
1001
|
+
)
|
|
1002
|
+
if (bootstrap.status === 'started') {
|
|
1003
|
+
return (
|
|
1004
|
+
`Delegated to ${args.agent}: [${job.alias}] (task ${childSessionID}).\n` +
|
|
1005
|
+
`Read the result with pantheon_delegation_read({ id: "${job.alias}" }).`
|
|
1006
|
+
)
|
|
1007
|
+
}
|
|
730
1008
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1009
|
+
// Every startup diagnosis is terminal for this child. Only a prompt
|
|
1010
|
+
// rejection (known not accepted) permits one fresh-session retry;
|
|
1011
|
+
// accepted-empty, unavailable APIs, and request timeouts do not.
|
|
1012
|
+
const boardStartupState =
|
|
1013
|
+
bootstrap.status === 'bootstrap_unknown' ? 'startup_unknown' : 'startup_failed'
|
|
1014
|
+
await finalize(childSessionID, {
|
|
1015
|
+
state: boardStartupState,
|
|
1016
|
+
error: `${bootstrap.status}: ${bootstrap.reason}`,
|
|
1017
|
+
})
|
|
1018
|
+
if (!safeRetry) {
|
|
1019
|
+
const finalResult: DelegationResult = {
|
|
1020
|
+
status: bootstrap.status,
|
|
1021
|
+
content: `Child session ${childSessionID} did not start (${bootstrap.reason}).`,
|
|
1022
|
+
retryCount: 0,
|
|
1023
|
+
recommendation:
|
|
1024
|
+
bootstrap.status === 'bootstrap_unknown'
|
|
1025
|
+
? 'Inspect the child session in the board/TUI and retry manually only after confirming the prompt was not accepted.'
|
|
1026
|
+
: 'Inspect the child session/host promptAsync implementation; no automatic retry was attempted because acceptance was ambiguous.',
|
|
1027
|
+
}
|
|
1028
|
+
return formatDelegationResult(finalResult)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
let retry: { id: string }
|
|
1032
|
+
try {
|
|
1033
|
+
retry = await client.session.create({
|
|
1034
|
+
body: {
|
|
1035
|
+
parentID: ctx.sessionID,
|
|
1036
|
+
title: args.description ?? effectivePrompt.slice(0, 80),
|
|
1037
|
+
...(childModel !== undefined ? { model: childModel } : {}),
|
|
1038
|
+
},
|
|
1039
|
+
})
|
|
1040
|
+
} catch (err: unknown) {
|
|
1041
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
1042
|
+
return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; retry creation failed: ${reason}`
|
|
1043
|
+
}
|
|
1044
|
+
const retryID = retry.id
|
|
1045
|
+
knownChildren.add(retryID)
|
|
1046
|
+
options.registerChildSession?.(retryID, ctx.sessionID)
|
|
1047
|
+
const retryJob = await board.registerLaunch({
|
|
1048
|
+
taskID: retryID,
|
|
1049
|
+
parentSessionID: ctx.sessionID,
|
|
1050
|
+
agent: args.agent,
|
|
1051
|
+
description: args.description ?? effectivePrompt,
|
|
1052
|
+
objective: effectivePrompt,
|
|
1053
|
+
})
|
|
1054
|
+
const retryTimer = setTimeout(() => {
|
|
1055
|
+
timers.delete(retryID)
|
|
1056
|
+
void finalize(retryID, {
|
|
1057
|
+
state: 'error',
|
|
1058
|
+
error: `Delegation [${retryJob.alias}] timed out after ${timeoutMs}ms without reaching a terminal state`,
|
|
1059
|
+
timedOut: true,
|
|
1060
|
+
}).catch((err: unknown) =>
|
|
1061
|
+
log.error('[pantheon-delegate] retry timeout finalize failed:', err),
|
|
1062
|
+
)
|
|
1063
|
+
}, timeoutMs)
|
|
1064
|
+
retryTimer.unref()
|
|
1065
|
+
timers.set(retryID, retryTimer)
|
|
1066
|
+
const retryPrompt = await promptWithTimeout(
|
|
1067
|
+
client,
|
|
1068
|
+
{ path: { id: retryID }, body: promptBody },
|
|
1069
|
+
options.promptTimeoutMs ?? options.bootstrapTimeoutMs ?? BOOTSTRAP_TIMEOUT_MS,
|
|
1070
|
+
)
|
|
1071
|
+
let retryBootstrap: BootstrapOutcome
|
|
1072
|
+
if (retryPrompt.status === 'rejected') {
|
|
1073
|
+
retryBootstrap = {
|
|
1074
|
+
status: 'startup_failed',
|
|
1075
|
+
elapsedMs: 0,
|
|
1076
|
+
reason: `retry promptAsync rejected: ${retryPrompt.reason}`,
|
|
1077
|
+
}
|
|
1078
|
+
} else if (retryPrompt.status === 'timeout') {
|
|
1079
|
+
retryBootstrap = { status: 'startup_failed', elapsedMs: 0, reason: retryPrompt.reason }
|
|
1080
|
+
} else {
|
|
1081
|
+
retryBootstrap = await bootstrapChild(client, retryID, retryPrompt.value, options)
|
|
1082
|
+
}
|
|
1083
|
+
log.info(
|
|
1084
|
+
`[pantheon-delegate] bootstrap retry child=${retryID} elapsed=${retryBootstrap.elapsedMs}ms reason=${retryBootstrap.reason}`,
|
|
734
1085
|
)
|
|
1086
|
+
if (retryBootstrap.status === 'started') {
|
|
1087
|
+
return (
|
|
1088
|
+
`Delegated to ${args.agent}: [${retryJob.alias}] (task ${retryID}); retry=1.\n` +
|
|
1089
|
+
`Read the result with pantheon_delegation_read({ id: "${retryJob.alias}" }).`
|
|
1090
|
+
)
|
|
1091
|
+
}
|
|
1092
|
+
await finalize(retryID, {
|
|
1093
|
+
state: retryBootstrap.status === 'bootstrap_unknown' ? 'startup_unknown' : 'startup_failed',
|
|
1094
|
+
error: `startup retry exhausted: ${retryBootstrap.status}: ${retryBootstrap.reason}`,
|
|
1095
|
+
})
|
|
1096
|
+
const finalResult: DelegationResult = {
|
|
1097
|
+
status: retryBootstrap.status,
|
|
1098
|
+
content: `Child session ${retryID} did not start (${retryBootstrap.reason}).`,
|
|
1099
|
+
retryCount: 1,
|
|
1100
|
+
recommendation:
|
|
1101
|
+
'Inspect the child session/host promptAsync implementation; no further automatic retries will be attempted.',
|
|
1102
|
+
}
|
|
1103
|
+
return formatDelegationResult(finalResult)
|
|
735
1104
|
},
|
|
736
1105
|
}
|
|
737
1106
|
|
|
@@ -768,10 +1137,30 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
768
1137
|
return `Delegation [${terminal.alias}] reached state ${terminal.state} but no report file was found.`
|
|
769
1138
|
}
|
|
770
1139
|
|
|
1140
|
+
// Classify the result for stuck-agent detection
|
|
1141
|
+
const classification = classifyStuckAgent(md)
|
|
1142
|
+
const hasActivity = collector.sampled || collector.lines.length > 0
|
|
1143
|
+
const activitySuffix = hasActivity ? `\n\n${formatActivitySection(collector.lines)}` : ''
|
|
1144
|
+
|
|
1145
|
+
if (classification.status === 'success') {
|
|
1146
|
+
await board.markReconciled(job.taskID)
|
|
1147
|
+
// Backward compatible: success reports returned as-is
|
|
1148
|
+
if (!hasActivity) return md
|
|
1149
|
+
return `${md.replace(/\n+$/, '')}${activitySuffix}`
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Non-success: build structured DelegationResult and format
|
|
1153
|
+
const delegationResult: DelegationResult = {
|
|
1154
|
+
status: classification.status === 'empty' ? 'empty' : classification.status,
|
|
1155
|
+
content: md,
|
|
1156
|
+
retryCount: 0,
|
|
1157
|
+
partialResult: classification.partialResult,
|
|
1158
|
+
recommendation: classification.recommendation,
|
|
1159
|
+
}
|
|
1160
|
+
|
|
771
1161
|
await board.markReconciled(job.taskID)
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
return `${md.replace(/\n+$/, '')}\n\n${formatActivitySection(collector.lines)}`
|
|
1162
|
+
const formatted = formatDelegationResult(delegationResult)
|
|
1163
|
+
return `${formatted}${activitySuffix}`
|
|
775
1164
|
},
|
|
776
1165
|
}
|
|
777
1166
|
|
|
@@ -802,5 +1191,6 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
802
1191
|
pantheon_delegation_read,
|
|
803
1192
|
pantheon_delegation_list,
|
|
804
1193
|
finalizeDelegation: finalize,
|
|
1194
|
+
finalizeIdleChildrenWithoutMd,
|
|
805
1195
|
}
|
|
806
1196
|
}
|
|
@@ -34,6 +34,8 @@ export interface DispatchResultLike {
|
|
|
34
34
|
tokensInput?: number
|
|
35
35
|
/** Output tokens (present when the model actually reasoned). */
|
|
36
36
|
tokensOutput?: number
|
|
37
|
+
/** R1: the failure that produced this result (classified by retry-policy). */
|
|
38
|
+
error?: unknown
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/** Classification of a dispatch result. */
|
|
@@ -58,7 +58,11 @@ export class FilePersistenceAdapter implements PersistenceAdapter {
|
|
|
58
58
|
const content = await readFile(this.statePath, 'utf-8')
|
|
59
59
|
return JSON.parse(content) as BackgroundJobRecord[]
|
|
60
60
|
} catch (err: unknown) {
|
|
61
|
-
if (
|
|
61
|
+
if (
|
|
62
|
+
err instanceof Error &&
|
|
63
|
+
'code' in err &&
|
|
64
|
+
(err as NodeJS.ErrnoException).code === 'ENOENT'
|
|
65
|
+
) {
|
|
62
66
|
return null
|
|
63
67
|
}
|
|
64
68
|
throw err
|
|
@@ -75,7 +79,7 @@ export class FilePersistenceAdapter implements PersistenceAdapter {
|
|
|
75
79
|
|
|
76
80
|
async saveJob(record: BackgroundJobRecord): Promise<void> {
|
|
77
81
|
const records = (await this.readState()) ?? []
|
|
78
|
-
const idx = records.findIndex(r => r.taskID === record.taskID)
|
|
82
|
+
const idx = records.findIndex((r) => r.taskID === record.taskID)
|
|
79
83
|
if (idx >= 0) {
|
|
80
84
|
records[idx] = record
|
|
81
85
|
} else {
|
|
@@ -91,7 +95,7 @@ export class FilePersistenceAdapter implements PersistenceAdapter {
|
|
|
91
95
|
async deleteJob(taskID: string): Promise<void> {
|
|
92
96
|
const records = await this.readState()
|
|
93
97
|
if (!records) return
|
|
94
|
-
const idx = records.findIndex(r => r.taskID === taskID)
|
|
98
|
+
const idx = records.findIndex((r) => r.taskID === taskID)
|
|
95
99
|
if (idx < 0) return
|
|
96
100
|
records.splice(idx, 1)
|
|
97
101
|
await this.writeState(records)
|