opencode-goal-plugin 0.4.1 → 0.5.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/CHANGELOG.md +85 -0
- package/README.md +75 -2
- package/index.d.ts +299 -0
- package/package.json +9 -3
- package/scripts/verify.mjs +123 -0
- package/src/goal-plugin.js +328 -58
package/src/goal-plugin.js
CHANGED
|
@@ -56,7 +56,13 @@ const MAX_ARCHIVED_PER_SESSION = 10
|
|
|
56
56
|
const lastGoalResults = new Map()
|
|
57
57
|
const seenTokens = new Map()
|
|
58
58
|
const seenOutputTokens = new Map()
|
|
59
|
-
|
|
59
|
+
// Map<sessionID, token> rather than Set so the idle handler's finally block can
|
|
60
|
+
// detect whether its entry has been superseded by a new handler: if cleanupGoal
|
|
61
|
+
// deletes the sessionID (allowing a new handler to start and set a fresh token)
|
|
62
|
+
// before the old handler's finally fires, the old finally skips the delete
|
|
63
|
+
// because the token no longer matches. With a plain Set, the old finally would
|
|
64
|
+
// unconditionally delete the new handler's guard, exposing a race window.
|
|
65
|
+
const activeContinues = new Map()
|
|
60
66
|
const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"])
|
|
61
67
|
const PAUSE_COMMANDS = new Set(["pause"])
|
|
62
68
|
const GOAL_FLAG_SPECS = {
|
|
@@ -111,7 +117,9 @@ const GOAL_FLAG_SPECS = {
|
|
|
111
117
|
// shapes count as tool-using turns too). A continuation turn with none of these
|
|
112
118
|
// is "talk only" — a signal of a self-chat loop the auto-continue should not
|
|
113
119
|
// keep feeding.
|
|
114
|
-
|
|
120
|
+
// Covers both normalized OpenCode types and raw provider-specific part types
|
|
121
|
+
// (some adapters forward the provider's original shape without normalizing).
|
|
122
|
+
const TOOL_PART_TYPES = new Set(["tool", "tool-invocation", "subtask", "tool_use", "function_call", "tool-call"])
|
|
115
123
|
|
|
116
124
|
function messageHasToolCall(message) {
|
|
117
125
|
const parts = Array.isArray(message?.parts) ? message.parts : []
|
|
@@ -442,10 +450,13 @@ function promoteNextOrderedGoal(sessionID) {
|
|
|
442
450
|
function cleanupGoal(sessionID) {
|
|
443
451
|
const goal = goalStates.get(sessionID)
|
|
444
452
|
if (goal) {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
453
|
+
// seenTokens entries for this goal's message IDs are intentionally NOT deleted
|
|
454
|
+
// here. resetGoalBudget also leaves them in place. The message.updated handler
|
|
455
|
+
// uses the presence of an ID in seenTokens combined with its absence from the
|
|
456
|
+
// current goal.messageIDs to detect and skip stale re-deliveries — deleting
|
|
457
|
+
// entries here would break that guard for post-replacement stale events.
|
|
458
|
+
// Entries are cleared in bulk by clearRuntimeState on plugin teardown; the
|
|
459
|
+
// per-process accumulation is small (O(turns × messages_per_turn)).
|
|
449
460
|
removeSessionGoal(sessionID, goal.goalId)
|
|
450
461
|
}
|
|
451
462
|
goalStates.delete(sessionID)
|
|
@@ -505,10 +516,11 @@ function rememberGoalResult(sessionID, goal, state, reason = "", evidence = "")
|
|
|
505
516
|
}
|
|
506
517
|
|
|
507
518
|
function resetGoalBudget(goal) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
519
|
+
// Do NOT delete old message IDs from seenTokens here. The message.updated
|
|
520
|
+
// handler guards against stale re-deliveries by checking whether the message ID
|
|
521
|
+
// is in seenTokens but NOT in the current goal.messageIDs — keeping the entries
|
|
522
|
+
// alive is what makes that check reliable. cleanupGoal removes them when the
|
|
523
|
+
// goal is fully discarded, so seenTokens entries are bounded to active goals.
|
|
512
524
|
goal.goalId = randomUUID()
|
|
513
525
|
goal.startedAt = Date.now()
|
|
514
526
|
goal.turnCount = 0
|
|
@@ -765,6 +777,7 @@ function normalizePersistedGoal(rawGoal) {
|
|
|
765
777
|
stopped: rawGoal.stopped === true,
|
|
766
778
|
stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "",
|
|
767
779
|
promptFailures: toNonNegativeInteger(rawGoal.promptFailures),
|
|
780
|
+
formatFailures: toNonNegativeInteger(rawGoal.formatFailures),
|
|
768
781
|
messageIDs: Array.isArray(rawGoal.messageIDs)
|
|
769
782
|
? rawGoal.messageIDs.filter((messageID) => typeof messageID === "string" && messageID)
|
|
770
783
|
: [],
|
|
@@ -925,6 +938,38 @@ async function applyParsedStateFile(raw, client) {
|
|
|
925
938
|
return "loaded"
|
|
926
939
|
}
|
|
927
940
|
|
|
941
|
+
// After applyParsedStateFile loads goals into goalStates, check the ledger for
|
|
942
|
+
// terminal events. If a goal has a "completed" or "cleared" entry in the ledger
|
|
943
|
+
// but still appears active in the state file (because the state write failed
|
|
944
|
+
// after the terminal ledger write), remove it so it is not re-driven.
|
|
945
|
+
async function reconcileLoadedStateWithLedger(persistenceOptions, client) {
|
|
946
|
+
const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath)
|
|
947
|
+
if (!entries.length) return
|
|
948
|
+
|
|
949
|
+
const terminalGoalIds = new Set()
|
|
950
|
+
for (const entry of entries) {
|
|
951
|
+
if (LEDGER_TERMINAL_TYPES.has(entry.type) && typeof entry.goalId === "string" && entry.goalId) {
|
|
952
|
+
terminalGoalIds.add(entry.goalId)
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (!terminalGoalIds.size) return
|
|
956
|
+
|
|
957
|
+
let removed = 0
|
|
958
|
+
for (const [sessionID, goal] of goalStates.entries()) {
|
|
959
|
+
if (terminalGoalIds.has(goal.goalId)) {
|
|
960
|
+
removeSessionGoal(sessionID, goal.goalId)
|
|
961
|
+
goalStates.delete(sessionID)
|
|
962
|
+
removed++
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (removed > 0) {
|
|
966
|
+
await logPluginError(
|
|
967
|
+
client,
|
|
968
|
+
`Ledger cross-check: removed ${removed} goal(s) whose terminal state was recorded in the ledger but not yet reflected in the state file (likely a failed terminal persist).`,
|
|
969
|
+
)
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
928
973
|
async function loadPersistedState(persistenceOptions, client) {
|
|
929
974
|
if (!persistenceOptions.persistState) return "disabled"
|
|
930
975
|
|
|
@@ -955,7 +1000,15 @@ async function loadPersistedState(persistenceOptions, client) {
|
|
|
955
1000
|
continue
|
|
956
1001
|
}
|
|
957
1002
|
|
|
958
|
-
if (status === "loaded")
|
|
1003
|
+
if (status === "loaded") {
|
|
1004
|
+
// Cross-check: the ledger is written before the state file for terminal
|
|
1005
|
+
// events (completed, cleared). If the terminal persist succeeded in the
|
|
1006
|
+
// ledger but the state file write failed (e.g. process killed between the
|
|
1007
|
+
// two writes), the reloaded state may still have the goal as active. Remove
|
|
1008
|
+
// any loaded active goals whose goalId has a terminal ledger entry.
|
|
1009
|
+
await reconcileLoadedStateWithLedger(persistenceOptions, client)
|
|
1010
|
+
return primary ? "loaded" : "migrated"
|
|
1011
|
+
}
|
|
959
1012
|
// status === "invalid": preserve a present-but-corrupt primary; for a
|
|
960
1013
|
// fallback, keep trying the next candidate.
|
|
961
1014
|
if (primary) return "invalid"
|
|
@@ -1179,6 +1232,17 @@ const STRUCTURAL_TAGS = [
|
|
|
1179
1232
|
"next_step",
|
|
1180
1233
|
"completion_audit",
|
|
1181
1234
|
"evidence_required",
|
|
1235
|
+
// Role-like names that model providers treat as elevated context (second-order
|
|
1236
|
+
// injection: a goal could guide the model to emit these in output captured by
|
|
1237
|
+
// recordCheckpoint, then re-injected via compaction or buildGoalBlock).
|
|
1238
|
+
"system",
|
|
1239
|
+
"instructions",
|
|
1240
|
+
"human",
|
|
1241
|
+
"assistant",
|
|
1242
|
+
"anthropic",
|
|
1243
|
+
"claude",
|
|
1244
|
+
"context",
|
|
1245
|
+
"prompt",
|
|
1182
1246
|
]
|
|
1183
1247
|
const STRUCTURAL_OPEN_TAG_RE = new RegExp(`<(${STRUCTURAL_TAGS.join("|")})\\b`, "gi")
|
|
1184
1248
|
|
|
@@ -1321,14 +1385,17 @@ function buildCompactionProgressSummary(goal, { maxCheckpoints = 3, maxEvents =
|
|
|
1321
1385
|
if (checkpoints.length) {
|
|
1322
1386
|
lines.push("Recent checkpoints (oldest first):")
|
|
1323
1387
|
for (const checkpoint of checkpoints) {
|
|
1324
|
-
|
|
1388
|
+
// Escape: checkpoint summaries contain assistant-generated text; an
|
|
1389
|
+
// adversarial model output could inject structural tags into this string,
|
|
1390
|
+
// which would be re-embedded in the compaction context system message.
|
|
1391
|
+
lines.push(`- ${escapeGoalText(summarizeText(checkpoint.summary, 200))}`)
|
|
1325
1392
|
}
|
|
1326
1393
|
}
|
|
1327
1394
|
const events = Array.isArray(goal.history) ? goal.history.slice(-maxEvents) : []
|
|
1328
1395
|
if (events.length) {
|
|
1329
1396
|
lines.push("Recent lifecycle events (oldest first):")
|
|
1330
1397
|
for (const event of events) {
|
|
1331
|
-
lines.push(`- ${event.type}: ${summarizeText(event.detail, 160)}`)
|
|
1398
|
+
lines.push(`- ${event.type}: ${escapeGoalText(summarizeText(event.detail, 160))}`)
|
|
1332
1399
|
}
|
|
1333
1400
|
}
|
|
1334
1401
|
return lines
|
|
@@ -1339,14 +1406,19 @@ function buildCompactionContext(goal) {
|
|
|
1339
1406
|
// this, a compaction can drop the goal objective and budget state from the
|
|
1340
1407
|
// working context, so the assistant loses the thread mid-run even though the
|
|
1341
1408
|
// plugin still re-injects via system.transform afterward.
|
|
1342
|
-
|
|
1409
|
+
// Use goal.lastContinueAt (set on each persist cycle) rather than Date.now()
|
|
1410
|
+
// so buildCompactionContext is deterministic. If OpenCode calls the compacting
|
|
1411
|
+
// hook more than once, each invocation produces the same elapsedSeconds and
|
|
1412
|
+
// therefore the same string — preserving the prefix cache from this point on.
|
|
1413
|
+
const snapshotAt = goal.lastContinueAt || goal.startedAt || 0
|
|
1414
|
+
const elapsedSeconds = Math.round((snapshotAt - goal.startedAt) / 1000)
|
|
1343
1415
|
return [
|
|
1344
1416
|
"An OpenCode goal is active for this session. Preserve it across compaction.",
|
|
1345
1417
|
"The summary below is reconstructed deterministically from the plugin's persisted goal record, not from chat memory.",
|
|
1346
1418
|
buildGoalBlock(goal),
|
|
1347
1419
|
`Goal status: ${goal.stopped ? goal.stopReason || "stopped" : "active"}.`,
|
|
1348
1420
|
`Auto-continues used: ${goal.turnCount}/${goal.options.maxTurns}. Context tokens: ${goal.totalTokens}/${goal.options.maxTokens}. Elapsed: ${elapsedSeconds}s.`,
|
|
1349
|
-
goal.lastCheckpoint ? `Latest checkpoint: ${goal.lastCheckpoint.summary}` : null,
|
|
1421
|
+
goal.lastCheckpoint ? `Latest checkpoint: ${escapeGoalText(goal.lastCheckpoint.summary)}` : null,
|
|
1350
1422
|
...buildCompactionProgressSummary(goal),
|
|
1351
1423
|
"After compaction, continue from the next concrete unfinished step while the goal is active. Verify the result against the goal objective before ending; output [goal:complete] (preceded by a [goal:evidence] line) only when fully satisfied, or [goal:blocked] (preceded by a concrete blocker) only if user input is required.",
|
|
1352
1424
|
]
|
|
@@ -1601,7 +1673,11 @@ const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed
|
|
|
1601
1673
|
// result. Goal creation/replacement routes through the multi-goal registry
|
|
1602
1674
|
// (buildGoalState + registerSessionGoal + focusGoal) exactly like the command
|
|
1603
1675
|
// path, so tool-created goals persist and are driven by the idle handler.
|
|
1604
|
-
function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
1676
|
+
function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState = null, completionAuditor = null }) {
|
|
1677
|
+
// Use persistTerminalState (which logs on failure) for terminal operations when
|
|
1678
|
+
// available; fall back to plain persist for callers that don't wire it up (e.g.
|
|
1679
|
+
// tests using buildAgentToolHandlers directly).
|
|
1680
|
+
const persistFinal = persistTerminalState || persist
|
|
1605
1681
|
async function getGoal(sessionID) {
|
|
1606
1682
|
const goal = goalStates.get(sessionID)
|
|
1607
1683
|
if (goal) return formatStatus(goal)
|
|
@@ -1638,6 +1714,17 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
|
1638
1714
|
const objective = typeof args.objective === "string" ? args.objective.trim() : ""
|
|
1639
1715
|
if (!objective) return "No objective provided. Pass a non-empty `objective`."
|
|
1640
1716
|
|
|
1717
|
+
// Validate budget args before normalizing: normalizeOptions silently substitutes
|
|
1718
|
+
// defaults for non-positive values, giving no feedback to the caller.
|
|
1719
|
+
if (Number.isFinite(args.maxTurns) && args.maxTurns <= 0)
|
|
1720
|
+
return `Invalid maxTurns: ${args.maxTurns} — must be a positive integer.`
|
|
1721
|
+
if (Number.isFinite(args.maxTokens) && args.maxTokens <= 0)
|
|
1722
|
+
return `Invalid maxTokens: ${args.maxTokens} — must be a positive integer.`
|
|
1723
|
+
if (Number.isFinite(args.maxDurationMs) && args.maxDurationMs <= 0)
|
|
1724
|
+
return `Invalid maxDurationMs: ${args.maxDurationMs} — must be a positive number.`
|
|
1725
|
+
if (args.mode !== undefined && !GOAL_MODES.has(String(args.mode).toLowerCase()))
|
|
1726
|
+
return `Invalid mode: ${args.mode} (expected ${[...GOAL_MODES].join(" or ")}).`
|
|
1727
|
+
|
|
1641
1728
|
const options = normalizeOptions({
|
|
1642
1729
|
...defaultGoalOptions,
|
|
1643
1730
|
...(Number.isFinite(args.maxTurns) ? { maxTurns: args.maxTurns } : {}),
|
|
@@ -1664,25 +1751,48 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
|
1664
1751
|
registerSessionGoal(goal)
|
|
1665
1752
|
focusGoal(sessionID, goal)
|
|
1666
1753
|
await persist()
|
|
1667
|
-
|
|
1754
|
+
// Escape in the tool result only: goal.condition is stored raw so callers
|
|
1755
|
+
// that build XML (buildGoalBlock, buildContinueMessage) can apply escaping
|
|
1756
|
+
// themselves. Escaping here prevents XML metacharacters in user-supplied
|
|
1757
|
+
// objectives from breaking tool-result boundaries in XML-serialized formats.
|
|
1758
|
+
return `New active goal: ${escapeGoalText(goal.condition)}`
|
|
1668
1759
|
}
|
|
1669
1760
|
|
|
1670
1761
|
async function updateGoal(sessionID, args = {}) {
|
|
1671
1762
|
const goal = goalStates.get(sessionID)
|
|
1672
1763
|
if (!goal) return "No active goal to update. Use set_goal first."
|
|
1673
1764
|
|
|
1765
|
+
// Reject the combination of an objective update with status='complete': the
|
|
1766
|
+
// completion would be archived under a condition that was never executed,
|
|
1767
|
+
// falsifying the audit trail. Require two separate calls.
|
|
1768
|
+
if (
|
|
1769
|
+
typeof args.objective === "string" &&
|
|
1770
|
+
args.objective.trim() &&
|
|
1771
|
+
String(args.status || "").trim().toLowerCase() === "complete"
|
|
1772
|
+
) {
|
|
1773
|
+
return (
|
|
1774
|
+
"Cannot combine an objective update with status='complete'. " +
|
|
1775
|
+
"Use two separate calls: first update the objective (which revises the goal), " +
|
|
1776
|
+
"then mark it complete after completing the revised work."
|
|
1777
|
+
)
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1674
1780
|
const messages = []
|
|
1675
1781
|
|
|
1676
1782
|
if (typeof args.objective === "string" && args.objective.trim()) {
|
|
1677
1783
|
goal.condition = args.objective.trim()
|
|
1678
|
-
goal.stopped
|
|
1679
|
-
goal.
|
|
1784
|
+
// Deliberately NOT clearing goal.stopped or goal.stopReason: updating the
|
|
1785
|
+
// objective does not un-stop a goal. Use status='resumed' to explicitly
|
|
1786
|
+
// restart a stopped goal; silently un-stopping would resurrect audit-rejected
|
|
1787
|
+
// or user-paused goals without the user's knowledge.
|
|
1680
1788
|
goal.blockedReason = ""
|
|
1681
1789
|
goal.budgetWrapupSent = false
|
|
1682
1790
|
goal.noProgressTurns = 0
|
|
1791
|
+
goal.noToolCallTurns = 0
|
|
1792
|
+
goal.formatFailures = 0
|
|
1683
1793
|
goal.lastStatus = "Goal objective updated."
|
|
1684
1794
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(goal.condition, 400)}`)
|
|
1685
|
-
messages.push(`Objective updated: ${goal.condition}`)
|
|
1795
|
+
messages.push(`Objective updated: ${escapeGoalText(goal.condition)}`)
|
|
1686
1796
|
}
|
|
1687
1797
|
|
|
1688
1798
|
if (args.status !== undefined) {
|
|
@@ -1692,6 +1802,27 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
|
1692
1802
|
}
|
|
1693
1803
|
if (status === "complete") {
|
|
1694
1804
|
const evidence = typeof args.evidence === "string" ? args.evidence.trim() : ""
|
|
1805
|
+
// If a completion auditor is configured, run it before archiving so the
|
|
1806
|
+
// agent tool path has the same integrity gate as the [goal:complete] marker
|
|
1807
|
+
// path. Without this, an autonomous agent could bypass the auditor by
|
|
1808
|
+
// calling update_goal({status:"complete"}) instead of using the marker.
|
|
1809
|
+
if (completionAuditor) {
|
|
1810
|
+
let verdict
|
|
1811
|
+
try {
|
|
1812
|
+
verdict = await completionAuditor({ goal, sessionID, latestText: evidence })
|
|
1813
|
+
} catch (error) {
|
|
1814
|
+
verdict = { approved: false, reason: "auditor error" }
|
|
1815
|
+
}
|
|
1816
|
+
if (!verdict || verdict.approved !== true) {
|
|
1817
|
+
const reason = (verdict && verdict.reason) || "completion not substantiated"
|
|
1818
|
+
goal.stopped = true
|
|
1819
|
+
goal.stopReason = "audit rejected"
|
|
1820
|
+
goal.lastStatus = `Completion audit rejected: ${summarizeText(reason, 200)}. Address it, then run /goal resume.`
|
|
1821
|
+
pushHistory(goal, "audit-rejected", `Agent tool completion audit rejected: ${summarizeText(reason, 300)}`)
|
|
1822
|
+
await persist()
|
|
1823
|
+
return `Completion audit rejected: ${summarizeText(reason, 200)}. Goal paused; use /goal resume after addressing the issue.`
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1695
1826
|
goal.lastStatus = "Goal completed."
|
|
1696
1827
|
pushHistory(
|
|
1697
1828
|
goal,
|
|
@@ -1702,15 +1833,18 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
|
1702
1833
|
cleanupGoal(sessionID)
|
|
1703
1834
|
// Advance an ordered (sisyphus) sequence just like the marker path does.
|
|
1704
1835
|
if (sessionOrdered.has(sessionID)) promoteNextOrderedGoal(sessionID)
|
|
1705
|
-
await
|
|
1836
|
+
await persistFinal("completion")
|
|
1706
1837
|
return "Goal marked complete and archived."
|
|
1707
1838
|
}
|
|
1708
1839
|
if (status === "blocked") {
|
|
1709
|
-
|
|
1840
|
+
const blockerText = typeof args.blocker === "string" ? args.blocker.trim() : ""
|
|
1841
|
+
if (!blockerText)
|
|
1842
|
+
return "status 'blocked' requires a non-empty 'blocker' argument describing what is needed."
|
|
1843
|
+
goal.blockedReason = blockerText
|
|
1710
1844
|
goal.stopped = true
|
|
1711
1845
|
goal.stopReason = "blocked"
|
|
1712
1846
|
goal.lastStatus = "Assistant reported blocked."
|
|
1713
|
-
pushHistory(goal, "blocked", goal.blockedReason
|
|
1847
|
+
pushHistory(goal, "blocked", goal.blockedReason)
|
|
1714
1848
|
messages.push("Goal marked blocked.")
|
|
1715
1849
|
} else if (status === "paused") {
|
|
1716
1850
|
goal.stopped = true
|
|
@@ -1719,15 +1853,15 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
|
1719
1853
|
pushHistory(goal, "paused", "Paused via agent tool.")
|
|
1720
1854
|
messages.push("Goal paused.")
|
|
1721
1855
|
} else if (status === "resumed") {
|
|
1856
|
+
if (!goal.stopped)
|
|
1857
|
+
return "Goal is already running. Pause or stop it first if you want to reset the budget window."
|
|
1722
1858
|
const previousGoalId = goal.goalId
|
|
1723
1859
|
resetGoalBudget(goal)
|
|
1724
|
-
// resetGoalBudget rotates goalId
|
|
1725
|
-
//
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
focusGoal(sessionID, goal)
|
|
1730
|
-
}
|
|
1860
|
+
// resetGoalBudget always rotates goalId via randomUUID; unconditionally
|
|
1861
|
+
// re-key the registry so the goal stays findable by its new id.
|
|
1862
|
+
removeSessionGoal(sessionID, previousGoalId)
|
|
1863
|
+
registerSessionGoal(goal)
|
|
1864
|
+
focusGoal(sessionID, goal)
|
|
1731
1865
|
goal.stopped = false
|
|
1732
1866
|
goal.stopReason = ""
|
|
1733
1867
|
goal.blockedReason = ""
|
|
@@ -1745,11 +1879,17 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
|
1745
1879
|
}
|
|
1746
1880
|
|
|
1747
1881
|
async function clearGoal(sessionID) {
|
|
1748
|
-
// Mirror `/goal clear`: drop the ordered flag
|
|
1882
|
+
// Mirror `/goal clear`: drop the ordered flag, ALL backgrounded goals, and the
|
|
1883
|
+
// focused goal + result. Without sessionGoals.delete, background goals added via
|
|
1884
|
+
// `/goal add` survive clear and resurrect as the focused goal on restart.
|
|
1885
|
+
// Record the clear in the ledger before cleanupGoal removes the goal object.
|
|
1886
|
+
const goalBeforeClear = goalStates.get(sessionID)
|
|
1887
|
+
if (goalBeforeClear) pushHistory(goalBeforeClear, "cleared", "Cleared via agent tool.")
|
|
1749
1888
|
sessionOrdered.delete(sessionID)
|
|
1889
|
+
sessionGoals.delete(sessionID)
|
|
1750
1890
|
cleanupGoal(sessionID)
|
|
1751
1891
|
lastGoalResults.delete(sessionID)
|
|
1752
|
-
await
|
|
1892
|
+
await persistFinal("clear")
|
|
1753
1893
|
return "Goal cleared."
|
|
1754
1894
|
}
|
|
1755
1895
|
|
|
@@ -1925,9 +2065,9 @@ function extractAuditVerdictText(response) {
|
|
|
1925
2065
|
// so a missing/broken auditor pipeline never blocks legitimate completions.
|
|
1926
2066
|
// NOTE: the exact child-session SDK shape should be confirmed against a live
|
|
1927
2067
|
// OpenCode; the orchestration around it is what the tests cover.
|
|
1928
|
-
function createChildSessionAuditor(client, { agent = "build" } = {}) {
|
|
2068
|
+
function createChildSessionAuditor(client, { agent = "build", timeoutMs = 120_000 } = {}) {
|
|
1929
2069
|
return async ({ goal, sessionID, latestText }) => {
|
|
1930
|
-
|
|
2070
|
+
const run = async () => {
|
|
1931
2071
|
const sessionApi = client?.session
|
|
1932
2072
|
if (!sessionApi?.create || !sessionApi?.prompt) {
|
|
1933
2073
|
return { approved: true, reason: "child-session API unavailable; auto-approved" }
|
|
@@ -1948,20 +2088,50 @@ function createChildSessionAuditor(client, { agent = "build" } = {}) {
|
|
|
1948
2088
|
verdictText = getText(findLatestAssistantMessage(messages?.data)?.parts)
|
|
1949
2089
|
}
|
|
1950
2090
|
return parseAuditVerdict(verdictText)
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
let timerID
|
|
2094
|
+
const timeout = new Promise((resolve) => {
|
|
2095
|
+
timerID = setTimeout(
|
|
2096
|
+
() => resolve({ approved: false, reason: `auditor timed out after ${timeoutMs}ms` }),
|
|
2097
|
+
timeoutMs,
|
|
2098
|
+
)
|
|
2099
|
+
})
|
|
2100
|
+
|
|
2101
|
+
try {
|
|
2102
|
+
const result = await Promise.race([run(), timeout])
|
|
2103
|
+
return result
|
|
1951
2104
|
} catch (error) {
|
|
1952
2105
|
return { approved: true, reason: `auditor error (auto-approved): ${error?.message || error}` }
|
|
2106
|
+
} finally {
|
|
2107
|
+
clearTimeout(timerID)
|
|
1953
2108
|
}
|
|
1954
2109
|
}
|
|
1955
2110
|
}
|
|
1956
2111
|
|
|
1957
|
-
export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
2112
|
+
export const GoalPlugin = async ({ client, directory } = {}, pluginOptions = {}) => {
|
|
1958
2113
|
const defaultGoalOptions = normalizeOptions(pluginOptions)
|
|
2114
|
+
// OpenCode's PluginInput carries the active session's project directory
|
|
2115
|
+
// separately from the Node process's own process.cwd(), which — when
|
|
2116
|
+
// OpenCode runs as a persistent server/daemon serving multiple
|
|
2117
|
+
// projects/sessions — does NOT track the session's directory. Falling back
|
|
2118
|
+
// to process.cwd() here would silently resolve the project-local state
|
|
2119
|
+
// path against wherever the server happened to boot, not the project the
|
|
2120
|
+
// user is actually working in. An explicit `cwd` plugin option (mainly for
|
|
2121
|
+
// tests) still takes precedence.
|
|
1959
2122
|
const persistenceOptions = normalizePersistenceOptions(pluginOptions, {
|
|
1960
2123
|
env: pluginOptions.env,
|
|
1961
|
-
cwd: pluginOptions.cwd,
|
|
2124
|
+
cwd: pluginOptions.cwd || directory,
|
|
1962
2125
|
})
|
|
1963
2126
|
const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
|
|
1964
|
-
|
|
2127
|
+
// Serialize all persist() calls through a promise chain so concurrent callers
|
|
2128
|
+
// never race on the temp-file rename. persistState returns a boolean and never
|
|
2129
|
+
// rejects, so the chain cannot stall on a thrown error.
|
|
2130
|
+
let persistChain = Promise.resolve(true)
|
|
2131
|
+
const persist = () => {
|
|
2132
|
+
persistChain = persistChain.then(() => persistState(persistenceOptions, client))
|
|
2133
|
+
return persistChain
|
|
2134
|
+
}
|
|
1965
2135
|
|
|
1966
2136
|
// Fail-closed (item 2.5): when persisting a terminal state (complete/blocked)
|
|
1967
2137
|
// fails, surface it loudly. The terminal event is already in the append-only
|
|
@@ -2023,7 +2193,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2023
2193
|
await persist()
|
|
2024
2194
|
}
|
|
2025
2195
|
|
|
2026
|
-
const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist })
|
|
2196
|
+
const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState, completionAuditor })
|
|
2027
2197
|
|
|
2028
2198
|
const hooks = {
|
|
2029
2199
|
"command.execute.before": async (input, output) => {
|
|
@@ -2076,7 +2246,16 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2076
2246
|
}
|
|
2077
2247
|
|
|
2078
2248
|
if (CLEAR_COMMANDS.has(args)) {
|
|
2249
|
+
// Record the clear in the ledger before cleanupGoal removes the goal
|
|
2250
|
+
// object, so reconstructFromLedger can identify cleared goals and skip
|
|
2251
|
+
// them rather than reconstructing them after a missing state file.
|
|
2252
|
+
// sessionGoals.delete clears ALL backgrounded goals so they do not
|
|
2253
|
+
// resurrect as the focused goal on restart (cleanupGoal only removes the
|
|
2254
|
+
// focused one; background goals from `/goal add` would survive otherwise).
|
|
2255
|
+
const goalBeforeClear = goalStates.get(sessionID)
|
|
2256
|
+
if (goalBeforeClear) pushHistory(goalBeforeClear, "cleared", "User cleared the goal.")
|
|
2079
2257
|
sessionOrdered.delete(sessionID)
|
|
2258
|
+
sessionGoals.delete(sessionID)
|
|
2080
2259
|
cleanupGoal(sessionID)
|
|
2081
2260
|
lastGoalResults.delete(sessionID)
|
|
2082
2261
|
await persist()
|
|
@@ -2112,14 +2291,11 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2112
2291
|
|
|
2113
2292
|
const previousGoalId = goal.goalId
|
|
2114
2293
|
resetGoalBudget(goal)
|
|
2115
|
-
// resetGoalBudget rotates goalId
|
|
2116
|
-
//
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
registerSessionGoal(goal)
|
|
2121
|
-
focusGoal(sessionID, goal)
|
|
2122
|
-
}
|
|
2294
|
+
// resetGoalBudget always rotates goalId via randomUUID; unconditionally
|
|
2295
|
+
// re-key the registry so a later clear/replace removes the right entry.
|
|
2296
|
+
removeSessionGoal(sessionID, previousGoalId)
|
|
2297
|
+
registerSessionGoal(goal)
|
|
2298
|
+
focusGoal(sessionID, goal)
|
|
2123
2299
|
goal.stopped = false
|
|
2124
2300
|
goal.stopReason = ""
|
|
2125
2301
|
goal.blockedReason = ""
|
|
@@ -2156,6 +2332,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2156
2332
|
goal.blockedReason = ""
|
|
2157
2333
|
goal.budgetWrapupSent = false
|
|
2158
2334
|
goal.noProgressTurns = 0
|
|
2335
|
+
goal.noToolCallTurns = 0
|
|
2336
|
+
goal.formatFailures = 0
|
|
2159
2337
|
goal.lastStatus = "Goal objective updated."
|
|
2160
2338
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
|
|
2161
2339
|
await persist()
|
|
@@ -2358,7 +2536,11 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2358
2536
|
|
|
2359
2537
|
// Replace the focused goal (cleanupGoal discards it); backgrounded goals
|
|
2360
2538
|
// for this session are preserved. Use `/goal add` to keep the current
|
|
2361
|
-
// goal and add another.
|
|
2539
|
+
// goal and add another. Clear any ordered-sequence flag so the new
|
|
2540
|
+
// standalone goal does not trigger sisyphus auto-promotion of old sequence
|
|
2541
|
+
// goals that may still be in the registry (matches the agent setGoal path).
|
|
2542
|
+
const replacedGoal = goalStates.get(sessionID)
|
|
2543
|
+
sessionOrdered.delete(sessionID)
|
|
2362
2544
|
cleanupGoal(sessionID)
|
|
2363
2545
|
lastGoalResults.delete(sessionID)
|
|
2364
2546
|
registerSessionGoal(goal)
|
|
@@ -2367,6 +2549,13 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2367
2549
|
output.parts = [
|
|
2368
2550
|
makeTextPart(
|
|
2369
2551
|
[
|
|
2552
|
+
...(replacedGoal
|
|
2553
|
+
? [
|
|
2554
|
+
`⚠️ Replacing active goal: "${replacedGoal.condition}"`,
|
|
2555
|
+
`Use \`/${commandName} add <condition>\` instead to keep it running in the background.`,
|
|
2556
|
+
"",
|
|
2557
|
+
]
|
|
2558
|
+
: []),
|
|
2370
2559
|
`New active goal: ${goal.condition}`,
|
|
2371
2560
|
goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null,
|
|
2372
2561
|
goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null,
|
|
@@ -2398,6 +2587,14 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2398
2587
|
const currentMessageID = messageID(message)
|
|
2399
2588
|
if (!currentMessageID) return
|
|
2400
2589
|
|
|
2590
|
+
// Skip stale re-deliveries from a prior budget window or a replaced goal.
|
|
2591
|
+
// resetGoalBudget and cleanupGoal both leave seenTokens entries in place
|
|
2592
|
+
// so this guard can fire: if an ID is already recorded in seenTokens but
|
|
2593
|
+
// is absent from the current goal.messageIDs, it belongs to a previous
|
|
2594
|
+
// budget epoch or a different goal that was replaced, and the event must
|
|
2595
|
+
// not re-inflate totalTokens.
|
|
2596
|
+
if (seenTokens.has(currentMessageID) && !goal.messageIDs.has(currentMessageID)) return
|
|
2597
|
+
|
|
2401
2598
|
let changed = false
|
|
2402
2599
|
const currentOutputTokens = outputTokensForMessage(message)
|
|
2403
2600
|
const previousOutputTokens = seenOutputTokens.get(currentMessageID) || 0
|
|
@@ -2438,7 +2635,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2438
2635
|
if (!goal || goal.stopped || activeContinues.has(sessionID)) return
|
|
2439
2636
|
const goalID = goal.goalId
|
|
2440
2637
|
|
|
2441
|
-
|
|
2638
|
+
const continueToken = randomUUID()
|
|
2639
|
+
activeContinues.set(sessionID, continueToken)
|
|
2442
2640
|
try {
|
|
2443
2641
|
const messages = await client.session.messages({
|
|
2444
2642
|
path: { id: sessionID },
|
|
@@ -2494,6 +2692,11 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2494
2692
|
sessionID,
|
|
2495
2693
|
`Auditing goal completion: verifying "${summarizeText(activeGoalAfterMessages.condition, 120)}" is satisfied before archiving.`,
|
|
2496
2694
|
)
|
|
2695
|
+
// Re-check liveness: announceAudit is async and can yield long enough
|
|
2696
|
+
// for the user to /goal clear or replace the goal. If it's gone,
|
|
2697
|
+
// bail out without archiving — archiving a cleared goal would resurrect
|
|
2698
|
+
// it in memory and potentially in the persisted state.
|
|
2699
|
+
if (!activeGoal(sessionID, goalID)) return
|
|
2497
2700
|
// Optional independent auditor (item 2.2): an approved verdict
|
|
2498
2701
|
// archives; a rejected verdict restores (pauses) the goal instead.
|
|
2499
2702
|
if (completionAuditor) {
|
|
@@ -2505,7 +2708,18 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2505
2708
|
verdict = { approved: false, reason: "auditor error" }
|
|
2506
2709
|
}
|
|
2507
2710
|
const auditedGoal = activeGoal(sessionID, goalID)
|
|
2508
|
-
if (!auditedGoal)
|
|
2711
|
+
if (!auditedGoal) {
|
|
2712
|
+
// The goal was cleared or replaced while the auditor was running.
|
|
2713
|
+
// If the verdict was approved, surface the loss so the user knows
|
|
2714
|
+
// the completion was verified but not recorded — they can re-engage.
|
|
2715
|
+
if (verdict && verdict.approved === true) {
|
|
2716
|
+
await announceAudit(
|
|
2717
|
+
sessionID,
|
|
2718
|
+
"Audit result: completion was approved but the goal was modified while the audit ran — completion not recorded.",
|
|
2719
|
+
)
|
|
2720
|
+
}
|
|
2721
|
+
return
|
|
2722
|
+
}
|
|
2509
2723
|
if (!verdict || verdict.approved !== true) {
|
|
2510
2724
|
const reason = (verdict && verdict.reason) || "completion not substantiated"
|
|
2511
2725
|
auditedGoal.stopped = true
|
|
@@ -2525,8 +2739,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2525
2739
|
)
|
|
2526
2740
|
}
|
|
2527
2741
|
activeGoalAfterMessages.lastStatus = "Goal completed."
|
|
2528
|
-
// pushHistory
|
|
2529
|
-
//
|
|
2742
|
+
// pushHistory attempts to append the terminal event to the ledger before
|
|
2743
|
+
// the state write below. Note: ledger write failures are silent (bare
|
|
2744
|
+
// catch in emitLedgerEvent), and the ledger only enables recovery when
|
|
2745
|
+
// the state file is absent — a stale state file always takes precedence.
|
|
2530
2746
|
pushHistory(
|
|
2531
2747
|
activeGoalAfterMessages,
|
|
2532
2748
|
"completed",
|
|
@@ -2608,6 +2824,13 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2608
2824
|
// tokens), so it resets noProgressTurns the same way the noToolCall
|
|
2609
2825
|
// gate already resets noToolCallTurns.
|
|
2610
2826
|
const latestHasToolCall = messageHasToolCall(latestAssistant)
|
|
2827
|
+
// A turn that produced only reasoning tokens (no prose, no tool calls)
|
|
2828
|
+
// is an extended-thinking pass, not a stall. latestOutputTokens counts
|
|
2829
|
+
// prose output only; reasoning tokens are tracked separately. Without
|
|
2830
|
+
// this guard a pure-thinking turn matches lowOutputTurn (output=0 < threshold)
|
|
2831
|
+
// and latestText is empty, so it would false-positively look stalled.
|
|
2832
|
+
const latestHasThinkingTokens =
|
|
2833
|
+
toNonNegativeInteger(messageTokens(latestAssistant).reasoning) > 0
|
|
2611
2834
|
|
|
2612
2835
|
const lowOutputTurn =
|
|
2613
2836
|
activeGoalAfterMessages.turnCount > 0 &&
|
|
@@ -2618,13 +2841,25 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2618
2841
|
// real work via tool calls. Excluding tool-call turns prevents false
|
|
2619
2842
|
// noProgress pauses on thinking models.
|
|
2620
2843
|
const lowOutputLooksStalled =
|
|
2621
|
-
lowOutputTurn &&
|
|
2844
|
+
lowOutputTurn &&
|
|
2845
|
+
!latestHasToolCall &&
|
|
2846
|
+
!latestHasThinkingTokens &&
|
|
2847
|
+
(assistantRepeated || !latestText || !assistantChanged)
|
|
2622
2848
|
if (lowOutputLooksStalled) {
|
|
2623
2849
|
activeGoalAfterMessages.noProgressTurns += 1
|
|
2624
2850
|
if (
|
|
2625
2851
|
activeGoalAfterMessages.noProgressTurns >=
|
|
2626
2852
|
activeGoalAfterMessages.options.noProgressTurnsBeforePause
|
|
2627
2853
|
) {
|
|
2854
|
+
// Accumulate format-validation failures even when the stall gate fires
|
|
2855
|
+
// first and returns early, so the formatFailures cap remains reachable
|
|
2856
|
+
// for low-output unverified completions. Without this, a model that
|
|
2857
|
+
// repeatedly emits bare [goal:complete] with low output tokens causes
|
|
2858
|
+
// the stall gate to fire before formatFailures can accumulate, and
|
|
2859
|
+
// /goal resume resets it to zero, making the cap permanently unreachable.
|
|
2860
|
+
if (completionUnverified || blockerUnstated) {
|
|
2861
|
+
activeGoalAfterMessages.formatFailures += 1
|
|
2862
|
+
}
|
|
2628
2863
|
activeGoalAfterMessages.stopped = true
|
|
2629
2864
|
activeGoalAfterMessages.stopReason = "no progress"
|
|
2630
2865
|
activeGoalAfterMessages.lastStatus = `Goal auto-continue paused after ${activeGoalAfterMessages.noProgressTurns} low-progress turn(s); the latest turn produced ${latestOutputTokens} output token(s). Run /${commandName} resume to continue.`
|
|
@@ -2643,7 +2878,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2643
2878
|
"warning",
|
|
2644
2879
|
`Observed a low-progress turn below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens; grace count ${activeGoalAfterMessages.noProgressTurns}/${activeGoalAfterMessages.options.noProgressTurnsBeforePause}.`,
|
|
2645
2880
|
)
|
|
2646
|
-
} else if (latestOutputTokens !== null || assistantChanged) {
|
|
2881
|
+
} else if (latestOutputTokens !== null || assistantChanged || !latestAssistant) {
|
|
2647
2882
|
activeGoalAfterMessages.noProgressTurns = 0
|
|
2648
2883
|
}
|
|
2649
2884
|
|
|
@@ -2653,9 +2888,14 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2653
2888
|
// configured grace window. Complements the low-output check above:
|
|
2654
2889
|
// a turn can be high-output yet still make no real progress because it
|
|
2655
2890
|
// never touched a tool.
|
|
2891
|
+
// Guard on !lowOutputLooksStalled: if the noProgress gate already fired
|
|
2892
|
+
// for this turn, the noToolCall counter must NOT also increment. Without
|
|
2893
|
+
// this guard, the effective grace window is min(noProgress, noToolCall)
|
|
2894
|
+
// rather than two independent limits — the user's higher noProgress
|
|
2895
|
+
// threshold gets silently overridden by the lower noToolCall threshold.
|
|
2656
2896
|
const noToolCallContinuation =
|
|
2657
2897
|
activeGoalAfterMessages.turnCount > 0 && Boolean(latestAssistant) && !latestHasToolCall
|
|
2658
|
-
if (noToolCallContinuation) {
|
|
2898
|
+
if (noToolCallContinuation && !lowOutputLooksStalled) {
|
|
2659
2899
|
activeGoalAfterMessages.noToolCallTurns += 1
|
|
2660
2900
|
if (
|
|
2661
2901
|
activeGoalAfterMessages.noToolCallTurns >=
|
|
@@ -2679,7 +2919,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2679
2919
|
"warning",
|
|
2680
2920
|
`Observed a continuation turn with no tool calls; grace count ${activeGoalAfterMessages.noToolCallTurns}/${activeGoalAfterMessages.options.noToolCallTurnsBeforePause}.`,
|
|
2681
2921
|
)
|
|
2682
|
-
} else if (latestHasToolCall) {
|
|
2922
|
+
} else if (latestHasToolCall || !latestAssistant) {
|
|
2683
2923
|
activeGoalAfterMessages.noToolCallTurns = 0
|
|
2684
2924
|
}
|
|
2685
2925
|
|
|
@@ -2700,6 +2940,11 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2700
2940
|
activeGoalBeforePrompt.stopped = true
|
|
2701
2941
|
activeGoalBeforePrompt.stopReason = "budget wrap-up requested"
|
|
2702
2942
|
activeGoalBeforePrompt.lastStatus = "Budget threshold reached; requested final handoff."
|
|
2943
|
+
// Persist before sending the wrapup prompt so that a crash during
|
|
2944
|
+
// promptAsync doesn't cause a duplicate wrapup on resume. This mirrors
|
|
2945
|
+
// the hard-limit path which also persists before its promptAsync call.
|
|
2946
|
+
pushHistory(activeGoalBeforePrompt, "budget-wrapup", "Budget threshold reached; sending final handoff prompt.")
|
|
2947
|
+
await persist()
|
|
2703
2948
|
}
|
|
2704
2949
|
|
|
2705
2950
|
activeGoalBeforePrompt.turnCount += 1
|
|
@@ -2712,7 +2957,14 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2712
2957
|
activeGoalBeforePrompt.formatFailures += 1
|
|
2713
2958
|
activeGoalBeforePrompt.lastStatus = `Rejected a [goal:blocked] with no concrete blocker; re-prompting on turn ${activeGoalBeforePrompt.turnCount}.`
|
|
2714
2959
|
} else {
|
|
2715
|
-
|
|
2960
|
+
// Decrement rather than reset: an alternating bad/good/bad pattern
|
|
2961
|
+
// should not indefinitely bypass the consecutive-failure cap. A model
|
|
2962
|
+
// that produces one clean turn for every violation keeps formatFailures
|
|
2963
|
+
// pinned near 1, which still accumulates toward the cap over time.
|
|
2964
|
+
activeGoalBeforePrompt.formatFailures = Math.max(
|
|
2965
|
+
0,
|
|
2966
|
+
activeGoalBeforePrompt.formatFailures - 1,
|
|
2967
|
+
)
|
|
2716
2968
|
activeGoalBeforePrompt.lastStatus = latestText
|
|
2717
2969
|
? `Continuing after assistant turn ${activeGoalBeforePrompt.turnCount}.`
|
|
2718
2970
|
: `Continuing after idle event ${activeGoalBeforePrompt.turnCount}.`
|
|
@@ -2769,7 +3021,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2769
3021
|
} else {
|
|
2770
3022
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID)
|
|
2771
3023
|
if (activeGoalAfterPrompt) {
|
|
2772
|
-
|
|
3024
|
+
// Decrement rather than reset: an alternating error/success pattern
|
|
3025
|
+
// should still accumulate toward the circuit-breaker cap over time,
|
|
3026
|
+
// matching the formatFailures approach for the same reason.
|
|
3027
|
+
activeGoalAfterPrompt.promptFailures = Math.max(0, activeGoalAfterPrompt.promptFailures - 1)
|
|
2773
3028
|
pushHistory(
|
|
2774
3029
|
activeGoalAfterPrompt,
|
|
2775
3030
|
budgetWrapup ? "budget-wrapup" : "auto-continue",
|
|
@@ -2796,7 +3051,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2796
3051
|
}
|
|
2797
3052
|
await logPluginError(client, "Auto-continue failed", error)
|
|
2798
3053
|
} finally {
|
|
2799
|
-
|
|
3054
|
+
// Only delete our own entry. If cleanupGoal already removed it (because
|
|
3055
|
+
// the goal completed) and a new handler has since set a fresh token,
|
|
3056
|
+
// we must not clobber the new handler's guard.
|
|
3057
|
+
if (activeContinues.get(sessionID) === continueToken) activeContinues.delete(sessionID)
|
|
2800
3058
|
}
|
|
2801
3059
|
},
|
|
2802
3060
|
|
|
@@ -2849,6 +3107,18 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2849
3107
|
} else {
|
|
2850
3108
|
output.context = [context]
|
|
2851
3109
|
}
|
|
3110
|
+
// Reset the token high-water mark so the remaining budget reflects the
|
|
3111
|
+
// compacted context size, not the pre-compaction peak. Without this,
|
|
3112
|
+
// Math.max semantics mean totalTokens never decreases: a goal that crossed
|
|
3113
|
+
// the 80% wrapup threshold before compaction would permanently stay above it
|
|
3114
|
+
// even after the context shrinks to a fraction of its prior size.
|
|
3115
|
+
// Move current message IDs to priorMessageIDs so the message.updated guard
|
|
3116
|
+
// ignores stale events for pre-compaction messages.
|
|
3117
|
+
if (!goal.priorMessageIDs) goal.priorMessageIDs = new Set()
|
|
3118
|
+
for (const id of goal.messageIDs) goal.priorMessageIDs.add(id)
|
|
3119
|
+
goal.messageIDs = new Set()
|
|
3120
|
+
goal.totalTokens = 0
|
|
3121
|
+
await persist()
|
|
2852
3122
|
},
|
|
2853
3123
|
|
|
2854
3124
|
"experimental.compaction.autocontinue": async (input, output) => {
|