opencode-agent-skill 14.2.0-beta.1 → 15.0.0
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 +51 -18
- package/docs/PI-COMPAT.md +167 -3
- package/docs/V15-MANAGED-RUNTIME.md +128 -0
- package/global-config/agents/architect.md +63 -1
- package/global-config/agents/executor.md +7 -0
- package/global-config/agents/plan-checker.md +8 -1
- package/global-config/agents/verifier.md +3 -3
- package/global-config/skills/database-engineering/references/workflow.md +15 -0
- package/global-config/skills/engineering-orchestrator/references/long-horizon.md +21 -11
- package/lib/activity-deadline.mjs +70 -0
- package/lib/adaptive-context-budget.mjs +25 -5
- package/lib/affected-tests.mjs +1 -1
- package/lib/capability-fabric.mjs +40 -0
- package/lib/code-intelligence/edit-anchor.mjs +102 -0
- package/lib/code-intelligence/index.mjs +95 -0
- package/lib/code-intelligence/lsp-provider.mjs +189 -0
- package/lib/completion-auditor.mjs +102 -0
- package/lib/document-ingestion.mjs +60 -0
- package/lib/evidence-store.mjs +92 -8
- package/lib/execution-contract.mjs +579 -0
- package/lib/fast-verification-gate.mjs +69 -0
- package/lib/leaf-runtime-optimizer.mjs +76 -0
- package/lib/mcp-health.mjs +144 -0
- package/lib/mcp-tool-policy.mjs +19 -0
- package/lib/memory-engine.mjs +28 -0
- package/lib/pi-child-invocation.mjs +65 -0
- package/lib/pi-rpc-pool.mjs +61 -9
- package/lib/plan-salvage.mjs +124 -0
- package/lib/planning-speed-policy.mjs +131 -0
- package/lib/prompt-cache.mjs +26 -12
- package/lib/repo-graph.mjs +1 -1
- package/lib/repo-inspect.mjs +1 -0
- package/lib/reversible-context.mjs +49 -0
- package/lib/runtime-artifacts.mjs +42 -0
- package/lib/runtime-events.mjs +33 -1
- package/lib/safety.mjs +1 -0
- package/lib/semantic-index.mjs +1 -1
- package/lib/service-manager.mjs +526 -0
- package/lib/session-display.mjs +49 -0
- package/lib/task-engine.mjs +6 -10
- package/lib/task-graph.mjs +87 -0
- package/lib/task-policy.mjs +210 -8
- package/lib/trajectory.mjs +111 -1
- package/lib/turbo-fast-path.mjs +44 -0
- package/lib/workspace-fingerprint.mjs +2 -11
- package/lib/workspace-root.mjs +49 -0
- package/lib/worktree-sandbox.mjs +234 -11
- package/package.json +9 -6
- package/pi/extensions/ues-child-runtime.ts +300 -2
- package/pi/extensions/ues.ts +1887 -160
- package/scripts/check-release-consistency.mjs +25 -2
- package/scripts/check-source-integrity.mjs +532 -3
- package/scripts/eval-pi.mjs +76 -36
- package/scripts/smoke-pi-extension.mjs +1 -1
- package/pi/prompts/ues-run.md +0 -17
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { classifyEngineeringTask } from "./task-policy.mjs"
|
|
2
|
+
|
|
3
|
+
function strings(value) {
|
|
4
|
+
if (!value) return []
|
|
5
|
+
return (Array.isArray(value) ? value : [value]).map(String).map((item) => item.trim()).filter(Boolean)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function declaredWriteFiles(task = {}) {
|
|
9
|
+
const files = task?.files || {}
|
|
10
|
+
return [...new Set([
|
|
11
|
+
...strings(files.create),
|
|
12
|
+
...strings(files.modify),
|
|
13
|
+
...strings(files.test),
|
|
14
|
+
...strings(files.delete),
|
|
15
|
+
].map((item) => item.replaceAll("\\", "/")))]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function leafTaskPolicy(task = {}, rootPolicy = {}) {
|
|
19
|
+
const writeFiles = declaredWriteFiles(task)
|
|
20
|
+
const readFiles = strings(task?.files?.read)
|
|
21
|
+
const text = [
|
|
22
|
+
task?.title,
|
|
23
|
+
task?.summary,
|
|
24
|
+
...strings(task?.acceptance),
|
|
25
|
+
...strings(task?.verification),
|
|
26
|
+
task?.riskNotes,
|
|
27
|
+
writeFiles.length ? "Declared write files: " + writeFiles.join(", ") : "",
|
|
28
|
+
readFiles.length ? "Declared read files: " + readFiles.join(", ") : "",
|
|
29
|
+
].filter(Boolean).join("\n")
|
|
30
|
+
|
|
31
|
+
const policy = classifyEngineeringTask(text, {
|
|
32
|
+
declaredFiles: writeFiles,
|
|
33
|
+
changedFiles: writeFiles.length,
|
|
34
|
+
risk: task?.risk,
|
|
35
|
+
longHorizon: false,
|
|
36
|
+
failureEvidence: false,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// The root may be DEEP because the overall project is large; do not force an
|
|
40
|
+
// independent low-risk leaf to pay that cost. Root safety is preserved by the
|
|
41
|
+
// final integration/completion gates and by leaf-declared risk.
|
|
42
|
+
return {
|
|
43
|
+
...policy,
|
|
44
|
+
requireFreshEvidence: true,
|
|
45
|
+
rootExecutionProfile: rootPolicy?.executionProfile || null,
|
|
46
|
+
rootRisk: rootPolicy?.risk || null,
|
|
47
|
+
localEnvWriteExplicitlyAllowed: rootPolicy?.localEnvWriteExplicitlyAllowed === true,
|
|
48
|
+
leafOptimized: true,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const FAILURE_SIGNAL = /(?:\bFAIL(?:ED|URE)?\b|\bERROR\b|AssertionError|TypeError|ReferenceError|SQLSTATE|ECONN\w*|EADDRINUSE|expected|actual|exit(?:Code| code)?\s*[:=]?\s*[1-9]|timed?\s*out|timeout|not found|cannot|unable|mismatch|regression|✖|×)/i
|
|
53
|
+
const FILE_SIGNAL = /(?:^|\s)(?:[A-Za-z]:)?[^\s:]+\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|cs|sql|json|yaml|yml)(?::\d+(?::\d+)?)?/i
|
|
54
|
+
|
|
55
|
+
export function failureDelta(output, options = {}) {
|
|
56
|
+
const maxChars = Math.max(1200, Math.min(8000, Number(options.maxChars || 4200)))
|
|
57
|
+
const raw = String(output || "").trim()
|
|
58
|
+
if (!raw) return ""
|
|
59
|
+
|
|
60
|
+
const lines = raw.split(/\r?\n/)
|
|
61
|
+
const selected = []
|
|
62
|
+
for (let i = 0; i < lines.length; i++) {
|
|
63
|
+
const line = lines[i]
|
|
64
|
+
if (!FAILURE_SIGNAL.test(line) && !FILE_SIGNAL.test(line)) continue
|
|
65
|
+
if (i > 0 && lines[i - 1].trim()) selected.push(lines[i - 1])
|
|
66
|
+
selected.push(line)
|
|
67
|
+
if (i + 1 < lines.length && lines[i + 1].trim()) selected.push(lines[i + 1])
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const compact = [...new Set(selected.map((line) => line.trim()).filter(Boolean))].join("\n")
|
|
71
|
+
if (compact) return compact.slice(0, maxChars)
|
|
72
|
+
|
|
73
|
+
// Fail closed on unknown output shape: keep a bounded tail, which usually
|
|
74
|
+
// contains the final verdict/error without replaying the entire previous turn.
|
|
75
|
+
return raw.slice(Math.max(0, raw.length - maxChars))
|
|
76
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const TRANSIENT_ERROR =
|
|
2
|
+
/(timeout|timed out|econnreset|econnrefused|socket|connection (?:closed|reset|lost)|transport|network|temporar(?:y|ily)|service unavailable|bad gateway|gateway timeout|\b502\b|\b503\b|\b504\b|rate limit|\b429\b)/i
|
|
3
|
+
|
|
4
|
+
function nowMs(value) {
|
|
5
|
+
const number = Number(value)
|
|
6
|
+
return Number.isFinite(number) ? number : Date.now()
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isTransientMcpFailure(value = "") {
|
|
10
|
+
return TRANSIENT_ERROR.test(String(value || ""))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function mcpReconnectAdvice(input = {}) {
|
|
14
|
+
const policy = input.policy || {}
|
|
15
|
+
const attempt = Math.max(1, Math.trunc(Number(input.attempt || 1)))
|
|
16
|
+
const transient = isTransientMcpFailure(input.error || input.message || "")
|
|
17
|
+
const destructive = policy.confirmationRequired === true || policy.annotations?.destructiveHint === true
|
|
18
|
+
const retryable = policy.retryAllowed === true && transient && !destructive && attempt < Math.max(1, Number(input.maxAttempts || 3))
|
|
19
|
+
return {
|
|
20
|
+
schemaVersion: 1,
|
|
21
|
+
retryable,
|
|
22
|
+
reconnectRecommended: retryable,
|
|
23
|
+
reason: destructive
|
|
24
|
+
? "destructive-tool-never-auto-reconnect"
|
|
25
|
+
: !transient
|
|
26
|
+
? "non-transient-failure"
|
|
27
|
+
: policy.retryAllowed !== true
|
|
28
|
+
? "tool-not-explicitly-idempotent"
|
|
29
|
+
: retryable
|
|
30
|
+
? "transient-idempotent-failure"
|
|
31
|
+
: "retry-budget-exhausted",
|
|
32
|
+
backoffMs: retryable ? Math.min(5_000, 250 * (2 ** Math.max(0, attempt - 1))) : 0,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class McpHealthTracker {
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
this.failureThreshold = Math.max(1, Math.min(10, Number(options.failureThreshold || 2)))
|
|
39
|
+
this.cooldownMs = Math.max(1_000, Math.min(5 * 60_000, Number(options.cooldownMs || 15_000)))
|
|
40
|
+
this.maxEntries = Math.max(16, Math.min(2_000, Number(options.maxEntries || 256)))
|
|
41
|
+
this.state = new Map()
|
|
42
|
+
this.active = new Map()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
clear() {
|
|
46
|
+
this.state.clear()
|
|
47
|
+
this.active.clear()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
begin(callId, toolName, policy = {}, at = Date.now()) {
|
|
51
|
+
const id = String(callId || "")
|
|
52
|
+
const name = String(toolName || "")
|
|
53
|
+
if (id && name) this.active.set(id, { name, policy, startedAt: nowMs(at) })
|
|
54
|
+
this.#trim()
|
|
55
|
+
return this.status(name, at)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
finish(callId, result = {}, at = Date.now()) {
|
|
59
|
+
const id = String(callId || "")
|
|
60
|
+
const active = this.active.get(id)
|
|
61
|
+
if (!active) return null
|
|
62
|
+
this.active.delete(id)
|
|
63
|
+
const timestamp = nowMs(at)
|
|
64
|
+
const previous = this.state.get(active.name) || {
|
|
65
|
+
successes: 0,
|
|
66
|
+
failures: 0,
|
|
67
|
+
consecutiveFailures: 0,
|
|
68
|
+
cooldownUntil: 0,
|
|
69
|
+
lastError: null,
|
|
70
|
+
lastFinishedAt: 0,
|
|
71
|
+
}
|
|
72
|
+
const errorText = [
|
|
73
|
+
result.error,
|
|
74
|
+
result.message,
|
|
75
|
+
result.stderr,
|
|
76
|
+
result.text,
|
|
77
|
+
].filter(Boolean).join("\n").slice(0, 4000)
|
|
78
|
+
const failed = result.isError === true || Number(result.exitCode) > 0
|
|
79
|
+
const transient = failed && isTransientMcpFailure(errorText)
|
|
80
|
+
const consecutiveFailures = failed ? Number(previous.consecutiveFailures || 0) + 1 : 0
|
|
81
|
+
const cooldownUntil = transient && consecutiveFailures >= this.failureThreshold
|
|
82
|
+
? timestamp + this.cooldownMs
|
|
83
|
+
: failed
|
|
84
|
+
? Number(previous.cooldownUntil || 0)
|
|
85
|
+
: 0
|
|
86
|
+
const reconnectAdvice = failed
|
|
87
|
+
? mcpReconnectAdvice({ policy: active.policy, error: errorText, attempt: consecutiveFailures })
|
|
88
|
+
: null
|
|
89
|
+
const next = {
|
|
90
|
+
successes: Number(previous.successes || 0) + (failed ? 0 : 1),
|
|
91
|
+
failures: Number(previous.failures || 0) + (failed ? 1 : 0),
|
|
92
|
+
consecutiveFailures,
|
|
93
|
+
cooldownUntil,
|
|
94
|
+
lastError: failed ? errorText || "tool-result-error" : null,
|
|
95
|
+
lastTransientFailure: transient,
|
|
96
|
+
reconnectAdvice,
|
|
97
|
+
lastFinishedAt: timestamp,
|
|
98
|
+
policy: active.policy,
|
|
99
|
+
}
|
|
100
|
+
this.state.set(active.name, next)
|
|
101
|
+
this.#trim()
|
|
102
|
+
return this.status(active.name, timestamp)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
status(toolName, at = Date.now()) {
|
|
106
|
+
const name = String(toolName || "")
|
|
107
|
+
const row = this.state.get(name)
|
|
108
|
+
const timestamp = nowMs(at)
|
|
109
|
+
if (!row) {
|
|
110
|
+
return { schemaVersion: 1, tool: name, status: "unknown", available: true, consecutiveFailures: 0, cooldownUntil: null }
|
|
111
|
+
}
|
|
112
|
+
const coolingDown = Number(row.cooldownUntil || 0) > timestamp
|
|
113
|
+
return {
|
|
114
|
+
schemaVersion: 1,
|
|
115
|
+
tool: name,
|
|
116
|
+
status: coolingDown ? "degraded" : row.consecutiveFailures > 0 ? "recovering" : "healthy",
|
|
117
|
+
available: !coolingDown,
|
|
118
|
+
successes: row.successes,
|
|
119
|
+
failures: row.failures,
|
|
120
|
+
consecutiveFailures: row.consecutiveFailures,
|
|
121
|
+
cooldownUntil: coolingDown ? new Date(row.cooldownUntil).toISOString() : null,
|
|
122
|
+
lastError: row.lastError,
|
|
123
|
+
lastTransientFailure: row.lastTransientFailure === true,
|
|
124
|
+
reconnectAdvice: row.reconnectAdvice || null,
|
|
125
|
+
lastFinishedAt: row.lastFinishedAt ? new Date(row.lastFinishedAt).toISOString() : null,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
available(toolName, at = Date.now()) {
|
|
130
|
+
return this.status(toolName, at).available !== false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
snapshot(at = Date.now()) {
|
|
134
|
+
return [...this.state.keys()]
|
|
135
|
+
.sort()
|
|
136
|
+
.map((name) => this.status(name, at))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#trim() {
|
|
140
|
+
if (this.state.size <= this.maxEntries) return
|
|
141
|
+
const ordered = [...this.state.entries()].sort((a, b) => Number(a[1]?.lastFinishedAt || 0) - Number(b[1]?.lastFinishedAt || 0))
|
|
142
|
+
for (const [name] of ordered.slice(0, Math.max(0, this.state.size - this.maxEntries))) this.state.delete(name)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
function firstObject(...values) { return values.find((value) => value && typeof value === "object" && !Array.isArray(value)) || {} }
|
|
2
|
+
function explicitBoolean(value) { return typeof value === "boolean" ? value : null }
|
|
3
|
+
export function normalizeMcpAnnotations(tool = {}) {
|
|
4
|
+
const raw = firstObject(tool.annotations, tool.metadata?.annotations, tool._meta?.annotations, tool._meta?.mcp?.annotations)
|
|
5
|
+
const annotations = { readOnlyHint: explicitBoolean(raw.readOnlyHint), destructiveHint: explicitBoolean(raw.destructiveHint), idempotentHint: explicitBoolean(raw.idempotentHint), openWorldHint: explicitBoolean(raw.openWorldHint) }
|
|
6
|
+
return { schemaVersion: 1, explicit: Object.values(annotations).some((value) => value !== null), ...annotations }
|
|
7
|
+
}
|
|
8
|
+
export function mcpExecutionPolicy(tool = {}) {
|
|
9
|
+
const annotations = normalizeMcpAnnotations(tool)
|
|
10
|
+
const destructive = annotations.destructiveHint === true
|
|
11
|
+
const readOnly = annotations.readOnlyHint === true && !destructive
|
|
12
|
+
const idempotent = annotations.idempotentHint === true && !destructive
|
|
13
|
+
return { schemaVersion: 1, tool: String(tool?.name || ""), annotations, fastAllowed: readOnly, confirmationRequired: destructive, retryAllowed: idempotent, externalEvidenceBoundary: annotations.openWorldHint === true, trust: "hint-only" }
|
|
14
|
+
}
|
|
15
|
+
export function mcpPolicyMap(tools = []) {
|
|
16
|
+
const map = new Map()
|
|
17
|
+
for (const tool of Array.isArray(tools) ? tools : []) { const name = String(tool?.name || "").trim(); if (name) map.set(name, mcpExecutionPolicy(tool)) }
|
|
18
|
+
return map
|
|
19
|
+
}
|
package/lib/memory-engine.mjs
CHANGED
|
@@ -303,6 +303,33 @@ function rankMap(rows, key) {
|
|
|
303
303
|
)
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
export function buildMemorySnapshot(memories = []) {
|
|
307
|
+
const entries = (Array.isArray(memories) ? memories : [])
|
|
308
|
+
.map((item) => ({
|
|
309
|
+
id: item.id || null,
|
|
310
|
+
type: item.type || null,
|
|
311
|
+
scope: item.scope || null,
|
|
312
|
+
content: normalizedContent(item.content),
|
|
313
|
+
confidence: clamp(item.confidence),
|
|
314
|
+
files: uniq((item.files || []).map(normalizeFile)).sort(),
|
|
315
|
+
taskClass: item.taskClass || null,
|
|
316
|
+
verifiedAt: item.verifiedAt || null,
|
|
317
|
+
evidenceRefs: uniq(item.evidenceRefs || []).sort(),
|
|
318
|
+
}))
|
|
319
|
+
.filter((item) => item.id && item.content)
|
|
320
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)))
|
|
321
|
+
const generation = createHash("sha256")
|
|
322
|
+
.update(JSON.stringify(entries))
|
|
323
|
+
.digest("hex")
|
|
324
|
+
.slice(0, 20)
|
|
325
|
+
return {
|
|
326
|
+
schemaVersion: 1,
|
|
327
|
+
generation,
|
|
328
|
+
entryCount: entries.length,
|
|
329
|
+
entries,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
306
333
|
export async function retrieveMemories(root, query, options = {}) {
|
|
307
334
|
const state = await readMemoryState(root)
|
|
308
335
|
const memories = state.memories.filter((item) => item.status === "verified" && !item.supersededBy && !isExpired(item))
|
|
@@ -365,6 +392,7 @@ export async function retrieveMemories(root, query, options = {}) {
|
|
|
365
392
|
query: String(query || ""),
|
|
366
393
|
eligible: memories.length,
|
|
367
394
|
results,
|
|
395
|
+
snapshot: buildMemorySnapshot(results),
|
|
368
396
|
}
|
|
369
397
|
}
|
|
370
398
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { resolveManagedPiCommand, resolveWindowsCommand } from "./windows-shim.mjs"
|
|
4
|
+
|
|
5
|
+
export function looksLikePiCliEntrypoint(file) {
|
|
6
|
+
const value = String(file || "").replaceAll("\\", "/").toLowerCase()
|
|
7
|
+
if (!value) return false
|
|
8
|
+
|
|
9
|
+
// Managed/npm Pi installs execute the package CLI script. ACP adapters may
|
|
10
|
+
// also be Node scripts, so do not trust an arbitrary existing argv[1].
|
|
11
|
+
if (value.includes("/@earendil-works/pi-coding-agent/")) return true
|
|
12
|
+
|
|
13
|
+
const base = path.posix.basename(value)
|
|
14
|
+
return /^(?:pi)(?:\.[cm]?js)?$/.test(base)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fromResolvedPi(resolved, args, source) {
|
|
18
|
+
if (!resolved?.executable) return null
|
|
19
|
+
return {
|
|
20
|
+
command: resolved.executable,
|
|
21
|
+
args: [...(resolved.argsPrefix || []), ...args],
|
|
22
|
+
source,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolvePiChildInvocation(args = [], options = {}) {
|
|
27
|
+
const currentScript = options.currentScript ?? process.argv[1]
|
|
28
|
+
const execPath = options.execPath ?? process.execPath
|
|
29
|
+
const platform = options.platform ?? process.platform
|
|
30
|
+
const exists = options.existsSync ?? existsSync
|
|
31
|
+
|
|
32
|
+
if (
|
|
33
|
+
currentScript &&
|
|
34
|
+
!String(currentScript).startsWith("/$bunfs/root/") &&
|
|
35
|
+
exists(currentScript) &&
|
|
36
|
+
looksLikePiCliEntrypoint(currentScript)
|
|
37
|
+
) {
|
|
38
|
+
return {
|
|
39
|
+
command: execPath,
|
|
40
|
+
args: [currentScript, ...args],
|
|
41
|
+
source: "host-pi-cli",
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const execBase = path.basename(String(execPath || "")).toLowerCase()
|
|
46
|
+
if (/^pi(?:\.exe)?$/.test(execBase)) {
|
|
47
|
+
return { command: execPath, args, source: "host-pi-executable" }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (platform === "win32") {
|
|
51
|
+
const managed = options.managedPi === undefined
|
|
52
|
+
? resolveManagedPiCommand()
|
|
53
|
+
: options.managedPi
|
|
54
|
+
const managedInvocation = fromResolvedPi(managed, args, "managed-pi")
|
|
55
|
+
if (managedInvocation) return managedInvocation
|
|
56
|
+
|
|
57
|
+
const pathPi = options.pathPi === undefined
|
|
58
|
+
? resolveWindowsCommand("pi")
|
|
59
|
+
: options.pathPi
|
|
60
|
+
const pathInvocation = fromResolvedPi(pathPi, args, "path-pi")
|
|
61
|
+
if (pathInvocation) return pathInvocation
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { command: "pi", args, source: "path-pi-fallback" }
|
|
65
|
+
}
|
package/lib/pi-rpc-pool.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process"
|
|
2
2
|
import { terminateProcessTree } from "./process-supervisor.mjs"
|
|
3
|
+
import { createAdaptiveDeadline } from "./activity-deadline.mjs"
|
|
3
4
|
|
|
4
5
|
function clamp(value, fallback, min, max) {
|
|
5
6
|
const parsed = Number(value)
|
|
@@ -226,11 +227,37 @@ class RpcWorker {
|
|
|
226
227
|
this.stderr = ""
|
|
227
228
|
|
|
228
229
|
const hardTimeoutMs = clamp(options.hardTimeoutMs, 30 * 60_000, 30_000, 2 * 60 * 60_000)
|
|
230
|
+
const absoluteHardTimeoutMs = clamp(
|
|
231
|
+
options.absoluteHardTimeoutMs,
|
|
232
|
+
hardTimeoutMs,
|
|
233
|
+
hardTimeoutMs,
|
|
234
|
+
2 * 60 * 60_000,
|
|
235
|
+
)
|
|
236
|
+
const activityExtensionMs = clamp(
|
|
237
|
+
options.activityExtensionMs,
|
|
238
|
+
0,
|
|
239
|
+
0,
|
|
240
|
+
Math.max(hardTimeoutMs, absoluteHardTimeoutMs),
|
|
241
|
+
)
|
|
242
|
+
const activityWindowMs = clamp(
|
|
243
|
+
options.activityWindowMs,
|
|
244
|
+
Math.max(5_000, Math.min(activityExtensionMs || hardTimeoutMs, 30_000)),
|
|
245
|
+
1_000,
|
|
246
|
+
Math.max(hardTimeoutMs, absoluteHardTimeoutMs),
|
|
247
|
+
)
|
|
229
248
|
const idleTimeoutMs = clamp(options.idleTimeoutMs, 5 * 60_000, 10_000, 30 * 60_000)
|
|
230
|
-
|
|
249
|
+
const runStartedAt = Date.now()
|
|
250
|
+
let lastActivityAt = runStartedAt
|
|
251
|
+
const adaptiveDeadline = createAdaptiveDeadline({
|
|
252
|
+
hardTimeoutMs,
|
|
253
|
+
absoluteHardTimeoutMs,
|
|
254
|
+
activityExtensionMs,
|
|
255
|
+
activityWindowMs,
|
|
256
|
+
}, runStartedAt)
|
|
231
257
|
this.lastActivityAt = lastActivityAt
|
|
232
258
|
this.active = true
|
|
233
259
|
let finalMessage = null
|
|
260
|
+
let partialAssistantMessage = null
|
|
234
261
|
let toolCalls = 0
|
|
235
262
|
let lastToolErrorAt = 0
|
|
236
263
|
const toolNames = new Set()
|
|
@@ -240,19 +267,37 @@ class RpcWorker {
|
|
|
240
267
|
settledResolve = resolve
|
|
241
268
|
settledReject = reject
|
|
242
269
|
})
|
|
270
|
+
// The active run can be aborted while the prompt transport is still
|
|
271
|
+
// completing, before execution reaches "await settled". Attach a handler
|
|
272
|
+
// immediately so Node never reports a transient unhandled rejection;
|
|
273
|
+
// awaiting the original promise below still preserves the rejection.
|
|
274
|
+
settled.catch(() => {})
|
|
243
275
|
|
|
244
276
|
let aborting = false
|
|
245
277
|
const abortFor = async (reason) => {
|
|
246
278
|
if (aborting) return
|
|
247
279
|
aborting = true
|
|
280
|
+
const error = new Error("UES RPC " + reason)
|
|
281
|
+
error.partialMessage = partialAssistantMessage || finalMessage
|
|
282
|
+
error.stderr = this.stderr
|
|
283
|
+
error.toolCalls = toolCalls
|
|
284
|
+
error.toolNames = [...toolNames]
|
|
285
|
+
error.deadlineExtensions = adaptiveDeadline.extensions
|
|
286
|
+
error.deadlineAt = adaptiveDeadline.deadlineAt
|
|
287
|
+
error.absoluteDeadlineAt = adaptiveDeadline.absoluteAt
|
|
288
|
+
// Settle with the rich timeout error before transport teardown can emit a
|
|
289
|
+
// generic worker-exit error and win the race.
|
|
290
|
+
settledReject(error)
|
|
248
291
|
try { await this.abortTransport() } catch {}
|
|
249
|
-
settledReject(new Error("UES RPC " + reason))
|
|
250
292
|
}
|
|
251
293
|
this.activeAbort = abortFor
|
|
252
294
|
|
|
253
295
|
const unsubscribe = this.onEvent((event) => {
|
|
254
296
|
lastActivityAt = Date.now()
|
|
255
297
|
this.lastActivityAt = lastActivityAt
|
|
298
|
+
if (event?.message?.role === "assistant") {
|
|
299
|
+
partialAssistantMessage = event.message
|
|
300
|
+
}
|
|
256
301
|
if (event.type === "tool_execution_start") {
|
|
257
302
|
toolCalls += 1
|
|
258
303
|
lastToolErrorAt = 0
|
|
@@ -272,22 +317,27 @@ class RpcWorker {
|
|
|
272
317
|
if (event.type === "ues_rpc_worker_error") settledReject(new Error(event.error || "RPC worker failed"))
|
|
273
318
|
})
|
|
274
319
|
|
|
275
|
-
const hardTimer = setTimeout(() => void abortFor("hard-timeout"), hardTimeoutMs)
|
|
276
|
-
hardTimer.unref?.()
|
|
277
320
|
const postToolErrorIdleTimeoutMs = clamp(
|
|
278
321
|
options.postToolErrorIdleTimeoutMs,
|
|
279
322
|
60_000,
|
|
280
323
|
5_000,
|
|
281
324
|
idleTimeoutMs,
|
|
282
325
|
)
|
|
283
|
-
const
|
|
326
|
+
const watchdogTimer = setInterval(() => {
|
|
284
327
|
const now = Date.now()
|
|
285
|
-
const threshold = lastToolErrorAt > 0
|
|
328
|
+
const threshold = lastToolErrorAt > 0
|
|
329
|
+
? Math.min(idleTimeoutMs, postToolErrorIdleTimeoutMs)
|
|
330
|
+
: idleTimeoutMs
|
|
286
331
|
if (now - lastActivityAt >= threshold) {
|
|
287
332
|
void abortFor(lastToolErrorAt > 0 ? "post-tool-error-stall" : "idle-timeout")
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
const deadline = adaptiveDeadline.shouldAbort(now, lastActivityAt)
|
|
336
|
+
if (deadline.abort) {
|
|
337
|
+
void abortFor(deadline.reason || "hard-timeout")
|
|
288
338
|
}
|
|
289
339
|
}, 1000)
|
|
290
|
-
|
|
340
|
+
watchdogTimer.unref?.()
|
|
291
341
|
|
|
292
342
|
const signal = options.signal
|
|
293
343
|
const onAbort = () => void abortFor("aborted")
|
|
@@ -304,6 +354,9 @@ class RpcWorker {
|
|
|
304
354
|
stderr: this.stderr,
|
|
305
355
|
toolCalls,
|
|
306
356
|
toolNames: [...toolNames],
|
|
357
|
+
deadlineExtensions: adaptiveDeadline.extensions,
|
|
358
|
+
deadlineAt: adaptiveDeadline.deadlineAt,
|
|
359
|
+
absoluteDeadlineAt: adaptiveDeadline.absoluteAt,
|
|
307
360
|
}
|
|
308
361
|
} catch (error) {
|
|
309
362
|
if (error && typeof error === "object") error.uesRpcPhase = "runtime"
|
|
@@ -311,8 +364,7 @@ class RpcWorker {
|
|
|
311
364
|
} finally {
|
|
312
365
|
this.active = false
|
|
313
366
|
this.activeAbort = null
|
|
314
|
-
|
|
315
|
-
clearInterval(idleTimer)
|
|
367
|
+
clearInterval(watchdogTimer)
|
|
316
368
|
unsubscribe()
|
|
317
369
|
if (signal) signal.removeEventListener("abort", onAbort)
|
|
318
370
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizePlanForValidation,
|
|
3
|
+
validatePlan,
|
|
4
|
+
} from "./task-graph.mjs"
|
|
5
|
+
|
|
6
|
+
function balancedObject(source, start) {
|
|
7
|
+
let depth = 0
|
|
8
|
+
let inString = false
|
|
9
|
+
let escaped = false
|
|
10
|
+
for (let index = start; index < source.length; index++) {
|
|
11
|
+
const char = source[index]
|
|
12
|
+
if (inString) {
|
|
13
|
+
if (escaped) escaped = false
|
|
14
|
+
else if (char === "\\") escaped = true
|
|
15
|
+
else if (char === "\"") inString = false
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
if (char === "\"") {
|
|
19
|
+
inString = true
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
if (char === "{") depth += 1
|
|
23
|
+
else if (char === "}") {
|
|
24
|
+
depth -= 1
|
|
25
|
+
if (depth === 0) {
|
|
26
|
+
const raw = source.slice(start, index + 1)
|
|
27
|
+
try {
|
|
28
|
+
return { value: JSON.parse(raw), raw, end: index + 1 }
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function markedCandidate(source, marker) {
|
|
39
|
+
const markerIndex = source.lastIndexOf(marker)
|
|
40
|
+
if (markerIndex < 0) return null
|
|
41
|
+
const start = source.indexOf("{", markerIndex + marker.length)
|
|
42
|
+
if (start < 0) return null
|
|
43
|
+
const parsed = balancedObject(source, start)
|
|
44
|
+
return parsed ? { ...parsed, source: "marked" } : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function fencedCandidates(source) {
|
|
48
|
+
const results = []
|
|
49
|
+
const fence = String.fromCharCode(96, 96, 96)
|
|
50
|
+
let offset = 0
|
|
51
|
+
while (offset < source.length) {
|
|
52
|
+
const open = source.indexOf(fence, offset)
|
|
53
|
+
if (open < 0) break
|
|
54
|
+
const bodyStart = source.indexOf("\n", open + fence.length)
|
|
55
|
+
if (bodyStart < 0) break
|
|
56
|
+
const close = source.indexOf(fence, bodyStart + 1)
|
|
57
|
+
if (close < 0) break
|
|
58
|
+
const body = source.slice(bodyStart + 1, close).trim()
|
|
59
|
+
if (body.startsWith("{")) {
|
|
60
|
+
try { results.push({ value: JSON.parse(body), raw: body, source: "fenced" }) } catch {}
|
|
61
|
+
}
|
|
62
|
+
offset = close + fence.length
|
|
63
|
+
}
|
|
64
|
+
return results
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function balancedCandidates(source, limit = 24) {
|
|
68
|
+
const starts = []
|
|
69
|
+
for (let i = source.length - 1; i >= 0 && starts.length < limit; i--) {
|
|
70
|
+
if (source[i] === "{") starts.push(i)
|
|
71
|
+
}
|
|
72
|
+
const results = []
|
|
73
|
+
for (const start of starts) {
|
|
74
|
+
const parsed = balancedObject(source, start)
|
|
75
|
+
if (parsed) results.push({ ...parsed, source: "unmarked-balanced" })
|
|
76
|
+
}
|
|
77
|
+
return results
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function planLike(value) {
|
|
81
|
+
return Boolean(
|
|
82
|
+
value &&
|
|
83
|
+
typeof value === "object" &&
|
|
84
|
+
Array.isArray(value.tasks) &&
|
|
85
|
+
(typeof value.goal === "string" || value.schemaVersion === 1)
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function extractValidatedPlan(output, options = {}) {
|
|
90
|
+
const source = String(output || "")
|
|
91
|
+
const marker = String(options.marker || "UES_PLAN_JSON:")
|
|
92
|
+
const candidates = []
|
|
93
|
+
const marked = markedCandidate(source, marker)
|
|
94
|
+
if (marked) candidates.push(marked)
|
|
95
|
+
candidates.push(...fencedCandidates(source))
|
|
96
|
+
candidates.push(...balancedCandidates(source))
|
|
97
|
+
|
|
98
|
+
const seen = new Set()
|
|
99
|
+
let bestInvalid = null
|
|
100
|
+
for (const candidate of candidates) {
|
|
101
|
+
if (!planLike(candidate.value)) continue
|
|
102
|
+
let normalized
|
|
103
|
+
try { normalized = normalizePlanForValidation(candidate.value) } catch { continue }
|
|
104
|
+
const key = JSON.stringify(normalized)
|
|
105
|
+
if (seen.has(key)) continue
|
|
106
|
+
seen.add(key)
|
|
107
|
+
const validation = validatePlan(normalized)
|
|
108
|
+
const result = {
|
|
109
|
+
plan: normalized,
|
|
110
|
+
validation,
|
|
111
|
+
source: candidate.source,
|
|
112
|
+
salvaged: candidate.source !== "marked",
|
|
113
|
+
}
|
|
114
|
+
if (validation && validation.valid === true) return result
|
|
115
|
+
if (!bestInvalid) bestInvalid = result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return bestInvalid || {
|
|
119
|
+
plan: null,
|
|
120
|
+
validation: null,
|
|
121
|
+
source: null,
|
|
122
|
+
salvaged: false,
|
|
123
|
+
}
|
|
124
|
+
}
|