pantheon-opencode 1.3.5-beta.60.efa533f → 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 +38 -6
- package/package.json +4 -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 +58 -15
- 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 +8 -2
- package/scripts/install/shared.mjs +8 -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/apollo.md +3 -0
- package/src/agents/demeter.md +1 -1
- package/src/agents/gaia.md +3 -0
- package/src/agents/hephaestus.md +1 -1
- package/src/agents/hermes.md +1 -1
- package/src/agents/themis.md +4 -0
- package/src/agents/zeus.md +5 -1
- 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 +67 -3
- package/src/pantheon/delegation-finalize.ts +44 -4
- package/src/pantheon/delegation.ts +413 -30
- package/src/pantheon/dispatch-guard.ts +2 -0
- package/src/pantheon/file-persistence.ts +7 -3
- package/src/pantheon/goal-loop.ts +13 -2
- 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 +135 -0
- package/src/pantheon/retry-policy.ts +212 -0
- package/src/pantheon/session-guard.ts +48 -0
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/todo-enforcer.ts +31 -5
- package/src/pantheon/todo-preserve.ts +7 -1
- package/src/pantheon/vision.ts +7 -5
- package/src/pantheon/zeus-delegate-with-retry.ts +401 -0
- package/src/plugin.ts +72 -13
- 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 +36 -14
- package/src/plugins/tui/dist/tui.tsx +71 -16
- package/src/plugins/tui/package.json +1 -1
- package/src/plugins/tui/src/index.tsx +71 -16
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +47 -0
- package/src/agents/README.md +0 -26
|
@@ -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`
|
|
@@ -365,15 +583,18 @@ export interface ChildSessionModelRef {
|
|
|
365
583
|
}
|
|
366
584
|
|
|
367
585
|
/**
|
|
368
|
-
* Known-good fallback model (P1, 2026-08-11 — release 1.3.4
|
|
586
|
+
* Known-good fallback model (P1, 2026-08-11 — release 1.3.4, updated
|
|
587
|
+
* 2026-08-21 for sandbox without PANTHEON_OPENCODE_API_KEY): when an
|
|
369
588
|
* AUTO-RESOLVED model (routing.yml agent entry / active preset) points at a
|
|
370
589
|
* provider whose API key is not configured, the child is dispatched on the
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
590
|
+
* `opencode-go` provider instead — `opencode-go/deepseek-v4-flash`
|
|
591
|
+
* via https://opencode.ai/zen/go/v1 works without an external API key
|
|
592
|
+
* (Go subscription / opencode auth, sandbox default). Validated with the
|
|
593
|
+
* same providerKeyConfigured gate as the resolved model, with an
|
|
594
|
+
* opencode-go exception for the sandbox; an explicit caller-supplied
|
|
595
|
+
* `model` always wins over this fallback.
|
|
375
596
|
*/
|
|
376
|
-
const FALLBACK_MODEL = 'opencode/deepseek-v4-flash
|
|
597
|
+
const FALLBACK_MODEL = 'opencode-go/deepseek-v4-flash'
|
|
377
598
|
|
|
378
599
|
/**
|
|
379
600
|
* Split a `provider/model` model ID (e.g. `opencode/deepseek-v4-flash-free`)
|
|
@@ -484,7 +705,11 @@ function resolveUsableChildModel(
|
|
|
484
705
|
|
|
485
706
|
// Auto-resolved (agentModels / preset) → try the known-good fallback.
|
|
486
707
|
const fallback = splitModelRef(FALLBACK_MODEL)
|
|
487
|
-
if (
|
|
708
|
+
if (
|
|
709
|
+
fallback !== undefined &&
|
|
710
|
+
(fallback.providerID === 'opencode-go' ||
|
|
711
|
+
missingProviderKeyEnv(fallback.providerID, { env }) === undefined)
|
|
712
|
+
) {
|
|
488
713
|
warn(
|
|
489
714
|
`[pantheon-delegate] provider "${model.providerID}" requires API key ${missingVar} ` +
|
|
490
715
|
`(unset) — falling back to ${FALLBACK_MODEL}`,
|
|
@@ -579,10 +804,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
579
804
|
}
|
|
580
805
|
|
|
581
806
|
const pantheon_delegate: DelegationTool<typeof delegateArgs> = {
|
|
582
|
-
description:
|
|
583
|
-
'Dispatch a background agent as a child session and register it on the job board. ' +
|
|
584
|
-
'Returns the readable alias (e.g. "apo-1"); read the result with ' +
|
|
585
|
-
'pantheon_delegation_read. Only root sessions may delegate.',
|
|
807
|
+
description: buildDelegateDescription(options),
|
|
586
808
|
args: delegateArgs,
|
|
587
809
|
execute: async (args, ctx) => {
|
|
588
810
|
if (!isRootSession(ctx.sessionID)) {
|
|
@@ -597,13 +819,34 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
597
819
|
)
|
|
598
820
|
}
|
|
599
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
|
+
|
|
600
840
|
// Fase B3: only the two read-only exceptions are budgeted. The official
|
|
601
841
|
// SDK provides the current agent on ToolContext. If a structural/older
|
|
602
842
|
// host omits it, do NOT infer identity from prompt, target, or process
|
|
603
843
|
// state: the budget is explicitly skipped and the delegation continues.
|
|
604
844
|
const parentAgent = normalizeToolAgent(ctx.agent)
|
|
605
845
|
const targetAgent = args.agent.toLowerCase()
|
|
606
|
-
if (
|
|
846
|
+
if (
|
|
847
|
+
options.enforceRuntimeMatrix === true &&
|
|
848
|
+
!isDelegationAllowed(parentAgent, targetAgent, options.permissionTask)
|
|
849
|
+
) {
|
|
607
850
|
const reason =
|
|
608
851
|
parentAgent === undefined
|
|
609
852
|
? 'ToolContext.agent is unavailable'
|
|
@@ -653,7 +896,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
653
896
|
// preset) is used when its provider key is configured; an explicit
|
|
654
897
|
// caller model is always respected (warned); an auto-resolved model
|
|
655
898
|
// whose provider key is missing falls back to the known-good
|
|
656
|
-
// opencode/deepseek-v4-flash
|
|
899
|
+
// opencode-go/deepseek-v4-flash; if nothing usable remains the
|
|
657
900
|
// failure is returned as TEXT below — no session, no board job.
|
|
658
901
|
const resolved = resolveUsableChildModel(options, args.agent, args.model)
|
|
659
902
|
if (resolved.error !== undefined) {
|
|
@@ -667,7 +910,7 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
667
910
|
created = await client.session.create({
|
|
668
911
|
body: {
|
|
669
912
|
parentID: ctx.sessionID,
|
|
670
|
-
title: args.description ??
|
|
913
|
+
title: args.description ?? effectivePrompt.slice(0, 80),
|
|
671
914
|
...(childModel !== undefined ? { model: childModel } : {}),
|
|
672
915
|
},
|
|
673
916
|
})
|
|
@@ -699,8 +942,8 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
699
942
|
taskID: childSessionID,
|
|
700
943
|
parentSessionID: ctx.sessionID,
|
|
701
944
|
agent: args.agent,
|
|
702
|
-
description: args.description ??
|
|
703
|
-
objective:
|
|
945
|
+
description: args.description ?? effectivePrompt,
|
|
946
|
+
objective: effectivePrompt,
|
|
704
947
|
})
|
|
705
948
|
|
|
706
949
|
// Timeout manager: cleared on finalize, unref'd so the timer never
|
|
@@ -716,19 +959,139 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
716
959
|
timer.unref()
|
|
717
960
|
timers.set(childSessionID, timer)
|
|
718
961
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
+
}
|
|
727
999
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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,
|
|
731
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)
|
|
732
1095
|
},
|
|
733
1096
|
}
|
|
734
1097
|
|
|
@@ -765,10 +1128,30 @@ export function createDelegationTools(input: CreateDelegationToolsInput): Delega
|
|
|
765
1128
|
return `Delegation [${terminal.alias}] reached state ${terminal.state} but no report file was found.`
|
|
766
1129
|
}
|
|
767
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
|
+
|
|
768
1152
|
await board.markReconciled(job.taskID)
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
return `${md.replace(/\n+$/, '')}\n\n${formatActivitySection(collector.lines)}`
|
|
1153
|
+
const formatted = formatDelegationResult(delegationResult)
|
|
1154
|
+
return `${formatted}${activitySuffix}`
|
|
772
1155
|
},
|
|
773
1156
|
}
|
|
774
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)
|
|
@@ -20,6 +20,7 @@ import type { BackgroundJobBoard } from './background-job-board.ts'
|
|
|
20
20
|
import type { ToolContextLike } from './delegation.ts'
|
|
21
21
|
import { GoalStore } from './goal-store.ts'
|
|
22
22
|
import { createPantheonLogger } from './logger.ts'
|
|
23
|
+
import { safeSessionPath } from './session-guard.ts'
|
|
23
24
|
import type { TodoEnforcerMessage } from './todo-enforcer.ts'
|
|
24
25
|
|
|
25
26
|
// Silence-by-default TUI policy (pantheon-hooks L42-58): warn → hooks.log,
|
|
@@ -188,8 +189,13 @@ export class GoalLoop {
|
|
|
188
189
|
this.lastContinuationAt.set(sessionID, now)
|
|
189
190
|
const inherited = await this.inheritAgentOrModel(sessionID)
|
|
190
191
|
const prompt = GOAL_CONTINUATION_PROMPT.replace('{objective}', goal.objective)
|
|
192
|
+
const promptPath = safeSessionPath(sessionID)
|
|
193
|
+
if (!promptPath) {
|
|
194
|
+
this.warn(`goal continuation skipped: invalid sessionID "${sessionID}"`)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
191
197
|
await this.client.session.promptAsync({
|
|
192
|
-
path:
|
|
198
|
+
path: promptPath.path,
|
|
193
199
|
body: {
|
|
194
200
|
...(inherited.agent !== undefined ? { agent: inherited.agent } : {}),
|
|
195
201
|
...(inherited.model !== undefined ? { model: inherited.model } : {}),
|
|
@@ -206,7 +212,12 @@ export class GoalLoop {
|
|
|
206
212
|
* continuation runs in the same context as the session's last turn.
|
|
207
213
|
*/
|
|
208
214
|
private async inheritAgentOrModel(sessionID: string): Promise<InheritedContext> {
|
|
209
|
-
const
|
|
215
|
+
const path = safeSessionPath(sessionID)
|
|
216
|
+
if (!path) {
|
|
217
|
+
this.warn(`inherit skipped: invalid sessionID "${sessionID}"`)
|
|
218
|
+
return {}
|
|
219
|
+
}
|
|
220
|
+
const messages = await this.client.session.messages(path)
|
|
210
221
|
for (const msg of [...messages].reverse()) {
|
|
211
222
|
const info = msg.info
|
|
212
223
|
if (info === undefined || info.role !== 'assistant') continue
|