opencode-goal-plugin 0.6.1 → 0.6.3
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/CHANGELOG.md +27 -16
- package/CONTRIBUTING.md +10 -13
- package/README.md +13 -5
- package/SECURITY.md +7 -9
- package/docs/compatibility.md +41 -0
- package/docs/providers.md +22 -3
- package/docs/releasing.md +41 -0
- package/index.d.ts +54 -5
- package/package.json +12 -3
- package/scripts/verify.mjs +1 -0
- package/src/goal-plugin.js +61 -36
- package/scripts/behavior-benchmark.mjs +0 -272
- package/scripts/packed-host-contract.mjs +0 -160
- package/scripts/smoke-command-hook.mjs +0 -51
package/src/goal-plugin.js
CHANGED
|
@@ -88,6 +88,7 @@ function createRuntimeState() {
|
|
|
88
88
|
activeContinues: new Map(),
|
|
89
89
|
continuationControllers: new Map(),
|
|
90
90
|
seenIdleEventIDs: new Set(),
|
|
91
|
+
readOnlyCommandGuards: new Set(),
|
|
91
92
|
ledgerSink: null,
|
|
92
93
|
persistenceLease: null,
|
|
93
94
|
migrationLease: null,
|
|
@@ -143,6 +144,7 @@ const seenOutputTokens = runtimeCollection("seenOutputTokens")
|
|
|
143
144
|
const activeContinues = runtimeCollection("activeContinues")
|
|
144
145
|
const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"])
|
|
145
146
|
const PAUSE_COMMANDS = new Set(["pause"])
|
|
147
|
+
const READ_ONLY_COMMAND_TOOLS = new Set(["goal_status", "get_goal", "get_goal_history", "read", "glob", "grep"])
|
|
146
148
|
const GOAL_FLAG_SPECS = {
|
|
147
149
|
"--max-turns": {
|
|
148
150
|
optionKey: "maxTurns",
|
|
@@ -206,7 +208,7 @@ function messageHasToolCall(message) {
|
|
|
206
208
|
|
|
207
209
|
const GOAL_MODES = new Set(["normal", "ordered"])
|
|
208
210
|
|
|
209
|
-
// Goal
|
|
211
|
+
// Goal mode: normal vs ordered (a.k.a. sisyphus). `ordered`
|
|
210
212
|
// signals a strict execution sequence; `sisyphus` is accepted as an alias.
|
|
211
213
|
// Returns the canonical mode or null when unrecognized.
|
|
212
214
|
function normalizeMode(value) {
|
|
@@ -289,12 +291,12 @@ function makeHistoryEntry(type, detail, timestamp = Date.now()) {
|
|
|
289
291
|
}
|
|
290
292
|
}
|
|
291
293
|
|
|
292
|
-
// Append-only lifecycle ledger
|
|
294
|
+
// Append-only lifecycle ledger. pushHistory emits every lifecycle
|
|
293
295
|
// event to this sink, which a configured plugin instance points at a JSONL
|
|
294
296
|
// file. Because the in-memory history is truncated to MAX_HISTORY_ENTRIES, the
|
|
295
297
|
// ledger is the durable record used to reconstruct state if the main state file
|
|
296
298
|
// is lost or corrupted, and it captures terminal events even when the main
|
|
297
|
-
// state write fails (fail
|
|
299
|
+
// state write fails (fail closed).
|
|
298
300
|
function setLedgerSink(sink) {
|
|
299
301
|
currentRuntime().ledgerSink = typeof sink === "function" ? sink : null
|
|
300
302
|
}
|
|
@@ -705,6 +707,7 @@ function clearRuntimeState() {
|
|
|
705
707
|
activeContinues.clear()
|
|
706
708
|
runtime.continuationControllers.clear()
|
|
707
709
|
runtime.seenIdleEventIDs.clear()
|
|
710
|
+
runtime.readOnlyCommandGuards.clear()
|
|
708
711
|
}
|
|
709
712
|
|
|
710
713
|
function pruneGoalResults(options) {
|
|
@@ -1001,7 +1004,7 @@ async function assertSafeProjectPersistencePath({ stateFilePath, projectRoot, en
|
|
|
1001
1004
|
}
|
|
1002
1005
|
}
|
|
1003
1006
|
|
|
1004
|
-
// Command surface options
|
|
1007
|
+
// Command surface options: `commandName` lets the plugin own a
|
|
1005
1008
|
// different slash command (e.g. /objective) and `registerCommand: false` makes
|
|
1006
1009
|
// the plugin skip the command hook entirely (agent/programmatic use only). A
|
|
1007
1010
|
// leading slash in commandName is tolerated and stripped.
|
|
@@ -1418,7 +1421,7 @@ async function loadPersistedState(persistenceOptions, client) {
|
|
|
1418
1421
|
|
|
1419
1422
|
// Last-resort recovery: when the main state file is absent, rebuild still-active
|
|
1420
1423
|
// goals from the append-only ledger so a lost/rotated state file does not drop
|
|
1421
|
-
// in-flight goals
|
|
1424
|
+
// in-flight goals. Recovered goals are paused (via deserializeGoal).
|
|
1422
1425
|
async function reconstructFromLedger(persistenceOptions, client) {
|
|
1423
1426
|
const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
|
|
1424
1427
|
maxBytes: persistenceOptions.ledgerMaxBytes,
|
|
@@ -1753,11 +1756,16 @@ function buildContinueMessage(
|
|
|
1753
1756
|
)
|
|
1754
1757
|
} else {
|
|
1755
1758
|
lines.push(
|
|
1756
|
-
"Continue
|
|
1759
|
+
"Continue the next concrete step; inspect and repair failures.",
|
|
1757
1760
|
)
|
|
1758
1761
|
}
|
|
1759
1762
|
|
|
1760
|
-
lines.push(
|
|
1763
|
+
lines.push(
|
|
1764
|
+
"Completion format—consecutive plain lines; no Markdown/backticks/blank line:",
|
|
1765
|
+
"[goal:evidence] <proof>",
|
|
1766
|
+
"[goal:complete]",
|
|
1767
|
+
"Need user input? State why before [goal:blocked].",
|
|
1768
|
+
)
|
|
1761
1769
|
const limitWarning = buildLimitWarning(goal)
|
|
1762
1770
|
if (limitWarning) lines.push(limitWarning.trim())
|
|
1763
1771
|
|
|
@@ -1788,7 +1796,7 @@ function buildContinueMessage(
|
|
|
1788
1796
|
|
|
1789
1797
|
// Deterministic progress summary built from the plugin's persisted goal record
|
|
1790
1798
|
// (checkpoints + lifecycle history) rather than from chat memory, so it is
|
|
1791
|
-
// stable and reproducible across a compaction
|
|
1799
|
+
// stable and reproducible across a compaction.
|
|
1792
1800
|
function buildCompactionProgressSummary(goal, { maxCheckpoints = 3, maxEvents = 6 } = {}) {
|
|
1793
1801
|
const lines = []
|
|
1794
1802
|
const checkpoints = Array.isArray(goal.checkpoints) ? goal.checkpoints.slice(-maxCheckpoints) : []
|
|
@@ -2069,7 +2077,7 @@ function isPluginContinuationMessage(message) {
|
|
|
2069
2077
|
|
|
2070
2078
|
// "Latest instruction wins": detect a real (human) user message that arrived
|
|
2071
2079
|
// after the plugin's most recent continuation prompt. Plugin-generated
|
|
2072
|
-
// continuation/audit messages are ignored
|
|
2080
|
+
// continuation/audit messages are ignored. Detection requires the
|
|
2073
2081
|
// loop to be running (turnCount > 0) and a plugin continuation to be visible in
|
|
2074
2082
|
// the recent window, so the first idle after /goal set and sessions where the
|
|
2075
2083
|
// continuations have scrolled out of view are never misread as intervention.
|
|
@@ -2139,7 +2147,7 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
|
|
|
2139
2147
|
const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed"])
|
|
2140
2148
|
|
|
2141
2149
|
// Programmatic equivalents of the /goal command, exposed to the agent as tools
|
|
2142
|
-
//
|
|
2150
|
+
// Each handler operates on a session id and mutates
|
|
2143
2151
|
// the same in-memory state the command path uses, persisting through the
|
|
2144
2152
|
// provided `persist` callback, and returns a human-readable string for the tool
|
|
2145
2153
|
// result. Goal creation/replacement routes through the multi-goal registry
|
|
@@ -2356,12 +2364,9 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
|
|
|
2356
2364
|
} else if (status === "resumed") {
|
|
2357
2365
|
if (!goal.stopped)
|
|
2358
2366
|
return "Goal is already running. Pause or stop it first if you want to reset the budget window."
|
|
2359
|
-
const previousGoalId = goal.goalId
|
|
2360
2367
|
resetGoalBudget(goal)
|
|
2361
|
-
//
|
|
2362
|
-
//
|
|
2363
|
-
removeSessionGoal(sessionID, previousGoalId)
|
|
2364
|
-
registerSessionGoal(goal)
|
|
2368
|
+
// goalId is stable across budget windows; runId is the execution epoch.
|
|
2369
|
+
// Keeping the existing registry entry also preserves multi-goal order.
|
|
2365
2370
|
focusGoal(sessionID, goal)
|
|
2366
2371
|
goal.stopped = false
|
|
2367
2372
|
goal.stopReason = ""
|
|
@@ -2594,7 +2599,7 @@ function formatGoalList(sessionID, commandName = "goal") {
|
|
|
2594
2599
|
return lines.join("\n")
|
|
2595
2600
|
}
|
|
2596
2601
|
|
|
2597
|
-
// Visible audit messages
|
|
2602
|
+
// Visible audit messages: when the plugin audits a completion or
|
|
2598
2603
|
// blocker it announces the audit and its result instead of doing the work
|
|
2599
2604
|
// silently. Delivery is via this default messenger (structured app log, the
|
|
2600
2605
|
// channel OpenCode surfaces to the user) or a caller-supplied `auditMessenger`
|
|
@@ -2623,7 +2628,7 @@ async function defaultAuditMessenger(client, sessionID, text) {
|
|
|
2623
2628
|
}
|
|
2624
2629
|
}
|
|
2625
2630
|
|
|
2626
|
-
// Completion auditor
|
|
2631
|
+
// Completion auditor. When an auditor is configured, a [goal:complete]
|
|
2627
2632
|
// is verified before the goal is archived: an approved verdict archives it, a
|
|
2628
2633
|
// rejected verdict restores the goal (pauses it with the reason) instead of
|
|
2629
2634
|
// archiving. The auditor is a function `({ goal, sessionID, latestText }) =>
|
|
@@ -2783,7 +2788,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2783
2788
|
}
|
|
2784
2789
|
runtime.drainPersistence = () => persistChain.catch(() => false)
|
|
2785
2790
|
|
|
2786
|
-
// Fail
|
|
2791
|
+
// Fail closed when persisting a terminal state (complete/blocked)
|
|
2787
2792
|
// fails, surface it loudly. The terminal event is already in the append-only
|
|
2788
2793
|
// ledger, so it stays recoverable across a restart even though the main state
|
|
2789
2794
|
// file write did not land.
|
|
@@ -2810,7 +2815,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2810
2815
|
setLedgerSink(null)
|
|
2811
2816
|
}
|
|
2812
2817
|
|
|
2813
|
-
// Visible audit announcements
|
|
2818
|
+
// Visible audit announcements.
|
|
2814
2819
|
const auditMessagesEnabled = pluginOptions.auditMessages !== false
|
|
2815
2820
|
const auditMessenger =
|
|
2816
2821
|
typeof pluginOptions.auditMessenger === "function"
|
|
@@ -2885,6 +2890,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2885
2890
|
})
|
|
2886
2891
|
if (pluginOptions.completionAudit) verifierRegistrationReady = true
|
|
2887
2892
|
},
|
|
2893
|
+
"tool.execute.before": async (input) => {
|
|
2894
|
+
const sessionID = input?.sessionID
|
|
2895
|
+
if (!sessionID || !currentRuntime().readOnlyCommandGuards.has(sessionID)) return
|
|
2896
|
+
if (READ_ONLY_COMMAND_TOOLS.has(input?.tool)) return
|
|
2897
|
+
throw new Error(
|
|
2898
|
+
`This /${commandName} control command is read-only for the routed model turn. Tool "${input?.tool || "unknown"}" was blocked. Wait for a separate user turn; do not modify work or goal state now.`,
|
|
2899
|
+
)
|
|
2900
|
+
},
|
|
2888
2901
|
"command.execute.before": async (input, output) => {
|
|
2889
2902
|
if (!input || input.command !== commandName || !output) return
|
|
2890
2903
|
|
|
@@ -2898,10 +2911,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2898
2911
|
}
|
|
2899
2912
|
const args = input.arguments.trim()
|
|
2900
2913
|
const sessionID = input.sessionID
|
|
2914
|
+
currentRuntime().readOnlyCommandGuards.delete(sessionID)
|
|
2901
2915
|
pruneGoalResults(defaultGoalOptions)
|
|
2902
2916
|
|
|
2903
2917
|
if (!args || args === "status") {
|
|
2904
2918
|
const goal = goalStates.get(sessionID)
|
|
2919
|
+
currentRuntime().readOnlyCommandGuards.add(sessionID)
|
|
2905
2920
|
const lastResult = lastGoalResults.get(sessionID)
|
|
2906
2921
|
output.parts = [
|
|
2907
2922
|
makeTextPart(
|
|
@@ -2917,6 +2932,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2917
2932
|
|
|
2918
2933
|
if (args === "history") {
|
|
2919
2934
|
const goal = goalStates.get(sessionID)
|
|
2935
|
+
currentRuntime().readOnlyCommandGuards.add(sessionID)
|
|
2920
2936
|
const lastResult = lastGoalResults.get(sessionID)
|
|
2921
2937
|
output.parts = [
|
|
2922
2938
|
makeTextPart(
|
|
@@ -2943,6 +2959,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2943
2959
|
}
|
|
2944
2960
|
|
|
2945
2961
|
if (CLEAR_COMMANDS.has(args)) {
|
|
2962
|
+
currentRuntime().readOnlyCommandGuards.add(sessionID)
|
|
2946
2963
|
// Record the clear in the ledger before cleanupGoal removes the goal
|
|
2947
2964
|
// object, so reconstructFromLedger can identify cleared goals and skip
|
|
2948
2965
|
// them rather than reconstructing them after a missing state file.
|
|
@@ -2962,6 +2979,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2962
2979
|
}
|
|
2963
2980
|
|
|
2964
2981
|
if (PAUSE_COMMANDS.has(args)) {
|
|
2982
|
+
currentRuntime().readOnlyCommandGuards.add(sessionID)
|
|
2965
2983
|
const goal = goalStates.get(sessionID)
|
|
2966
2984
|
if (!goal) {
|
|
2967
2985
|
output.parts = [makeTextPart(`No active goal. Set one with \`/${commandName} <condition>\`.`)]
|
|
@@ -2987,12 +3005,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
2987
3005
|
return
|
|
2988
3006
|
}
|
|
2989
3007
|
|
|
2990
|
-
const previousGoalId = goal.goalId
|
|
2991
3008
|
resetGoalBudget(goal)
|
|
2992
|
-
//
|
|
2993
|
-
//
|
|
2994
|
-
removeSessionGoal(sessionID, previousGoalId)
|
|
2995
|
-
registerSessionGoal(goal)
|
|
3009
|
+
// goalId is stable across budget windows; runId is the execution epoch.
|
|
3010
|
+
// Keeping the existing registry entry also preserves multi-goal order.
|
|
2996
3011
|
focusGoal(sessionID, goal)
|
|
2997
3012
|
goal.stopped = false
|
|
2998
3013
|
goal.stopReason = ""
|
|
@@ -3052,6 +3067,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3052
3067
|
}
|
|
3053
3068
|
|
|
3054
3069
|
if (args === "list") {
|
|
3070
|
+
currentRuntime().readOnlyCommandGuards.add(sessionID)
|
|
3055
3071
|
output.parts = [makeTextPart(formatGoalList(sessionID, commandName))]
|
|
3056
3072
|
return
|
|
3057
3073
|
}
|
|
@@ -3394,6 +3410,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3394
3410
|
if (!isIdleEvent(event)) return
|
|
3395
3411
|
|
|
3396
3412
|
const sessionID = getSessionID(event)
|
|
3413
|
+
currentRuntime().readOnlyCommandGuards.delete(sessionID)
|
|
3397
3414
|
const eventID = typeof event?.id === "string" ? event.id : ""
|
|
3398
3415
|
const seenIdleEventIDs = currentRuntime().seenIdleEventIDs
|
|
3399
3416
|
if (eventID && seenIdleEventIDs.has(eventID)) return
|
|
@@ -3478,7 +3495,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3478
3495
|
// bail out without archiving — archiving a cleared goal would resurrect
|
|
3479
3496
|
// it in memory and potentially in the persisted state.
|
|
3480
3497
|
if (!activeGoal(sessionID, goalID, runID)) return
|
|
3481
|
-
// Optional independent auditor
|
|
3498
|
+
// Optional independent auditor: an approved verdict
|
|
3482
3499
|
// archives; a rejected verdict restores (pauses) the goal instead.
|
|
3483
3500
|
if (completionAuditor) {
|
|
3484
3501
|
let verdict
|
|
@@ -3867,7 +3884,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3867
3884
|
|
|
3868
3885
|
const goal = goalStates.get(input.sessionID)
|
|
3869
3886
|
if (!goal) return
|
|
3870
|
-
if (goal.stopped) return
|
|
3871
3887
|
const systemBlocks = Array.isArray(output.system) ? [...output.system] : []
|
|
3872
3888
|
if (systemBlocks.some((block) => systemBlockContainsGoal(block, goal.goalId))) return
|
|
3873
3889
|
|
|
@@ -3880,14 +3896,23 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3880
3896
|
// on every continuation turn via buildContinueMessage (buildLimitWarning
|
|
3881
3897
|
// and <progress_budget>), which is sufficient — the model doesn't need
|
|
3882
3898
|
// them in the system prompt mid-turn.
|
|
3883
|
-
const goalBlock =
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3899
|
+
const goalBlock = goal.stopped
|
|
3900
|
+
? [
|
|
3901
|
+
`<opencode_goal_plugin id="${goal.goalId}">`,
|
|
3902
|
+
"<goal_state>paused</goal_state>",
|
|
3903
|
+
"A goal exists for this session, but it is paused. Do not continue or modify work toward it, and do not call completion or blocker tools, unless the current user message explicitly asks to resume it.",
|
|
3904
|
+
"For status or history requests, only report the goal state; do not change files or goal state.",
|
|
3905
|
+
`To continue, the user can run /${commandName} resume or explicitly ask you to call goal_resume before doing any goal work.`,
|
|
3906
|
+
"</opencode_goal_plugin>",
|
|
3907
|
+
].join("\n")
|
|
3908
|
+
: [
|
|
3909
|
+
`<opencode_goal_plugin id="${goal.goalId}">`,
|
|
3910
|
+
buildGoalBlock(goal),
|
|
3911
|
+
"Keep working until the goal is fully satisfied.",
|
|
3912
|
+
"When fully satisfied, put a `[goal:evidence]` line summarizing what you verified immediately before `[goal:complete]`. A `[goal:complete]` without evidence is rejected.",
|
|
3913
|
+
"If user input is required, explain the concrete blocker in the line immediately before `[goal:blocked]`. A `[goal:blocked]` without a concrete blocker is rejected.",
|
|
3914
|
+
"</opencode_goal_plugin>",
|
|
3915
|
+
].join("\n")
|
|
3891
3916
|
|
|
3892
3917
|
if (systemBlocks.length === 0) {
|
|
3893
3918
|
output.system = [goalBlock]
|
|
@@ -3930,13 +3955,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
|
|
|
3930
3955
|
},
|
|
3931
3956
|
}
|
|
3932
3957
|
|
|
3933
|
-
// register_command toggle
|
|
3958
|
+
// register_command toggle: when disabled, the plugin does not own
|
|
3934
3959
|
// a slash command and only the event/transform/compaction hooks remain.
|
|
3935
3960
|
if (!registerCommand) {
|
|
3936
3961
|
delete hooks["command.execute.before"]
|
|
3937
3962
|
}
|
|
3938
3963
|
|
|
3939
|
-
// Register agent-facing tools
|
|
3964
|
+
// Register agent-facing tools when @opencode-ai/plugin is
|
|
3940
3965
|
// available (it provides the `tool` helper and zod-style schema). Disabled via
|
|
3941
3966
|
// `registerTools: false`. When the helper is absent the command/event hooks
|
|
3942
3967
|
// still work; only the programmatic tool surface is omitted, preserving the
|
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
|
-
import { promises as fs } from "node:fs"
|
|
5
|
-
import { tmpdir } from "node:os"
|
|
6
|
-
import { join } from "node:path"
|
|
7
|
-
import { GoalPlugin } from "../src/goal-plugin.js"
|
|
8
|
-
|
|
9
|
-
const startedAt = performance.now()
|
|
10
|
-
const temporaryDirectories = []
|
|
11
|
-
|
|
12
|
-
function assistantMessage(sessionID, text, id = `assistant-${sessionID}`) {
|
|
13
|
-
return {
|
|
14
|
-
info: {
|
|
15
|
-
id,
|
|
16
|
-
role: "assistant",
|
|
17
|
-
sessionID,
|
|
18
|
-
tokens: { input: 20, output: 120, reasoning: 0 },
|
|
19
|
-
},
|
|
20
|
-
parts: [{ type: "text", text }],
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function createHost(messageForSession = () => "Working with tools.") {
|
|
25
|
-
const prompts = []
|
|
26
|
-
const notices = []
|
|
27
|
-
return {
|
|
28
|
-
prompts,
|
|
29
|
-
notices,
|
|
30
|
-
client: {
|
|
31
|
-
app: { log: async () => {} },
|
|
32
|
-
session: {
|
|
33
|
-
messages: async ({ path }) => ({
|
|
34
|
-
data: [assistantMessage(path.id, messageForSession(path.id))],
|
|
35
|
-
}),
|
|
36
|
-
promptAsync: async (input) => {
|
|
37
|
-
prompts.push(input)
|
|
38
|
-
return {}
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
},
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function promptCharacters(prompts) {
|
|
46
|
-
return prompts.reduce(
|
|
47
|
-
(total, prompt) => total + (prompt?.body?.parts || []).reduce(
|
|
48
|
-
(partTotal, part) => partTotal + (typeof part?.text === "string" ? part.text.length : 0),
|
|
49
|
-
0,
|
|
50
|
-
),
|
|
51
|
-
0,
|
|
52
|
-
)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function makeDirectory(label) {
|
|
56
|
-
const directory = await fs.mkdtemp(join(tmpdir(), `goal-benchmark-${label}-`))
|
|
57
|
-
temporaryDirectories.push(directory)
|
|
58
|
-
return directory
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function createHooks(host, options = {}) {
|
|
62
|
-
return GoalPlugin(
|
|
63
|
-
{ client: host.client, directory: await makeDirectory("workspace") },
|
|
64
|
-
{
|
|
65
|
-
persistState: false,
|
|
66
|
-
registerTools: false,
|
|
67
|
-
registerAgents: false,
|
|
68
|
-
minDelayMs: 1,
|
|
69
|
-
noProgressTokenThreshold: 1,
|
|
70
|
-
noProgressTurnsBeforePause: 10,
|
|
71
|
-
noToolCallTurnsBeforePause: 2,
|
|
72
|
-
...options,
|
|
73
|
-
},
|
|
74
|
-
)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function goalCommand(hooks, sessionID, argumentsText) {
|
|
78
|
-
const output = { parts: [] }
|
|
79
|
-
await hooks["command.execute.before"](
|
|
80
|
-
{ command: "goal", sessionID, arguments: argumentsText },
|
|
81
|
-
output,
|
|
82
|
-
)
|
|
83
|
-
return output.parts.map((part) => part.text || "").join("\n")
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function idle(hooks, sessionID, id) {
|
|
87
|
-
await hooks.event({
|
|
88
|
-
event: {
|
|
89
|
-
id,
|
|
90
|
-
type: "session.status",
|
|
91
|
-
properties: { sessionID, status: { type: "idle" } },
|
|
92
|
-
},
|
|
93
|
-
})
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function scenario(name, points, run) {
|
|
97
|
-
const scenarioStartedAt = performance.now()
|
|
98
|
-
try {
|
|
99
|
-
const telemetry = await run()
|
|
100
|
-
return {
|
|
101
|
-
name,
|
|
102
|
-
passed: true,
|
|
103
|
-
points,
|
|
104
|
-
durationMs: Number((performance.now() - scenarioStartedAt).toFixed(2)),
|
|
105
|
-
...telemetry,
|
|
106
|
-
}
|
|
107
|
-
} catch (error) {
|
|
108
|
-
return {
|
|
109
|
-
name,
|
|
110
|
-
passed: false,
|
|
111
|
-
points: 0,
|
|
112
|
-
possiblePoints: points,
|
|
113
|
-
durationMs: Number((performance.now() - scenarioStartedAt).toFixed(2)),
|
|
114
|
-
error: error instanceof Error ? error.message : String(error),
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const results = []
|
|
120
|
-
|
|
121
|
-
results.push(await scenario("verified-success", 20, async () => {
|
|
122
|
-
const sessionID = "benchmark-success"
|
|
123
|
-
const host = createHost(() => "Tests pass.\n[goal:evidence] npm test: 210/210\n[goal:complete]")
|
|
124
|
-
const hooks = await createHooks(host, {
|
|
125
|
-
auditor: async () => ({ approved: true, reason: "evidence independently accepted" }),
|
|
126
|
-
})
|
|
127
|
-
await goalCommand(hooks, sessionID, "ship a verified release")
|
|
128
|
-
await idle(hooks, sessionID, "success-idle")
|
|
129
|
-
const status = await goalCommand(hooks, sessionID, "status")
|
|
130
|
-
assert.match(status, /State: achieved/)
|
|
131
|
-
await hooks.dispose()
|
|
132
|
-
return {
|
|
133
|
-
continuationPrompts: host.prompts.length,
|
|
134
|
-
continuationCharacters: promptCharacters(host.prompts),
|
|
135
|
-
status: "archived",
|
|
136
|
-
}
|
|
137
|
-
}))
|
|
138
|
-
|
|
139
|
-
results.push(await scenario("false-completion", 20, async () => {
|
|
140
|
-
const sessionID = "benchmark-false-completion"
|
|
141
|
-
const host = createHost(() => "Looks done.\n[goal:evidence] guessed from source\n[goal:complete]")
|
|
142
|
-
const hooks = await createHooks(host, {
|
|
143
|
-
auditor: async () => ({ approved: false, reason: "no executed verification" }),
|
|
144
|
-
})
|
|
145
|
-
await goalCommand(hooks, sessionID, "do not accept an unverified claim")
|
|
146
|
-
await idle(hooks, sessionID, "false-idle")
|
|
147
|
-
const status = await goalCommand(hooks, sessionID, "status")
|
|
148
|
-
assert.match(status, /audit rejected/i)
|
|
149
|
-
assert.doesNotMatch(status, /No active goal/)
|
|
150
|
-
await hooks.dispose()
|
|
151
|
-
return {
|
|
152
|
-
continuationPrompts: host.prompts.length,
|
|
153
|
-
continuationCharacters: promptCharacters(host.prompts),
|
|
154
|
-
status: "rejected",
|
|
155
|
-
}
|
|
156
|
-
}))
|
|
157
|
-
|
|
158
|
-
results.push(await scenario("loop-circuit-breaker", 15, async () => {
|
|
159
|
-
const sessionID = "benchmark-loop"
|
|
160
|
-
let turn = 0
|
|
161
|
-
const host = createHost(() => `Still discussing the work, turn ${turn++}.`)
|
|
162
|
-
const hooks = await createHooks(host)
|
|
163
|
-
await goalCommand(hooks, sessionID, "stop self-chat loops")
|
|
164
|
-
await idle(hooks, sessionID, "loop-1")
|
|
165
|
-
await idle(hooks, sessionID, "loop-2")
|
|
166
|
-
await idle(hooks, sessionID, "loop-3")
|
|
167
|
-
const status = await goalCommand(hooks, sessionID, "status")
|
|
168
|
-
assert.match(status, /no tool calls|self-chat loop/i)
|
|
169
|
-
assert.equal(host.prompts.length, 2)
|
|
170
|
-
await hooks.dispose()
|
|
171
|
-
return {
|
|
172
|
-
continuationPrompts: host.prompts.length,
|
|
173
|
-
continuationCharacters: promptCharacters(host.prompts),
|
|
174
|
-
status: "paused",
|
|
175
|
-
}
|
|
176
|
-
}))
|
|
177
|
-
|
|
178
|
-
results.push(await scenario("human-interruption", 15, async () => {
|
|
179
|
-
const sessionID = "benchmark-interruption"
|
|
180
|
-
const host = createHost()
|
|
181
|
-
const hooks = await createHooks(host)
|
|
182
|
-
await goalCommand(hooks, sessionID, "respect explicit interruption")
|
|
183
|
-
await hooks.event({
|
|
184
|
-
event: {
|
|
185
|
-
type: "session.error",
|
|
186
|
-
properties: {
|
|
187
|
-
sessionID,
|
|
188
|
-
error: { name: "MessageAbortedError", message: "aborted by user" },
|
|
189
|
-
},
|
|
190
|
-
},
|
|
191
|
-
})
|
|
192
|
-
await idle(hooks, sessionID, "interruption-idle")
|
|
193
|
-
assert.equal(host.prompts.length, 0)
|
|
194
|
-
assert.match(await goalCommand(hooks, sessionID, "status"), /abort|paused|stopped/i)
|
|
195
|
-
await hooks.dispose()
|
|
196
|
-
return { continuationPrompts: 0, status: "paused" }
|
|
197
|
-
}))
|
|
198
|
-
|
|
199
|
-
results.push(await scenario("compaction-continuity", 15, async () => {
|
|
200
|
-
const sessionID = "benchmark-compaction"
|
|
201
|
-
const host = createHost()
|
|
202
|
-
const hooks = await createHooks(host)
|
|
203
|
-
await goalCommand(hooks, sessionID, "preserve the objective across compaction")
|
|
204
|
-
const output = { context: [] }
|
|
205
|
-
await hooks["experimental.session.compacting"]({ sessionID }, output)
|
|
206
|
-
assert.equal(output.context.length, 1)
|
|
207
|
-
assert.match(output.context[0], /preserve the objective across compaction/)
|
|
208
|
-
assert.ok(output.context[0].length < 2_000, "compaction context exceeded token-efficient size cap")
|
|
209
|
-
await hooks.dispose()
|
|
210
|
-
return {
|
|
211
|
-
contextCharacters: output.context[0].length,
|
|
212
|
-
estimatedContextTokens: Math.ceil(output.context[0].length / 4),
|
|
213
|
-
status: "preserved",
|
|
214
|
-
}
|
|
215
|
-
}))
|
|
216
|
-
|
|
217
|
-
results.push(await scenario("restart-recovery", 15, async () => {
|
|
218
|
-
const sessionID = "benchmark-restart"
|
|
219
|
-
const directory = await makeDirectory("restart")
|
|
220
|
-
const stateFilePath = join(directory, "state.json")
|
|
221
|
-
const host = createHost()
|
|
222
|
-
const first = await GoalPlugin(
|
|
223
|
-
{ client: host.client, directory },
|
|
224
|
-
{ persistState: true, stateFilePath, registerTools: false, registerAgents: false, minDelayMs: 1 },
|
|
225
|
-
)
|
|
226
|
-
await goalCommand(first, sessionID, "recover safely after restart")
|
|
227
|
-
await first.dispose()
|
|
228
|
-
const second = await GoalPlugin(
|
|
229
|
-
{ client: host.client, directory },
|
|
230
|
-
{ persistState: true, stateFilePath, registerTools: false, registerAgents: false, minDelayMs: 1 },
|
|
231
|
-
)
|
|
232
|
-
const status = await goalCommand(second, sessionID, "status")
|
|
233
|
-
assert.match(status, /Recovered persisted goal state|recovered after restart/i)
|
|
234
|
-
await idle(second, sessionID, "restart-idle")
|
|
235
|
-
assert.equal(host.prompts.length, 0, "recovered goals must not resume without user consent")
|
|
236
|
-
const stateBytes = (await fs.stat(stateFilePath)).size
|
|
237
|
-
await second.dispose()
|
|
238
|
-
return { continuationPrompts: 0, persistedStateBytes: stateBytes, status: "recovered-paused" }
|
|
239
|
-
}))
|
|
240
|
-
|
|
241
|
-
const score = results.reduce((total, result) => total + result.points, 0)
|
|
242
|
-
const possibleScore = 100
|
|
243
|
-
const continuationCharacters = results.reduce(
|
|
244
|
-
(total, result) => total + (result.continuationCharacters || 0),
|
|
245
|
-
0,
|
|
246
|
-
)
|
|
247
|
-
const report = {
|
|
248
|
-
schemaVersion: 1,
|
|
249
|
-
benchmark: "opencode-goal-plugin-behavior",
|
|
250
|
-
score,
|
|
251
|
-
possibleScore,
|
|
252
|
-
passed: score === possibleScore,
|
|
253
|
-
durationMs: Number((performance.now() - startedAt).toFixed(2)),
|
|
254
|
-
efficiency: {
|
|
255
|
-
totalContinuationPrompts: results.reduce(
|
|
256
|
-
(total, result) => total + (result.continuationPrompts || 0),
|
|
257
|
-
0,
|
|
258
|
-
),
|
|
259
|
-
continuationCharacters,
|
|
260
|
-
estimatedContinuationTokens: Math.ceil(continuationCharacters / 4),
|
|
261
|
-
modelCalls: 0,
|
|
262
|
-
externalRequests: 0,
|
|
263
|
-
},
|
|
264
|
-
scenarios: results,
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
for (const directory of temporaryDirectories) {
|
|
268
|
-
await fs.rm(directory, { recursive: true, force: true })
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
272
|
-
if (!report.passed) process.exitCode = 1
|