pantheon-opencode 1.5.0-beta.2 → 1.5.0-beta.4
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/.pantheon/code-mode/manifest.json +21 -0
- package/CHANGELOG.md +835 -0
- package/README.md +103 -15
- package/README.pt-BR.md +57 -13
- package/package.json +8 -3
- package/plugin.json +1 -1
- package/pyproject.toml +57 -0
- package/scripts/code_mode_server.py +403 -42
- package/scripts/install/opencode.mjs +42 -0
- package/scripts/install/plugin.mjs +3 -33
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +516 -163
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/probe-pantheon-cost.mjs +10 -1
- package/scripts/release-beta-version.mjs +61 -60
- package/scripts/sync-tui.mjs +76 -54
- package/scripts/test-opencode-v1-v2-sandbox.sh +79 -161
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +131 -88
- package/src/agents/zeus.md +2 -2
- package/src/mcp/code_mode_server.py +403 -42
- package/src/mcp/mcp_persistence_server.py +516 -163
- package/src/pantheon/background-job-board.ts +14 -0
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/cost-command.ts +62 -42
- package/src/pantheon/delegate-manager.ts +109 -12
- package/src/pantheon/native-probe.ts +234 -0
- package/src/pantheon/native-task-status.ts +139 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/pantheon/task-result-guard.ts +107 -0
- package/src/pantheon/token-opt.ts +18 -110
- package/src/pantheon/tool-ceiling.ts +159 -0
- package/src/pantheon/v2-events.ts +10 -1
- package/src/plugin.ts +19 -1
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
type DelegationClient,
|
|
32
32
|
readDelegationReport,
|
|
33
33
|
} from './delegation-finalize.ts'
|
|
34
|
+
import { createNativeProbe, type NativeProbeFn, type ProbeClient } from './native-probe.ts'
|
|
35
|
+
import { classifyNativeResult, type NativeTaskStatus } from './native-task-status.ts'
|
|
34
36
|
import {
|
|
35
37
|
buildStopInstruction,
|
|
36
38
|
cappedSummary,
|
|
@@ -66,6 +68,7 @@ export interface ManagerReceipt {
|
|
|
66
68
|
id: string
|
|
67
69
|
agent: string
|
|
68
70
|
state: 'reconciled' | 'error'
|
|
71
|
+
status: NativeTaskStatus
|
|
69
72
|
line: string
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -102,10 +105,15 @@ export interface DelegateManagerOptions {
|
|
|
102
105
|
keepCompleted?: number
|
|
103
106
|
/** Per-agent step budgets (routing.yml max_steps). */
|
|
104
107
|
stepCap?: StepCapTracker
|
|
108
|
+
/** Lazy capability probe, invoked once before the first native dispatch. */
|
|
109
|
+
nativeProbe?: NativeProbeFn
|
|
110
|
+
/** V1 client used by the lazy capability probe. */
|
|
111
|
+
probeClient?: ProbeClient
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
export interface DelegateManager {
|
|
108
115
|
launch(args: LaunchArgs): Promise<ManagerReceipt>
|
|
116
|
+
ensureNativeAvailable(): Promise<NativeTaskStatus | undefined>
|
|
109
117
|
read(idOrAlias: string): Promise<string>
|
|
110
118
|
list(): Promise<string[]>
|
|
111
119
|
recover(): Promise<void>
|
|
@@ -131,9 +139,29 @@ export function resolveDelegateMode(
|
|
|
131
139
|
|
|
132
140
|
const LINE_MAX = 160
|
|
133
141
|
|
|
142
|
+
/** Remove terminal escapes and control characters from untrusted child output. */
|
|
143
|
+
export function sanitizeReceiptText(text: string): string {
|
|
144
|
+
let clean = ''
|
|
145
|
+
let skippingAnsi = false
|
|
146
|
+
for (const char of text) {
|
|
147
|
+
const code = char.charCodeAt(0)
|
|
148
|
+
if (skippingAnsi) {
|
|
149
|
+
if (code === 0x5b) continue
|
|
150
|
+
if (code >= 0x40 && code <= 0x7e) skippingAnsi = false
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (code === 0x1b) {
|
|
154
|
+
skippingAnsi = true
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (char === '\n' || char === '\t' || (code >= 0x20 && code <= 0x7e)) clean += char
|
|
158
|
+
}
|
|
159
|
+
return clean
|
|
160
|
+
}
|
|
161
|
+
|
|
134
162
|
function firstLine(text: string, max = 80): string {
|
|
135
163
|
const line =
|
|
136
|
-
(text ?? '')
|
|
164
|
+
sanitizeReceiptText(text ?? '')
|
|
137
165
|
.split('\n')
|
|
138
166
|
.map((l) => l.trim())
|
|
139
167
|
.find((l) => l !== '') ?? ''
|
|
@@ -182,21 +210,30 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
|
|
|
182
210
|
task,
|
|
183
211
|
parentSessionID,
|
|
184
212
|
env = process.env,
|
|
185
|
-
models = [],
|
|
213
|
+
models: modelsOpt = [],
|
|
186
214
|
timeoutMs = 120_000,
|
|
187
215
|
readTimeoutMs = 60_000,
|
|
188
|
-
foregroundFallback = true,
|
|
216
|
+
foregroundFallback: fgFallbackOpt = true,
|
|
189
217
|
maxEntries = 50,
|
|
190
218
|
keepCompleted = 10,
|
|
191
219
|
stepCap,
|
|
220
|
+
nativeProbe,
|
|
221
|
+
probeClient,
|
|
192
222
|
} = options
|
|
193
223
|
|
|
224
|
+
// Strict native mode: foregroundFallback and models are overridden.
|
|
225
|
+
// The user-facing options are ignored when PANTHEON_DELEGATE_MODE=native.
|
|
226
|
+
const isNativeMode = resolveDelegateMode(env) === 'native'
|
|
227
|
+
const foregroundFallback = isNativeMode ? false : fgFallbackOpt
|
|
228
|
+
const models = isNativeMode ? [] : modelsOpt
|
|
229
|
+
|
|
194
230
|
// Synchronous in-flight reservations per agent: launch() must decide
|
|
195
231
|
// background vs foreground BEFORE its first await, otherwise concurrent
|
|
196
232
|
// launches without interleaved awaits would all see an empty board.
|
|
197
233
|
const pendingByAgent = new Map<string, number>()
|
|
198
234
|
let seq = 0
|
|
199
235
|
const foregroundRecent: { agent: string; line: string; at: number }[] = []
|
|
236
|
+
let probeResult: Awaited<ReturnType<NativeProbeFn>> | null = null
|
|
200
237
|
|
|
201
238
|
function pending(agent: string): number {
|
|
202
239
|
return pendingByAgent.get(agent.toLowerCase()) ?? 0
|
|
@@ -208,6 +245,12 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
|
|
|
208
245
|
}
|
|
209
246
|
}
|
|
210
247
|
|
|
248
|
+
async function ensureNativeAvailable(): Promise<NativeTaskStatus | undefined> {
|
|
249
|
+
if (!isNativeMode || nativeProbe === undefined || probeClient === undefined) return undefined
|
|
250
|
+
probeResult ??= await nativeProbe({ client: probeClient, parentSessionID })
|
|
251
|
+
return probeResult.status === 'AVAILABLE' ? undefined : probeResult.status
|
|
252
|
+
}
|
|
253
|
+
|
|
211
254
|
async function runTask(
|
|
212
255
|
agent: string,
|
|
213
256
|
prompt: string,
|
|
@@ -253,6 +296,7 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
|
|
|
253
296
|
// acknowledgment that the result was consumed.
|
|
254
297
|
const terminalState = job.state
|
|
255
298
|
const failed = verifyError !== undefined || terminalState === 'error'
|
|
299
|
+
const status = classifyNativeResult(job, verifyError)
|
|
256
300
|
// Auto-reconcile + delete the signal file on the spot.
|
|
257
301
|
if (job.state !== 'reconciled') await board.markReconciled(taskID)
|
|
258
302
|
await board.deleteSignal(alias)
|
|
@@ -266,7 +310,7 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
|
|
|
266
310
|
LINE_MAX,
|
|
267
311
|
)
|
|
268
312
|
: boardLine(settled ?? job)
|
|
269
|
-
return { id: taskID, agent: job.agent, state: failed ? 'error' : 'reconciled', line }
|
|
313
|
+
return { id: taskID, agent: job.agent, state: failed ? 'error' : 'reconciled', status, line }
|
|
270
314
|
}
|
|
271
315
|
|
|
272
316
|
/** Apply a terminal transition best-effort: a racing hook may have gotten there first. */
|
|
@@ -333,7 +377,33 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
|
|
|
333
377
|
)
|
|
334
378
|
foregroundRecent.push({ agent: args.agent, line, at: Date.now() })
|
|
335
379
|
if (foregroundRecent.length > 20) foregroundRecent.splice(0, foregroundRecent.length - 20)
|
|
336
|
-
|
|
380
|
+
const status = classifyNativeResult(
|
|
381
|
+
{
|
|
382
|
+
taskID: id,
|
|
383
|
+
parentSessionID,
|
|
384
|
+
agent: args.agent,
|
|
385
|
+
description: args.description ?? firstLine(args.prompt, 120),
|
|
386
|
+
state: ok ? 'completed' : 'error',
|
|
387
|
+
timedOut: false,
|
|
388
|
+
alias: id,
|
|
389
|
+
launchedAt: Date.now(),
|
|
390
|
+
updatedAt: Date.now(),
|
|
391
|
+
totalErrors: 0,
|
|
392
|
+
timeoutCount: 0,
|
|
393
|
+
terminalUnreconciled: false,
|
|
394
|
+
contextFiles: [],
|
|
395
|
+
...(ok
|
|
396
|
+
? {
|
|
397
|
+
resultSummary: firstLine(
|
|
398
|
+
(outcome as { ok: true; result: NativeTaskResult }).result.content ?? '',
|
|
399
|
+
500,
|
|
400
|
+
),
|
|
401
|
+
}
|
|
402
|
+
: {}),
|
|
403
|
+
},
|
|
404
|
+
ok ? undefined : (outcome as { ok: false; error?: string }).error,
|
|
405
|
+
)
|
|
406
|
+
return { id, agent: args.agent, state: ok ? 'reconciled' : 'error', status, line }
|
|
337
407
|
}
|
|
338
408
|
|
|
339
409
|
return {
|
|
@@ -346,13 +416,31 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
|
|
|
346
416
|
const maxSteps = stepCap.maxStepsFor(args.agent)
|
|
347
417
|
if (stepCap.isCapped(args.agent)) {
|
|
348
418
|
const line = cappedSummary(args.agent, maxSteps ?? DEFAULT_MAX_STEPS).slice(0, LINE_MAX)
|
|
349
|
-
return {
|
|
419
|
+
return {
|
|
420
|
+
id: `cap-${Date.now()}`,
|
|
421
|
+
agent: args.agent,
|
|
422
|
+
state: 'reconciled',
|
|
423
|
+
status: 'ESCALATE',
|
|
424
|
+
line,
|
|
425
|
+
}
|
|
350
426
|
}
|
|
351
427
|
const rec = stepCap.recordStep(args.agent)
|
|
352
428
|
if (rec.capped && rec.maxSteps !== undefined) {
|
|
353
429
|
prompt = `${args.prompt}${buildStopInstruction(args.agent, rec.maxSteps)}`
|
|
354
430
|
}
|
|
355
431
|
}
|
|
432
|
+
if (isNativeMode) {
|
|
433
|
+
const unavailableStatus = await ensureNativeAvailable()
|
|
434
|
+
if (unavailableStatus !== undefined) {
|
|
435
|
+
return {
|
|
436
|
+
id: `probe-${parentSessionID}`,
|
|
437
|
+
agent: args.agent,
|
|
438
|
+
state: 'error',
|
|
439
|
+
status: unavailableStatus,
|
|
440
|
+
line: `[native] ${args.agent} — native API ${unavailableStatus.toLowerCase()} — ERR`,
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
356
444
|
// Synchronous routing decision: background when a slot is free,
|
|
357
445
|
// foreground fallback when full, clean rejection otherwise.
|
|
358
446
|
const free =
|
|
@@ -363,6 +451,7 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
|
|
|
363
451
|
`pantheon_delegate rejected: concurrency limit reached for agent "${args.agent}"`,
|
|
364
452
|
)
|
|
365
453
|
},
|
|
454
|
+
ensureNativeAvailable,
|
|
366
455
|
|
|
367
456
|
async read(idOrAlias: string): Promise<string> {
|
|
368
457
|
guardEnabled()
|
|
@@ -376,11 +465,11 @@ export function createDelegateManager(options: DelegateManagerOptions): Delegate
|
|
|
376
465
|
if (terminal.state !== 'reconciled') await board.markReconciled(terminal.taskID)
|
|
377
466
|
await board.deleteSignal(terminal.alias)
|
|
378
467
|
if (terminal.state === 'error' && !terminal.resultSummary) {
|
|
379
|
-
return `# Delegation Report — ${terminal.alias}\n\nERROR: ${terminal.lastStatusError ?? 'failed'}\n`
|
|
468
|
+
return `# Delegation Report — ${terminal.alias}\n\nERROR: ${sanitizeReceiptText(terminal.lastStatusError ?? 'failed')}\n`
|
|
380
469
|
}
|
|
381
470
|
return (
|
|
382
471
|
`# Delegation Report — ${terminal.alias}\n\n` +
|
|
383
|
-
`${terminal.resultSummary ?? terminal.lastStatusError ?? terminal.state}\n`
|
|
472
|
+
`${sanitizeReceiptText(terminal.resultSummary ?? terminal.lastStatusError ?? terminal.state)}\n`
|
|
384
473
|
)
|
|
385
474
|
},
|
|
386
475
|
|
|
@@ -524,7 +613,7 @@ export interface NativeDelegateToolset {
|
|
|
524
613
|
}
|
|
525
614
|
function shortFirstLine(text: string, max = 60): string {
|
|
526
615
|
const line =
|
|
527
|
-
text
|
|
616
|
+
sanitizeReceiptText(text)
|
|
528
617
|
.split('\n')
|
|
529
618
|
.map((item) => item.trim())
|
|
530
619
|
.find(Boolean) ?? ''
|
|
@@ -555,6 +644,7 @@ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeD
|
|
|
555
644
|
const managerFor = (parentSessionID: string): DelegateManager => {
|
|
556
645
|
const existing = managers.get(parentSessionID)
|
|
557
646
|
if (existing !== undefined) return existing
|
|
647
|
+
const nativeProbe = createNativeProbe()
|
|
558
648
|
const manager = createDelegateManager({
|
|
559
649
|
...managerOptions,
|
|
560
650
|
board,
|
|
@@ -562,6 +652,8 @@ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeD
|
|
|
562
652
|
parentSessionID,
|
|
563
653
|
env,
|
|
564
654
|
...(stepCap === undefined ? {} : { stepCap }),
|
|
655
|
+
nativeProbe,
|
|
656
|
+
probeClient: client,
|
|
565
657
|
})
|
|
566
658
|
managers.set(parentSessionID, manager)
|
|
567
659
|
return manager
|
|
@@ -577,6 +669,11 @@ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeD
|
|
|
577
669
|
throw new Error(
|
|
578
670
|
`pantheon_delegate rejected: session ${ctx.sessionID} is a sub-session — only root sessions can delegate`,
|
|
579
671
|
)
|
|
672
|
+
const manager = managerFor(ctx.sessionID)
|
|
673
|
+
const unavailableStatus = await manager.ensureNativeAvailable()
|
|
674
|
+
if (unavailableStatus !== undefined) {
|
|
675
|
+
return `status: ${unavailableStatus}\n[native] ${args.agent} — native API ${unavailableStatus.toLowerCase()} — ERR`
|
|
676
|
+
}
|
|
580
677
|
const model = args.model ?? agentModel?.(args.agent)
|
|
581
678
|
let childID: string
|
|
582
679
|
try {
|
|
@@ -590,7 +687,7 @@ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeD
|
|
|
590
687
|
})
|
|
591
688
|
childID = created.id
|
|
592
689
|
} catch (error) {
|
|
593
|
-
return `pantheon_delegate failed: session.create rejected: ${error instanceof Error ? error.message : String(error)}`
|
|
690
|
+
return `pantheon_delegate failed: session.create rejected: ${sanitizeReceiptText(error instanceof Error ? error.message : String(error))}`
|
|
594
691
|
}
|
|
595
692
|
registerChildSession?.(childID, ctx.sessionID)
|
|
596
693
|
if (args.read_only === true || isReadOnlyAgent?.(args.agent) === true)
|
|
@@ -598,14 +695,14 @@ export function createNativeDelegateTools(wiring: NativeDelegateWiring): NativeD
|
|
|
598
695
|
agent: args.agent,
|
|
599
696
|
...(args.read_only === undefined ? {} : { readOnlyFlag: args.read_only }),
|
|
600
697
|
})
|
|
601
|
-
const receipt = await
|
|
698
|
+
const receipt = await manager.launch({
|
|
602
699
|
agent: args.agent,
|
|
603
700
|
prompt: args.prompt,
|
|
604
701
|
...(args.description === undefined ? {} : { description: args.description }),
|
|
605
702
|
...(args.model === undefined ? {} : { model: args.model }),
|
|
606
703
|
taskID: childID,
|
|
607
704
|
})
|
|
608
|
-
return receipt.line
|
|
705
|
+
return `status: ${receipt.status}\n${receipt.line}`
|
|
609
706
|
},
|
|
610
707
|
},
|
|
611
708
|
pantheon_delegation_read: {
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Probe — lazy capability detection for native task() delegation.
|
|
3
|
+
*
|
|
4
|
+
* The probe is invoked on the FIRST native delegation of a session (not at
|
|
5
|
+
* startup). It creates a child session with a minimal "ping" prompt, waits
|
|
6
|
+
* for idle, and classifies the output.
|
|
7
|
+
*
|
|
8
|
+
* Results are cached per probe instance (singleton guard):
|
|
9
|
+
* - UNAVAILABLE is TERMINAL — no retry.
|
|
10
|
+
* - AVAILABLE is also cached — no re-probe.
|
|
11
|
+
*
|
|
12
|
+
* Uses the OpenCode V1 session API (session.create + promptAsync) injected via
|
|
13
|
+
* the parent context.
|
|
14
|
+
*
|
|
15
|
+
* @module native-probe
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createPantheonLogger } from './logger.ts'
|
|
19
|
+
|
|
20
|
+
const log = createPantheonLogger({ module: 'native-probe' })
|
|
21
|
+
|
|
22
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Possible outcomes of a native capability probe. */
|
|
25
|
+
export type NativeProbeResult = {
|
|
26
|
+
status: 'AVAILABLE' | 'UNAVAILABLE' | 'UNSUPPORTED'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Minimal V1 client shape needed by the probe (session.create + promptAsync). */
|
|
30
|
+
export interface ProbeClient {
|
|
31
|
+
session: {
|
|
32
|
+
create: (input: { body: { parentID: string } }) => Promise<{ id: string }>
|
|
33
|
+
promptAsync: (input: {
|
|
34
|
+
path: { id: string }
|
|
35
|
+
body: { agent: string; parts: Array<{ type: 'text'; text: string }> }
|
|
36
|
+
}) => Promise<unknown>
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Context injected into the probe function. */
|
|
41
|
+
export interface NativeProbeContext {
|
|
42
|
+
client: ProbeClient
|
|
43
|
+
parentSessionID: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Options for a single probe invocation. */
|
|
47
|
+
export interface NativeProbeOptions {
|
|
48
|
+
/** Timeout in milliseconds. Default: 30000. */
|
|
49
|
+
timeoutMs?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The probe function signature. */
|
|
53
|
+
export type NativeProbeFn = (
|
|
54
|
+
ctx: NativeProbeContext,
|
|
55
|
+
options?: NativeProbeOptions,
|
|
56
|
+
) => Promise<NativeProbeResult>
|
|
57
|
+
|
|
58
|
+
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/** Default timeout for the probe. */
|
|
61
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
62
|
+
|
|
63
|
+
/** Sentinel value indicating a timeout occurred. */
|
|
64
|
+
const TIMEOUT_SENTINEL = Symbol('timeout')
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wrap a promise with a timeout.
|
|
68
|
+
* Returns the value on success, throws the original error on rejection,
|
|
69
|
+
* or returns TIMEOUT_SENTINEL if the timeout fires first.
|
|
70
|
+
*/
|
|
71
|
+
async function withTimeout<T>(
|
|
72
|
+
promise: Promise<T>,
|
|
73
|
+
ms: number,
|
|
74
|
+
): Promise<T | typeof TIMEOUT_SENTINEL> {
|
|
75
|
+
return new Promise<T | typeof TIMEOUT_SENTINEL>((resolve, reject) => {
|
|
76
|
+
const timer = setTimeout(() => resolve(TIMEOUT_SENTINEL), ms)
|
|
77
|
+
void promise.then(
|
|
78
|
+
(value) => {
|
|
79
|
+
clearTimeout(timer)
|
|
80
|
+
resolve(value)
|
|
81
|
+
},
|
|
82
|
+
(err: unknown) => {
|
|
83
|
+
clearTimeout(timer)
|
|
84
|
+
reject(err)
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Extract text content from a promptAsync response.
|
|
92
|
+
* Returns empty string if no recognizable content found.
|
|
93
|
+
*/
|
|
94
|
+
function extractOutput(result: unknown): string {
|
|
95
|
+
if (result == null) return ''
|
|
96
|
+
if (typeof result === 'string') return result
|
|
97
|
+
if (typeof result === 'object') {
|
|
98
|
+
const obj = result as Record<string, unknown>
|
|
99
|
+
// Check common shapes: { output }, { content }, { text }
|
|
100
|
+
if (typeof obj.output === 'string') return obj.output
|
|
101
|
+
if (typeof obj.content === 'string') return obj.content
|
|
102
|
+
if (typeof obj.text === 'string') return obj.text
|
|
103
|
+
}
|
|
104
|
+
// Non-empty object → treat as content (accepted message, etc.)
|
|
105
|
+
if (typeof result === 'object' && result !== null) {
|
|
106
|
+
return 'acknowledged'
|
|
107
|
+
}
|
|
108
|
+
return ''
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isUnsupportedError(message: string): boolean {
|
|
112
|
+
const lower = message.toLowerCase()
|
|
113
|
+
return (
|
|
114
|
+
lower.includes('not available') ||
|
|
115
|
+
lower.includes('not supported') ||
|
|
116
|
+
lower.includes('unsupported') ||
|
|
117
|
+
lower.includes('method not found')
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Implementation ────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create a native probe function with per-instance caching.
|
|
125
|
+
*
|
|
126
|
+
* The probe is LAZY: it does not run at startup. Call it on the first
|
|
127
|
+
* native delegation of the session. The result is cached — subsequent
|
|
128
|
+
* calls return the cached value without re-probing.
|
|
129
|
+
*
|
|
130
|
+
* @returns A probe function that caches results per instance.
|
|
131
|
+
*/
|
|
132
|
+
export function createNativeProbe(): NativeProbeFn {
|
|
133
|
+
let cachedResult: NativeProbeResult | null = null
|
|
134
|
+
let inFlight: Promise<NativeProbeResult> | null = null
|
|
135
|
+
|
|
136
|
+
return async (
|
|
137
|
+
ctx: NativeProbeContext,
|
|
138
|
+
options?: NativeProbeOptions,
|
|
139
|
+
): Promise<NativeProbeResult> => {
|
|
140
|
+
// Return cached result if available (singleton guard)
|
|
141
|
+
if (cachedResult !== null) {
|
|
142
|
+
return cachedResult
|
|
143
|
+
}
|
|
144
|
+
// Concurrent first dispatches share one probe instead of creating multiple
|
|
145
|
+
// child sessions. The promise is cleared only after a terminal result.
|
|
146
|
+
if (inFlight !== null) return inFlight
|
|
147
|
+
|
|
148
|
+
inFlight = probe(ctx, options)
|
|
149
|
+
try {
|
|
150
|
+
return await inFlight
|
|
151
|
+
} finally {
|
|
152
|
+
inFlight = null
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function probe(
|
|
157
|
+
ctx: NativeProbeContext,
|
|
158
|
+
options?: NativeProbeOptions,
|
|
159
|
+
): Promise<NativeProbeResult> {
|
|
160
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
161
|
+
|
|
162
|
+
// ── Phase 1: create child session ──────────────────────────────
|
|
163
|
+
let childID: string
|
|
164
|
+
try {
|
|
165
|
+
const createResult = await withTimeout(
|
|
166
|
+
ctx.client.session.create({ body: { parentID: ctx.parentSessionID } }),
|
|
167
|
+
timeoutMs,
|
|
168
|
+
)
|
|
169
|
+
if (createResult === TIMEOUT_SENTINEL) {
|
|
170
|
+
// Timeout during session.create → UNAVAILABLE
|
|
171
|
+
log.warn('[Native Probe] session.create timed out after', timeoutMs, 'ms')
|
|
172
|
+
const result: NativeProbeResult = { status: 'UNAVAILABLE' }
|
|
173
|
+
cachedResult = result
|
|
174
|
+
return result
|
|
175
|
+
}
|
|
176
|
+
childID = createResult.id
|
|
177
|
+
} catch (err: unknown) {
|
|
178
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
179
|
+
log.error('[Native Probe] session.create failed:', msg)
|
|
180
|
+
// Check for unsupported API patterns
|
|
181
|
+
if (isUnsupportedError(msg)) {
|
|
182
|
+
const result: NativeProbeResult = { status: 'UNSUPPORTED' }
|
|
183
|
+
cachedResult = result
|
|
184
|
+
return result
|
|
185
|
+
}
|
|
186
|
+
// Other creation failures → UNAVAILABLE
|
|
187
|
+
const result: NativeProbeResult = { status: 'UNAVAILABLE' }
|
|
188
|
+
cachedResult = result
|
|
189
|
+
return result
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Phase 2: send probe prompt ─────────────────────────────────
|
|
193
|
+
let promptResult: unknown
|
|
194
|
+
try {
|
|
195
|
+
const raw = await withTimeout(
|
|
196
|
+
ctx.client.session.promptAsync({
|
|
197
|
+
path: { id: childID },
|
|
198
|
+
body: { agent: 'probe', parts: [{ type: 'text', text: 'Reply with PONG.' }] },
|
|
199
|
+
}),
|
|
200
|
+
timeoutMs,
|
|
201
|
+
)
|
|
202
|
+
if (raw === TIMEOUT_SENTINEL) {
|
|
203
|
+
// Timeout during promptAsync → UNAVAILABLE
|
|
204
|
+
log.warn('[Native Probe] promptAsync timed out after', timeoutMs, 'ms')
|
|
205
|
+
const result: NativeProbeResult = { status: 'UNAVAILABLE' }
|
|
206
|
+
cachedResult = result
|
|
207
|
+
return result
|
|
208
|
+
}
|
|
209
|
+
promptResult = raw
|
|
210
|
+
} catch (err: unknown) {
|
|
211
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
212
|
+
log.error('[Native Probe] promptAsync failed:', msg)
|
|
213
|
+
if (isUnsupportedError(msg)) {
|
|
214
|
+
const result: NativeProbeResult = { status: 'UNSUPPORTED' }
|
|
215
|
+
cachedResult = result
|
|
216
|
+
return result
|
|
217
|
+
}
|
|
218
|
+
// promptAsync error → UNAVAILABLE
|
|
219
|
+
const result: NativeProbeResult = { status: 'UNAVAILABLE' }
|
|
220
|
+
cachedResult = result
|
|
221
|
+
return result
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Phase 3: classify output ───────────────────────────────────
|
|
225
|
+
const output = extractOutput(promptResult)
|
|
226
|
+
const hasContent = output.trim() !== ''
|
|
227
|
+
|
|
228
|
+
const result: NativeProbeResult = {
|
|
229
|
+
status: hasContent ? 'AVAILABLE' : 'UNAVAILABLE',
|
|
230
|
+
}
|
|
231
|
+
cachedResult = result
|
|
232
|
+
return result
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Task Status — classifies BackgroundJobRecord results from native
|
|
3
|
+
* task() delegation into actionable status codes.
|
|
4
|
+
*
|
|
5
|
+
* Pure module — zero external dependencies. Consumed by native-probe.ts
|
|
6
|
+
* and the delegation manager to decide retry/skip/escalate strategy.
|
|
7
|
+
*
|
|
8
|
+
* @module native-task-status
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { BackgroundJobRecord } from './background-job-board.ts'
|
|
12
|
+
|
|
13
|
+
// ─── Types ─────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** Actionable status for a native task() delegation result. */
|
|
16
|
+
export type NativeTaskStatus =
|
|
17
|
+
| 'OK'
|
|
18
|
+
| 'UNSUPPORTED'
|
|
19
|
+
| 'UNAVAILABLE'
|
|
20
|
+
| 'INVALID_INPUT'
|
|
21
|
+
| 'INVALID_STATE'
|
|
22
|
+
| 'CONFLICT'
|
|
23
|
+
| 'CORRUPT_DATA'
|
|
24
|
+
| 'TIMEOUT'
|
|
25
|
+
| 'ESCALATE'
|
|
26
|
+
|
|
27
|
+
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** Check if a string contains any of the given substrings (case-insensitive). */
|
|
30
|
+
function containsAny(text: string | undefined, patterns: readonly string[]): boolean {
|
|
31
|
+
if (text == null) return false
|
|
32
|
+
const lower = text.toLowerCase()
|
|
33
|
+
return patterns.some((p) => lower.includes(p.toLowerCase()))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Check if a job's output is empty or whitespace-only. */
|
|
37
|
+
function isOutputEmpty(job: BackgroundJobRecord): boolean {
|
|
38
|
+
const summary = job.resultSummary
|
|
39
|
+
if (summary == null) return true
|
|
40
|
+
if (typeof summary !== 'string') return true
|
|
41
|
+
return summary.trim() === ''
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Implementation ────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Classify a BackgroundJobRecord result into a NativeTaskStatus.
|
|
48
|
+
*
|
|
49
|
+
* Priority order (first match wins):
|
|
50
|
+
* 1. verifyError and lastStatusError patterns → UNSUPPORTED, CONFLICT,
|
|
51
|
+
* INVALID_INPUT, INVALID_STATE, CORRUPT_DATA, ESCALATE
|
|
52
|
+
* 2. state === startup_failed or timedOut → TIMEOUT
|
|
53
|
+
* 3. state === completed + non-empty output → OK
|
|
54
|
+
* 4. state === completed + empty output → UNAVAILABLE (silent failure / admission rejection)
|
|
55
|
+
* 5. state === error → UNAVAILABLE (safe default for provider/infra errors)
|
|
56
|
+
* 6. Fallback → UNAVAILABLE
|
|
57
|
+
*/
|
|
58
|
+
export function classifyNativeResult(
|
|
59
|
+
job: BackgroundJobRecord,
|
|
60
|
+
verifyError?: string,
|
|
61
|
+
): NativeTaskStatus {
|
|
62
|
+
const statusError = [verifyError, job.lastStatusError]
|
|
63
|
+
.filter((value): value is string => value != null && value.length > 0)
|
|
64
|
+
.join(' ')
|
|
65
|
+
|
|
66
|
+
// A missing native task API is terminal and must not be retried or sent
|
|
67
|
+
// through a fallback model. Provider/transport failures remain unavailable.
|
|
68
|
+
if (
|
|
69
|
+
containsAny(statusError, [
|
|
70
|
+
'api unavailable',
|
|
71
|
+
'api not available',
|
|
72
|
+
'api missing',
|
|
73
|
+
'method not found',
|
|
74
|
+
'unknown method',
|
|
75
|
+
'not supported',
|
|
76
|
+
'unsupported',
|
|
77
|
+
'does not support',
|
|
78
|
+
'task() is not available',
|
|
79
|
+
])
|
|
80
|
+
) {
|
|
81
|
+
return 'UNSUPPORTED'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Phase 1: verifyError explicit signals (highest priority) ───────
|
|
85
|
+
|
|
86
|
+
if (verifyError != null && verifyError.length > 0) {
|
|
87
|
+
// CONFLICT: canDispatch=false or concurrency exceeded
|
|
88
|
+
if (containsAny(verifyError, ['canDispatch', 'concurrency', 'conflict'])) {
|
|
89
|
+
return 'CONFLICT'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// INVALID_INPUT: agent not recognized / invalid
|
|
93
|
+
if (containsAny(verifyError, ['invalid agent', 'agent not found', 'unknown agent'])) {
|
|
94
|
+
return 'INVALID_INPUT'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// INVALID_STATE: child already in terminal state
|
|
98
|
+
if (containsAny(verifyError, ['already terminal', 'already in terminal', 'invalid state'])) {
|
|
99
|
+
return 'INVALID_STATE'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// CORRUPT_DATA: output can't be parsed / corrupted
|
|
103
|
+
if (containsAny(verifyError, ['corrupt', 'parse failed', 'malformed', 'invalid format'])) {
|
|
104
|
+
return 'CORRUPT_DATA'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ESCALATE: unrecoverable failure
|
|
108
|
+
if (containsAny(verifyError, ['unrecoverable', 'fatal', 'max retries exceeded'])) {
|
|
109
|
+
return 'ESCALATE'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Phase 2: state-based classification ────────────────────────────
|
|
114
|
+
|
|
115
|
+
// TIMEOUT: startup failure or timed out
|
|
116
|
+
if (job.state === 'startup_failed' || job.state === 'startup_unknown' || job.timedOut) {
|
|
117
|
+
return 'TIMEOUT'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Phase 3: output-based classification ───────────────────────────
|
|
121
|
+
|
|
122
|
+
if (job.state === 'completed') {
|
|
123
|
+
if (!isOutputEmpty(job)) {
|
|
124
|
+
return 'OK'
|
|
125
|
+
}
|
|
126
|
+
// completed but empty → silent failure (BackendAdmissionRejected pattern)
|
|
127
|
+
return 'UNAVAILABLE'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Phase 4: error state fallback ──────────────────────────────────
|
|
131
|
+
|
|
132
|
+
if (job.state === 'error') {
|
|
133
|
+
return 'UNAVAILABLE'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Fallback ───────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
return 'UNAVAILABLE'
|
|
139
|
+
}
|