pantheon-opencode 1.3.5 → 1.3.6
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/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.ts +403 -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/vision.ts +7 -5
- package/src/pantheon/zeus-delegate-with-retry.ts +81 -12
- package/src/plugin.ts +28 -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 +52 -18
- 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,
|
|
@@ -54,7 +59,9 @@ import {
|
|
|
54
59
|
readDelegationReport,
|
|
55
60
|
} from './delegation-finalize.ts'
|
|
56
61
|
import { createPantheonLogger } from './logger.ts'
|
|
62
|
+
import { buildAgentListDescription } from './permission-globs.ts'
|
|
57
63
|
import { missingProviderKeyEnv, resolveActivePreset } from './presets.mjs'
|
|
64
|
+
import { buildStopInstruction, cappedSummary, DEFAULT_MAX_STEPS } from './step-cap.ts'
|
|
58
65
|
|
|
59
66
|
export type {
|
|
60
67
|
DelegationClient,
|
|
@@ -70,6 +77,11 @@ export { DELEGATION_DEFAULTS } from './delegation-finalize.ts'
|
|
|
70
77
|
// the same values through DelegationOptions.
|
|
71
78
|
export const B3_DEFAULT_EXCEPTION_BUDGET = 5
|
|
72
79
|
|
|
80
|
+
/** Maximum time spent proving that an accepted prompt actually booted. */
|
|
81
|
+
export const BOOTSTRAP_TIMEOUT_MS = 5_000
|
|
82
|
+
/** Default cadence for the bounded bootstrap probe. */
|
|
83
|
+
export const BOOTSTRAP_POLL_INTERVAL_MS = 250
|
|
84
|
+
|
|
73
85
|
function readIntegerEnv(
|
|
74
86
|
env: Record<string, string | undefined>,
|
|
75
87
|
name: string,
|
|
@@ -82,6 +94,175 @@ function readIntegerEnv(
|
|
|
82
94
|
return Number.isSafeInteger(value) && value >= minimum ? value : fallback
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
type BootstrapOutcome =
|
|
98
|
+
| { status: 'started'; elapsedMs: number; reason: string }
|
|
99
|
+
| { status: 'startup_failed' | 'bootstrap_unknown'; elapsedMs: number; reason: string }
|
|
100
|
+
|
|
101
|
+
type PromptOutcome =
|
|
102
|
+
| { status: 'accepted'; value: unknown }
|
|
103
|
+
| { status: 'rejected'; reason: string }
|
|
104
|
+
| { status: 'timeout'; reason: string }
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Bound the SDK request itself, not just the board watchdog. The AbortSignal
|
|
108
|
+
* is passed to hosts that support it; the race also protects older hosts that
|
|
109
|
+
* ignore the signal and never settle their promise.
|
|
110
|
+
*/
|
|
111
|
+
async function promptWithTimeout(
|
|
112
|
+
client: DelegationClient,
|
|
113
|
+
input: Parameters<DelegationClient['session']['promptAsync']>[0],
|
|
114
|
+
timeoutMs: number,
|
|
115
|
+
): Promise<PromptOutcome> {
|
|
116
|
+
const controller = new AbortController()
|
|
117
|
+
let timer: NodeJS.Timeout | undefined
|
|
118
|
+
const operation = client.session.promptAsync({ ...input, signal: controller.signal }).then(
|
|
119
|
+
(value) => ({ status: 'accepted' as const, value }),
|
|
120
|
+
(error: unknown) => ({
|
|
121
|
+
status: 'rejected' as const,
|
|
122
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
123
|
+
}),
|
|
124
|
+
)
|
|
125
|
+
try {
|
|
126
|
+
return await Promise.race([
|
|
127
|
+
operation,
|
|
128
|
+
new Promise<PromptOutcome>((resolve) => {
|
|
129
|
+
timer = setTimeout(() => {
|
|
130
|
+
controller.abort()
|
|
131
|
+
resolve({ status: 'timeout', reason: `promptAsync timed out after ${timeoutMs}ms` })
|
|
132
|
+
}, timeoutMs)
|
|
133
|
+
timer.unref()
|
|
134
|
+
}),
|
|
135
|
+
])
|
|
136
|
+
} finally {
|
|
137
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const RUNNING_SESSION_STATES = new Set(['busy', 'running', 'processing', 'retry', 'pending'])
|
|
142
|
+
|
|
143
|
+
async function boundedProbe<T>(operation: Promise<T>, timeoutMs: number): Promise<T | undefined> {
|
|
144
|
+
if (timeoutMs <= 0) return undefined
|
|
145
|
+
let timer: NodeJS.Timeout | undefined
|
|
146
|
+
try {
|
|
147
|
+
return await Promise.race([
|
|
148
|
+
operation,
|
|
149
|
+
new Promise<undefined>((resolve) => {
|
|
150
|
+
timer = setTimeout(() => resolve(undefined), timeoutMs)
|
|
151
|
+
timer.unref()
|
|
152
|
+
}),
|
|
153
|
+
])
|
|
154
|
+
} finally {
|
|
155
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isRunningSession(value: unknown): boolean {
|
|
160
|
+
if (typeof value !== 'object' || value === null) return false
|
|
161
|
+
const record = value as Record<string, unknown>
|
|
162
|
+
const state = record.status ?? record.state
|
|
163
|
+
return typeof state === 'string' && RUNNING_SESSION_STATES.has(state.toLowerCase())
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function hasDurableMessage(value: unknown): boolean {
|
|
167
|
+
if (!Array.isArray(value)) return false
|
|
168
|
+
return value.some((bundle) => {
|
|
169
|
+
if (typeof bundle !== 'object' || bundle === null) return false
|
|
170
|
+
const info = (bundle as { info?: { role?: unknown } }).info
|
|
171
|
+
return info?.role === 'user' || info?.role === 'assistant'
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function bootstrapChild(
|
|
176
|
+
client: DelegationClient,
|
|
177
|
+
childSessionID: string,
|
|
178
|
+
accepted: unknown,
|
|
179
|
+
options: DelegationOptions,
|
|
180
|
+
): Promise<BootstrapOutcome> {
|
|
181
|
+
const now = options.now ?? Date.now
|
|
182
|
+
const sleep =
|
|
183
|
+
options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
|
|
184
|
+
const timeoutMs = options.bootstrapTimeoutMs ?? BOOTSTRAP_TIMEOUT_MS
|
|
185
|
+
const intervalMs = options.bootstrapPollIntervalMs ?? BOOTSTRAP_POLL_INTERVAL_MS
|
|
186
|
+
const startedAt = now()
|
|
187
|
+
const hasMessagesAPI = typeof client.session.messages === 'function'
|
|
188
|
+
const hasStatusAPI = typeof client.session.get === 'function'
|
|
189
|
+
const acceptedRunning = isRunningSession(accepted)
|
|
190
|
+
|
|
191
|
+
if (!hasMessagesAPI && !hasStatusAPI) {
|
|
192
|
+
return acceptedRunning
|
|
193
|
+
? {
|
|
194
|
+
status: 'started',
|
|
195
|
+
elapsedMs: now() - startedAt,
|
|
196
|
+
reason: 'prompt accepted by legacy host',
|
|
197
|
+
}
|
|
198
|
+
: {
|
|
199
|
+
status: 'bootstrap_unknown',
|
|
200
|
+
elapsedMs: now() - startedAt,
|
|
201
|
+
reason:
|
|
202
|
+
'host exposes neither session.messages nor session.get and prompt had no running evidence',
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let sawProbe = false
|
|
207
|
+
while (now() - startedAt < timeoutMs) {
|
|
208
|
+
let durable = false
|
|
209
|
+
let running = acceptedRunning
|
|
210
|
+
const remainingMs = timeoutMs - (now() - startedAt)
|
|
211
|
+
if (hasMessagesAPI) {
|
|
212
|
+
try {
|
|
213
|
+
const messages = await boundedProbe(
|
|
214
|
+
client.session.messages!({ path: { id: childSessionID } }),
|
|
215
|
+
remainingMs,
|
|
216
|
+
)
|
|
217
|
+
if (messages !== undefined) {
|
|
218
|
+
sawProbe = true
|
|
219
|
+
durable = hasDurableMessage(messages)
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// A transient endpoint failure is diagnosed after the bounded window.
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (hasStatusAPI) {
|
|
226
|
+
try {
|
|
227
|
+
const status = await boundedProbe(
|
|
228
|
+
client.session.get!({ path: { id: childSessionID } }),
|
|
229
|
+
timeoutMs - (now() - startedAt),
|
|
230
|
+
)
|
|
231
|
+
if (status !== undefined) {
|
|
232
|
+
running = running || isRunningSession(status)
|
|
233
|
+
sawProbe = true
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// Fail closed only after the bounded window; never create an unbounded poll.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (durable)
|
|
240
|
+
return {
|
|
241
|
+
status: 'started',
|
|
242
|
+
elapsedMs: now() - startedAt,
|
|
243
|
+
reason: 'durable child message observed',
|
|
244
|
+
}
|
|
245
|
+
if (running)
|
|
246
|
+
return {
|
|
247
|
+
status: 'started',
|
|
248
|
+
elapsedMs: now() - startedAt,
|
|
249
|
+
reason: 'child session reports running/processing',
|
|
250
|
+
}
|
|
251
|
+
await sleep(Math.min(intervalMs, Math.max(0, timeoutMs - (now() - startedAt))))
|
|
252
|
+
}
|
|
253
|
+
return sawProbe
|
|
254
|
+
? {
|
|
255
|
+
status: 'startup_failed',
|
|
256
|
+
elapsedMs: now() - startedAt,
|
|
257
|
+
reason: `no durable message or running state after ${timeoutMs}ms`,
|
|
258
|
+
}
|
|
259
|
+
: {
|
|
260
|
+
status: 'bootstrap_unknown',
|
|
261
|
+
elapsedMs: now() - startedAt,
|
|
262
|
+
reason: `bootstrap probes were unavailable after ${timeoutMs}ms`,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
85
266
|
/** Resolve the two supported read-only delegation exception budgets. */
|
|
86
267
|
export function resolveDelegationBudgets(
|
|
87
268
|
env: Record<string, string | undefined> = process.env,
|
|
@@ -177,6 +358,24 @@ const listArgs = {} satisfies z.ZodRawShape
|
|
|
177
358
|
|
|
178
359
|
// ─── State labels ──────────────────────────────────────────────────────
|
|
179
360
|
|
|
361
|
+
/** Canonical 14-agent roster (O5 tool-description default). */
|
|
362
|
+
export const CANONICAL_AGENTS: readonly string[] = [
|
|
363
|
+
'zeus',
|
|
364
|
+
'athena',
|
|
365
|
+
'apollo',
|
|
366
|
+
'hermes',
|
|
367
|
+
'aphrodite',
|
|
368
|
+
'demeter',
|
|
369
|
+
'themis',
|
|
370
|
+
'prometheus',
|
|
371
|
+
'hephaestus',
|
|
372
|
+
'nyx',
|
|
373
|
+
'gaia',
|
|
374
|
+
'iris',
|
|
375
|
+
'mnemosyne',
|
|
376
|
+
'talos',
|
|
377
|
+
]
|
|
378
|
+
|
|
180
379
|
function stateLabel(state: BackgroundJobRecord['state']): string {
|
|
181
380
|
switch (state) {
|
|
182
381
|
case 'running':
|
|
@@ -185,6 +384,10 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
|
|
|
185
384
|
return 'OK'
|
|
186
385
|
case 'error':
|
|
187
386
|
return 'ERR'
|
|
387
|
+
case 'startup_failed':
|
|
388
|
+
return 'STARTUP FAILED'
|
|
389
|
+
case 'startup_unknown':
|
|
390
|
+
return 'STARTUP UNKNOWN'
|
|
188
391
|
case 'cancelled':
|
|
189
392
|
return 'CANCELLED'
|
|
190
393
|
case 'reconciled':
|
|
@@ -195,6 +398,21 @@ function stateLabel(state: BackgroundJobRecord['state']): string {
|
|
|
195
398
|
/** Normalize the SDK-provided caller agent without inventing an identity. */
|
|
196
399
|
const normalizeToolAgent = normalizeDelegationAgent
|
|
197
400
|
|
|
401
|
+
/**
|
|
402
|
+
* O5: the delegate tool description. When permission.task rules are
|
|
403
|
+
* configured, denied agents are REMOVED from the description entirely (not
|
|
404
|
+
* just blocked at call time) — the caller never sees them as invocable.
|
|
405
|
+
*/
|
|
406
|
+
function buildDelegateDescription(options: DelegationOptions): string {
|
|
407
|
+
const base =
|
|
408
|
+
'Dispatch a background agent as a child session and register it on the job board. ' +
|
|
409
|
+
'Returns the readable alias (e.g. "apo-1"); read the result with ' +
|
|
410
|
+
'pantheon_delegation_read. Only root sessions may delegate.'
|
|
411
|
+
if (options.permissionTask === undefined) return base
|
|
412
|
+
const roster = options.agentNames ?? CANONICAL_AGENTS
|
|
413
|
+
return `${base} Allowed subagents: ${buildAgentListDescription(options.permissionTask, roster)}.`
|
|
414
|
+
}
|
|
415
|
+
|
|
198
416
|
// ─── Agent activity sampling ────────────────────────────────────────────
|
|
199
417
|
//
|
|
200
418
|
// Visibility for `pantheon_delegation_read` / `pantheon_delegation_list`
|
|
@@ -487,7 +705,11 @@ function resolveUsableChildModel(
|
|
|
487
705
|
|
|
488
706
|
// Auto-resolved (agentModels / preset) → try the known-good fallback.
|
|
489
707
|
const fallback = splitModelRef(FALLBACK_MODEL)
|
|
490
|
-
if (
|
|
708
|
+
if (
|
|
709
|
+
fallback !== undefined &&
|
|
710
|
+
(fallback.providerID === 'opencode-go' ||
|
|
711
|
+
missingProviderKeyEnv(fallback.providerID, { env }) === undefined)
|
|
712
|
+
) {
|
|
491
713
|
warn(
|
|
492
714
|
`[pantheon-delegate] provider "${model.providerID}" requires API key ${missingVar} ` +
|
|
493
715
|
`(unset) — falling back to ${FALLBACK_MODEL}`,
|
|
@@ -582,10 +804,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
582
804
|
}
|
|
583
805
|
|
|
584
806
|
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.',
|
|
807
|
+
description: buildDelegateDescription(options),
|
|
589
808
|
args: delegateArgs,
|
|
590
809
|
execute: async (args, ctx) => {
|
|
591
810
|
if (!isRootSession(ctx.sessionID)) {
|
|
@@ -600,13 +819,34 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
600
819
|
)
|
|
601
820
|
}
|
|
602
821
|
|
|
822
|
+
// R4: per-agent step caps. An agent already at max_steps is forced to
|
|
823
|
+
// summarize-and-stop — the delegation is skipped with a capped summary
|
|
824
|
+
// (no session, no board job, no budget consumed). Otherwise record the
|
|
825
|
+
// step; when THIS step hits the cap, append the stop instruction to
|
|
826
|
+
// the prompt so the agent summarizes and stops instead of continuing.
|
|
827
|
+
let effectivePrompt = args.prompt
|
|
828
|
+
const stepCap = options.stepCapTracker
|
|
829
|
+
if (stepCap !== undefined) {
|
|
830
|
+
const maxSteps = stepCap.maxStepsFor(args.agent)
|
|
831
|
+
if (stepCap.isCapped(args.agent)) {
|
|
832
|
+
return cappedSummary(args.agent, maxSteps ?? DEFAULT_MAX_STEPS)
|
|
833
|
+
}
|
|
834
|
+
const rec = stepCap.recordStep(args.agent)
|
|
835
|
+
if (rec.capped && rec.maxSteps !== undefined) {
|
|
836
|
+
effectivePrompt = `${args.prompt}${buildStopInstruction(args.agent, rec.maxSteps)}`
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
603
840
|
// Fase B3: only the two read-only exceptions are budgeted. The official
|
|
604
841
|
// SDK provides the current agent on ToolContext. If a structural/older
|
|
605
842
|
// host omits it, do NOT infer identity from prompt, target, or process
|
|
606
843
|
// state: the budget is explicitly skipped and the delegation continues.
|
|
607
844
|
const parentAgent = normalizeToolAgent(ctx.agent)
|
|
608
845
|
const targetAgent = args.agent.toLowerCase()
|
|
609
|
-
if (
|
|
846
|
+
if (
|
|
847
|
+
options.enforceRuntimeMatrix === true &&
|
|
848
|
+
!isDelegationAllowed(parentAgent, targetAgent, options.permissionTask)
|
|
849
|
+
) {
|
|
610
850
|
const reason =
|
|
611
851
|
parentAgent === undefined
|
|
612
852
|
? 'ToolContext.agent is unavailable'
|
|
@@ -670,7 +910,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
670
910
|
created = await client.session.create({
|
|
671
911
|
body: {
|
|
672
912
|
parentID: ctx.sessionID,
|
|
673
|
-
title: args.description ??
|
|
913
|
+
title: args.description ?? effectivePrompt.slice(0, 80),
|
|
674
914
|
...(childModel !== undefined ? { model: childModel } : {}),
|
|
675
915
|
},
|
|
676
916
|
})
|
|
@@ -702,8 +942,8 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
702
942
|
taskID: childSessionID,
|
|
703
943
|
parentSessionID: ctx.sessionID,
|
|
704
944
|
agent: args.agent,
|
|
705
|
-
description: args.description ??
|
|
706
|
-
objective:
|
|
945
|
+
description: args.description ?? effectivePrompt,
|
|
946
|
+
objective: effectivePrompt,
|
|
707
947
|
})
|
|
708
948
|
|
|
709
949
|
// Timeout manager: cleared on finalize, unref'd so the timer never
|
|
@@ -719,19 +959,139 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
719
959
|
timer.unref()
|
|
720
960
|
timers.set(childSessionID, timer)
|
|
721
961
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
962
|
+
const promptBody = {
|
|
963
|
+
agent: args.agent,
|
|
964
|
+
...(childModel !== undefined ? { model: childModel } : {}),
|
|
965
|
+
parts: [{ type: 'text' as const, text: effectivePrompt }],
|
|
966
|
+
}
|
|
967
|
+
const promptOutcome = await promptWithTimeout(
|
|
968
|
+
client,
|
|
969
|
+
{ path: { id: childSessionID }, body: promptBody },
|
|
970
|
+
options.promptTimeoutMs ?? options.bootstrapTimeoutMs ?? BOOTSTRAP_TIMEOUT_MS,
|
|
971
|
+
)
|
|
972
|
+
let bootstrap: BootstrapOutcome
|
|
973
|
+
let safeRetry = false
|
|
974
|
+
if (promptOutcome.status === 'rejected') {
|
|
975
|
+
// A synchronous rejection is the only safe automatic retry signal: the
|
|
976
|
+
// host explicitly told us that it did not accept the request.
|
|
977
|
+
bootstrap = {
|
|
978
|
+
status: 'startup_failed',
|
|
979
|
+
elapsedMs: 0,
|
|
980
|
+
reason: `promptAsync rejected: ${promptOutcome.reason}`,
|
|
981
|
+
}
|
|
982
|
+
safeRetry = true
|
|
983
|
+
} else if (promptOutcome.status === 'timeout') {
|
|
984
|
+
// A timeout is ambiguous: the server may have accepted the prompt even
|
|
985
|
+
// though the client never received its response. Never resend it.
|
|
986
|
+
bootstrap = { status: 'startup_failed', elapsedMs: 0, reason: promptOutcome.reason }
|
|
987
|
+
} else {
|
|
988
|
+
bootstrap = await bootstrapChild(client, childSessionID, promptOutcome.value, options)
|
|
989
|
+
}
|
|
990
|
+
log.info(
|
|
991
|
+
`[pantheon-delegate] bootstrap child=${childSessionID} elapsed=${bootstrap.elapsedMs}ms reason=${bootstrap.reason}`,
|
|
992
|
+
)
|
|
993
|
+
if (bootstrap.status === 'started') {
|
|
994
|
+
return (
|
|
995
|
+
`Delegated to ${args.agent}: [${job.alias}] (task ${childSessionID}).\n` +
|
|
996
|
+
`Read the result with pantheon_delegation_read({ id: "${job.alias}" }).`
|
|
997
|
+
)
|
|
998
|
+
}
|
|
730
999
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1000
|
+
// Every startup diagnosis is terminal for this child. Only a prompt
|
|
1001
|
+
// rejection (known not accepted) permits one fresh-session retry;
|
|
1002
|
+
// accepted-empty, unavailable APIs, and request timeouts do not.
|
|
1003
|
+
const boardStartupState =
|
|
1004
|
+
bootstrap.status === 'bootstrap_unknown' ? 'startup_unknown' : 'startup_failed'
|
|
1005
|
+
await finalize(childSessionID, {
|
|
1006
|
+
state: boardStartupState,
|
|
1007
|
+
error: `${bootstrap.status}: ${bootstrap.reason}`,
|
|
1008
|
+
})
|
|
1009
|
+
if (!safeRetry) {
|
|
1010
|
+
const finalResult: DelegationResult = {
|
|
1011
|
+
status: bootstrap.status,
|
|
1012
|
+
content: `Child session ${childSessionID} did not start (${bootstrap.reason}).`,
|
|
1013
|
+
retryCount: 0,
|
|
1014
|
+
recommendation:
|
|
1015
|
+
bootstrap.status === 'bootstrap_unknown'
|
|
1016
|
+
? 'Inspect the child session in the board/TUI and retry manually only after confirming the prompt was not accepted.'
|
|
1017
|
+
: 'Inspect the child session/host promptAsync implementation; no automatic retry was attempted because acceptance was ambiguous.',
|
|
1018
|
+
}
|
|
1019
|
+
return formatDelegationResult(finalResult)
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
let retry: { id: string }
|
|
1023
|
+
try {
|
|
1024
|
+
retry = await client.session.create({
|
|
1025
|
+
body: {
|
|
1026
|
+
parentID: ctx.sessionID,
|
|
1027
|
+
title: args.description ?? effectivePrompt.slice(0, 80),
|
|
1028
|
+
...(childModel !== undefined ? { model: childModel } : {}),
|
|
1029
|
+
},
|
|
1030
|
+
})
|
|
1031
|
+
} catch (err: unknown) {
|
|
1032
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
1033
|
+
return `[STARTUP FAILED] ${bootstrap.status}: ${bootstrap.reason}; retry creation failed: ${reason}`
|
|
1034
|
+
}
|
|
1035
|
+
const retryID = retry.id
|
|
1036
|
+
knownChildren.add(retryID)
|
|
1037
|
+
options.registerChildSession?.(retryID, ctx.sessionID)
|
|
1038
|
+
const retryJob = await board.registerLaunch({
|
|
1039
|
+
taskID: retryID,
|
|
1040
|
+
parentSessionID: ctx.sessionID,
|
|
1041
|
+
agent: args.agent,
|
|
1042
|
+
description: args.description ?? effectivePrompt,
|
|
1043
|
+
objective: effectivePrompt,
|
|
1044
|
+
})
|
|
1045
|
+
const retryTimer = setTimeout(() => {
|
|
1046
|
+
timers.delete(retryID)
|
|
1047
|
+
void finalize(retryID, {
|
|
1048
|
+
state: 'error',
|
|
1049
|
+
error: `Delegation [${retryJob.alias}] timed out after ${timeoutMs}ms without reaching a terminal state`,
|
|
1050
|
+
timedOut: true,
|
|
1051
|
+
}).catch((err: unknown) =>
|
|
1052
|
+
log.error('[pantheon-delegate] retry timeout finalize failed:', err),
|
|
1053
|
+
)
|
|
1054
|
+
}, timeoutMs)
|
|
1055
|
+
retryTimer.unref()
|
|
1056
|
+
timers.set(retryID, retryTimer)
|
|
1057
|
+
const retryPrompt = await promptWithTimeout(
|
|
1058
|
+
client,
|
|
1059
|
+
{ path: { id: retryID }, body: promptBody },
|
|
1060
|
+
options.promptTimeoutMs ?? options.bootstrapTimeoutMs ?? BOOTSTRAP_TIMEOUT_MS,
|
|
734
1061
|
)
|
|
1062
|
+
let retryBootstrap: BootstrapOutcome
|
|
1063
|
+
if (retryPrompt.status === 'rejected') {
|
|
1064
|
+
retryBootstrap = {
|
|
1065
|
+
status: 'startup_failed',
|
|
1066
|
+
elapsedMs: 0,
|
|
1067
|
+
reason: `retry promptAsync rejected: ${retryPrompt.reason}`,
|
|
1068
|
+
}
|
|
1069
|
+
} else if (retryPrompt.status === 'timeout') {
|
|
1070
|
+
retryBootstrap = { status: 'startup_failed', elapsedMs: 0, reason: retryPrompt.reason }
|
|
1071
|
+
} else {
|
|
1072
|
+
retryBootstrap = await bootstrapChild(client, retryID, retryPrompt.value, options)
|
|
1073
|
+
}
|
|
1074
|
+
log.info(
|
|
1075
|
+
`[pantheon-delegate] bootstrap retry child=${retryID} elapsed=${retryBootstrap.elapsedMs}ms reason=${retryBootstrap.reason}`,
|
|
1076
|
+
)
|
|
1077
|
+
if (retryBootstrap.status === 'started') {
|
|
1078
|
+
return (
|
|
1079
|
+
`Delegated to ${args.agent}: [${retryJob.alias}] (task ${retryID}); retry=1.\n` +
|
|
1080
|
+
`Read the result with pantheon_delegation_read({ id: "${retryJob.alias}" }).`
|
|
1081
|
+
)
|
|
1082
|
+
}
|
|
1083
|
+
await finalize(retryID, {
|
|
1084
|
+
state: retryBootstrap.status === 'bootstrap_unknown' ? 'startup_unknown' : 'startup_failed',
|
|
1085
|
+
error: `startup retry exhausted: ${retryBootstrap.status}: ${retryBootstrap.reason}`,
|
|
1086
|
+
})
|
|
1087
|
+
const finalResult: DelegationResult = {
|
|
1088
|
+
status: retryBootstrap.status,
|
|
1089
|
+
content: `Child session ${retryID} did not start (${retryBootstrap.reason}).`,
|
|
1090
|
+
retryCount: 1,
|
|
1091
|
+
recommendation:
|
|
1092
|
+
'Inspect the child session/host promptAsync implementation; no further automatic retries will be attempted.',
|
|
1093
|
+
}
|
|
1094
|
+
return formatDelegationResult(finalResult)
|
|
735
1095
|
},
|
|
736
1096
|
}
|
|
737
1097
|
|
|
@@ -768,10 +1128,30 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
768
1128
|
return `Delegation [${terminal.alias}] reached state ${terminal.state} but no report file was found.`
|
|
769
1129
|
}
|
|
770
1130
|
|
|
1131
|
+
// Classify the result for stuck-agent detection
|
|
1132
|
+
const classification = classifyStuckAgent(md)
|
|
1133
|
+
const hasActivity = collector.sampled || collector.lines.length > 0
|
|
1134
|
+
const activitySuffix = hasActivity ? `\n\n${formatActivitySection(collector.lines)}` : ''
|
|
1135
|
+
|
|
1136
|
+
if (classification.status === 'success') {
|
|
1137
|
+
await board.markReconciled(job.taskID)
|
|
1138
|
+
// Backward compatible: success reports returned as-is
|
|
1139
|
+
if (!hasActivity) return md
|
|
1140
|
+
return `${md.replace(/\n+$/, '')}${activitySuffix}`
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Non-success: build structured DelegationResult and format
|
|
1144
|
+
const delegationResult: DelegationResult = {
|
|
1145
|
+
status: classification.status === 'empty' ? 'empty' : classification.status,
|
|
1146
|
+
content: md,
|
|
1147
|
+
retryCount: 0,
|
|
1148
|
+
partialResult: classification.partialResult,
|
|
1149
|
+
recommendation: classification.recommendation,
|
|
1150
|
+
}
|
|
1151
|
+
|
|
771
1152
|
await board.markReconciled(job.taskID)
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
return `${md.replace(/\n+$/, '')}\n\n${formatActivitySection(collector.lines)}`
|
|
1153
|
+
const formatted = formatDelegationResult(delegationResult)
|
|
1154
|
+
return `${formatted}${activitySuffix}`
|
|
775
1155
|
},
|
|
776
1156
|
}
|
|
777
1157
|
|
|
@@ -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)
|