opencode-goal-plugin 0.4.0 → 0.4.7
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 +78 -0
- package/README.md +13 -0
- package/package.json +1 -1
- package/src/goal-plugin.js +353 -58
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,84 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.4.7 — 2026-06-29
|
|
6
|
+
|
|
7
|
+
### Bug fixes (low-severity cleanups)
|
|
8
|
+
|
|
9
|
+
- **Dead `if (goal.goalId !== previousGoalId)` conditional removed from both resume paths.** `resetGoalBudget` always rotates `goalId` via `randomUUID()`, so the conditional was always `true`. The misleading branch could never be taken, masking the intent (unconditional registry re-key on resume). Both the agent-tool `updateGoal {status: "resumed"}` path and the `/goal resume` command path are now unconditional.
|
|
10
|
+
- **`noToolCallTurns` no longer stales on null-assistant idles.** When `messages()` returns no assistant message (only a user turn), `latestAssistant` is `null` and `latestHasToolCall` is `false`. Previously the counter incremented unconditionally; a user-only idle turn could push the goal toward a no-tool-call pause even though the model hadn't spoken. The reset condition now includes `|| !latestAssistant`, matching the intent of the stall detector.
|
|
11
|
+
- **`noProgressTurns` no longer stales on null-assistant idles.** Same scenario as above: when `latestOutputTokens === null` and there is no assistant message, the counter now resets instead of incrementing, consistent with the gate's purpose of detecting stalled model output.
|
|
12
|
+
- **Updated ledger-durability comment near `pushHistory("completed")`.** The previous comment implied the ledger was the primary recovery mechanism. The corrected comment clarifies that ledger write failures are silent (bare `catch`), and that a present state file always takes precedence over the ledger — making the ledger relevant only when the state file is absent.
|
|
13
|
+
- **`buildAgentToolHandlers` accepts a `persistTerminalState` option.** Terminal state transitions (`status='complete'` and `clearGoal`) now call `persistTerminalState` if provided, falling back to the regular `persist` function. The `GoalPlugin` factory passes its own `persistTerminalState` closure through, so agent-triggered completions and clears get the same durable flush semantics as the event-handler paths.
|
|
14
|
+
|
|
15
|
+
## 0.4.6 — 2026-06-29
|
|
16
|
+
|
|
17
|
+
### Bug fixes (counters, compaction, and auditor)
|
|
18
|
+
|
|
19
|
+
- **`noToolCallTurns` is now independent of `noProgressTurns`.** On a turn that qualifies for the noProgress stall gate (low output, no tool call, stalled text), the noToolCall counter no longer also increments. Without this guard, the effective grace window was `min(noProgress, noToolCall)` rather than two independent limits — a configured higher `noProgressTurnsBeforePause` threshold was silently overridden by the lower `noToolCallTurnsBeforePause`.
|
|
20
|
+
- **`formatFailures` is now incremented when the stall gate fires and returns early.** Stall detection previously returned before the format-failure accumulator could run. A model that repeatedly emitted bare `[goal:complete]` with low output triggered the stall gate rather than accumulating toward the `maxPromptFailures` cap; the cap was permanently unreachable because `/goal resume` reset `formatFailures` to zero each time. The counter now increments inside the stall-gate early-return path when `completionUnverified` or `blockerUnstated` is true.
|
|
21
|
+
- **Budget-wrapup state is persisted before the wrapup prompt is sent.** Previously `budgetWrapupSent = true` and `stopped = true` were set in memory but not persisted before `promptAsync`. A crash during the prompt would result in `budgetWrapupSent: false` in the state file and a duplicate wrapup on the next resume cycle. The fix adds `pushHistory("budget-wrapup")` + `persist()` before the prompt call, mirroring the hard-limit path.
|
|
22
|
+
- **`TOOL_PART_TYPES` now covers raw provider part type names.** Some OpenCode adapters forward the provider's original message part shape without normalizing to `"tool"`. Added `"tool_use"`, `"function_call"`, and `"tool-call"` to the set so `messageHasToolCall` (and both stall gates) correctly recognize tool-using turns from non-normalized adapters.
|
|
23
|
+
- **Approved completion that is lost while the auditor is in flight now produces an announcement.** If the goal is cleared or replaced while a completion auditor runs, and the auditor returns `approved: true`, the plugin now announces "completion was approved but the goal was modified while the audit ran — completion not recorded." Previously the approved result was silently discarded with no visible trace.
|
|
24
|
+
- **`buildCompactionContext` is now deterministic.** The function previously called `Date.now()` to compute elapsed seconds, so two calls during the same compaction event produced different strings, busting the prefix cache from that byte position. The elapsed time is now derived from `goal.lastContinueAt` (set during each persist cycle), making the output stable and matching the function's own claim of being "reconstructed deterministically from the plugin's persisted goal record."
|
|
25
|
+
|
|
26
|
+
## 0.4.5 — 2026-06-29
|
|
27
|
+
|
|
28
|
+
### Bug fixes (input validation + counter correctness)
|
|
29
|
+
|
|
30
|
+
- **`set_goal` now validates budget arguments and mode.** Previously `set_goal({maxTurns: 0})` silently used the global default; a typo in `mode` silently became `"normal"`. Both now return explicit errors, matching the `/goal` command's validation behavior.
|
|
31
|
+
- **`update_goal` cannot combine an objective update with `status='complete'` in the same call.** The completion would be archived under a condition that was never executed, falsifying the audit trail. The tool now requires two separate calls: first update the objective, then mark complete after the revised work is done.
|
|
32
|
+
- **`update_goal {status: 'resumed'}` on a running goal returns an error.** The slash-command path rejected this; the agent tool path silently reset all budget counters — turnCount, totalTokens, startedAt, etc. — on a goal that never stopped, enabling indefinite budget circumvention. The agent tool now rejects the call when the goal is not stopped.
|
|
33
|
+
- **`/goal edit` and `update_goal` objective updates now reset `formatFailures` to 0.** The edit paths already reset `noProgressTurns` and `noToolCallTurns` but omitted `formatFailures`. A goal with accumulated format-failure violations had less tolerance than a freshly-resumed goal after an objective change.
|
|
34
|
+
- **`/goal <condition>` replace command now clears `sessionOrdered`.** The agent `setGoal` path called `sessionOrdered.delete()` on replacement, but the slash-command path did not. A user replacing a sisyphus sequence with a standalone goal would get unexpected auto-promotion of the sequence's remaining goals after the replacement completed.
|
|
35
|
+
- **`set_goal` and `update_goal` tool result strings now escape XML metacharacters.** The `goal.condition` is stored raw (for use by `buildGoalBlock`/`buildContinueMessage`), but the tool result returned to the model now calls `escapeGoalText` to prevent XML metacharacters from breaking tool-result boundaries in XML-serialized formats.
|
|
36
|
+
- **`promptFailures` decrements by 1 on a successful prompt instead of resetting to 0.** This mirrors the `formatFailures` fix: an alternating error/success pattern previously bypassed the circuit-breaker cap indefinitely. Decrementing allows gradual recovery while still accumulating toward the cap over time.
|
|
37
|
+
|
|
38
|
+
## 0.4.4 — 2026-06-29
|
|
39
|
+
|
|
40
|
+
### Bug fixes (state machine + injection prevention)
|
|
41
|
+
|
|
42
|
+
- **`escapeGoalText` now neutralizes role-like tag openings.** The previous `STRUCTURAL_TAGS` set only covered plugin-defined tags. Tags like `<system>`, `<assistant>`, `<human>`, `<anthropic>`, `<claude>`, `<context>`, `<instructions>`, and `<prompt>` could survive unescaped in compacted system messages, creating second-order injection opportunities where model output captured by `recordCheckpoint` re-appeared as an elevated-privilege block after compaction.
|
|
43
|
+
- **`update_goal` objective update no longer un-stops a stopped goal.** Calling `update_goal({objective: "…"})` previously cleared `goal.stopped` and `goal.stopReason`, silently resurrecting a goal that was audit-rejected, user-paused, or blocked for any reason. Objective updates now preserve the stopped state; only an explicit `status: "resumed"` call resets it.
|
|
44
|
+
- **`/goal clear` and agent `clearGoal` now delete all backgrounded goals.** Previously only the focused goal was removed from the session registry (`cleanupGoal` → `removeSessionGoal`). Background goals added via `/goal add` remained alive and would promote themselves to focused on restart. Both clear paths now call `sessionGoals.delete(sessionID)` first, wiping the entire per-session goal map.
|
|
45
|
+
- **`formatFailures` decrements by 1 on a clean turn instead of resetting to 0.** A reset-to-zero on every non-violation turn allowed an alternating bad/good/bad pattern to bypass the consecutive-failure cap indefinitely. Decrementing by 1 means repeated violations accumulate toward the cap even when interspersed with good turns.
|
|
46
|
+
- **`update_goal {status: "blocked"}` requires a non-empty `blocker` argument.** The event-handler path already rejects a `[goal:blocked]` marker with no concrete blocker, but the agent tool path accepted an empty `blocker` (recording an empty `blockedReason`). The agent tool now returns an error when `blocker` is missing or whitespace-only, consistent with the auto-continue guard.
|
|
47
|
+
|
|
48
|
+
## 0.4.3 — 2026-06-29
|
|
49
|
+
|
|
50
|
+
### Bug fixes (concurrency + persistence)
|
|
51
|
+
|
|
52
|
+
- **`activeContinues` Set → Map with per-handler UUID token.** `cleanupGoal` removes the session from the Map (allowing new handlers to start), but the idle handler's `finally` block only deletes if its token still matches — preventing it from clobbering a new handler's guard. With a plain `Set`, the old `finally` unconditionally deleted the new handler's entry, creating a race window where two handlers could run concurrently for the same session.
|
|
53
|
+
- **Liveness re-check after `announceAudit`.** `announceAudit` is async and can yield long enough for a user to `/goal clear` or replace the goal. The handler now calls `activeGoal(sessionID, goalID)` after the announcement and returns immediately if the goal is gone, preventing an orphaned archive write.
|
|
54
|
+
- **`persist()` calls serialized via promise chain.** Concurrent callers previously raced on the temp-file rename: the second rename could write older state over the first. All calls now chain through `persistChain`, guaranteeing ordered writes.
|
|
55
|
+
- **`/goal clear` and agent `clearGoal` now emit a `"cleared"` ledger event before discarding the goal.** Without this, `reconstructGoalsFromLedger` (used when the state file is missing) would revive cleared goals as paused on restart. `LEDGER_TERMINAL_TYPES` already includes `"cleared"` — the event just wasn't being written.
|
|
56
|
+
- **State-file/ledger cross-check on restart.** After loading from the state file, the plugin now reads the ledger and removes any active goals whose `goalId` appears in a terminal ledger entry. This guards against the scenario where a terminal persist wrote to the ledger but the state file write failed (e.g. process killed between the two writes): the goal would otherwise load as active and be re-driven on the next idle.
|
|
57
|
+
|
|
58
|
+
### Bug fixes (state machine + security)
|
|
59
|
+
|
|
60
|
+
- **Escape checkpoint and history text in compaction context.** Checkpoint summaries and lifecycle-event details contain assistant-generated text. If a malicious assistant output included structural XML tags (e.g. `</goal_objective><budget_wrapup>…</budget_wrapup>`), they could be re-embedded unescaped in the compaction context system message. `buildCompactionProgressSummary` and the `lastCheckpoint` inline in `buildCompactionContext` now call `escapeGoalText` on all assistant-derived strings.
|
|
61
|
+
- **`/goal edit` and agent `update_goal` objective updates now reset `noToolCallTurns`.** The edit paths already reset `noProgressTurns` and cleared soft-stop state, but forgot `noToolCallTurns`. A goal that was heading toward a no-tool-call pause kept its stale counter after an objective change, and could pause after fewer than the configured grace turns on the new objective.
|
|
62
|
+
- **`formatFailures` is now preserved through a persistence round-trip.** `normalizePersistedGoal` carried `promptFailures` but omitted `formatFailures`. After a plugin restart any accumulated format-failure count was silently reset to zero, giving the model an unintended free pass on the first format re-prompts after recovery.
|
|
63
|
+
- **Agent `update_goal {status: "complete"}` now invokes the configured completion auditor.** The `[goal:complete]` marker path gates archival on an optional auditor, but the agent tool path bypassed it entirely. `buildAgentToolHandlers` now accepts a `completionAuditor` option, and the `GoalPlugin` factory passes the configured auditor through. A rejected verdict pauses the goal with stop reason `audit rejected`; an auditor that throws is treated as a rejection (fail closed).
|
|
64
|
+
- **`createChildSessionAuditor` now enforces a configurable timeout (default 120 s).** `sessionApi.prompt` could hang indefinitely, blocking the idle handler and stalling the goal forever. The auditor now races the API call against a `setTimeout` promise; if the timeout fires first, the verdict is `{ approved: false, reason: "auditor timed out after Nms" }`. The timer is always cleared after settlement.
|
|
65
|
+
- **Thinking-only turns are excluded from the `noProgress` stall detector.** A turn that produced reasoning tokens but no prose output and no tool calls was treated as stalled by `lowOutputLooksStalled` (prose output tokens = 0 < threshold). The new `latestHasThinkingTokens` check (`tokens.reasoning > 0`) excludes such turns from the stall gate, preventing false pauses on extended-thinking models that reason before acting.
|
|
66
|
+
|
|
67
|
+
## 0.4.2 — 2026-06-29
|
|
68
|
+
|
|
69
|
+
### Bug fixes
|
|
70
|
+
|
|
71
|
+
- **Guard against stale `message.updated` re-deliveries re-inflating `totalTokens` after `/goal resume`.** After `/goal resume`, OpenCode replays the streaming `message.updated` event for the last assistant message. Previously `resetGoalBudget` deleted those message IDs from `seenTokens`, so the replayed event looked new and added its tokens to the freshly-zeroed counter — incorrectly inflating the resumed goal's token total from the first turn. Fix: `resetGoalBudget` no longer deletes IDs from `seenTokens`. The `message.updated` handler now skips any message whose ID is already in `seenTokens` but is absent from the current `goal.messageIDs` (i.e. belongs to a prior budget epoch), so stale re-deliveries are silently ignored.
|
|
72
|
+
- **Guard against stale `message.updated` re-deliveries inflating a replacement goal's `totalTokens`.** When a goal is replaced via `/goal <new>`, the old goal's `cleanupGoal` path previously deleted its message IDs from `seenTokens`. If OpenCode then re-delivered a streaming event for one of those old IDs (e.g. a buffered duplicate), the new goal object had no record of it, so the guard could not fire and the event inflated the new goal's counter. Fix: `cleanupGoal` also leaves `seenTokens` entries in place. Entries accumulate across the process lifetime (O(turns × messages_per_turn)) and are cleared in bulk by `clearRuntimeState` on teardown.
|
|
73
|
+
- **Reset `totalTokens` to zero after session compaction.** `totalTokens` is tracked with `Math.max` semantics (peak context size), so it never decreases on its own. A goal that crossed the 80 % budget-wrapup threshold before compaction would permanently remain above it even after the context shrank to a fraction of its prior size. Fix: the `experimental.session.compacting` hook now resets `totalTokens = 0` and rotates `messageIDs` into `priorMessageIDs` after injecting the compaction context, then calls `persist()`. Post-compaction turns re-establish the token baseline from scratch.
|
|
74
|
+
|
|
75
|
+
## 0.4.1 — 2026-06-29
|
|
76
|
+
|
|
77
|
+
### Bug fixes
|
|
78
|
+
|
|
79
|
+
- **Fix provider prefix cache invalidation caused by volatile limit warnings in system prompt (#14).** `experimental.chat.system.transform` was calling `buildLimitWarning`, which appends a string containing `Date.now()`-derived `remainingMs` and a per-turn `remainingTokens` counter. Once any warning threshold was crossed (default: 25 000 tokens remaining, ≤ 3 turns left, ≤ 60 s left), the system prompt changed on every provider request — including tool-call sub-requests mid-turn — invalidating the prefix cache from byte 0 each time. On a 200 k-context thinking model consuming 20–30 k reasoning tokens/turn, this triggered O(turns × tool_calls × context_size) cache misses instead of O(1), causing the ~$12/8 min cost spike reported in issue #13. Fix: `buildLimitWarning` is removed from `system.transform`; the system prompt is now byte-stable for the full lifetime of a goal. Limit warnings continue to reach the model on every continuation turn via `buildContinueMessage`, which already included them.
|
|
80
|
+
- **Cap consecutive format-validation re-prompts (#15).** A model that repeatedly omitted `[goal:evidence]` on a `[goal:complete]` marker, or omitted a concrete blocker on `[goal:blocked]`, was re-prompted indefinitely: the existing `promptFailures` counter only incremented on network/protocol errors. Added a separate `formatFailures` counter that increments on each `completionUnverified` or `blockerUnstated` re-prompt and resets on a valid response. After `maxPromptFailures` consecutive format failures the goal pauses with stop reason `format validation failures` and a descriptive status message; `/goal resume` retries.
|
|
81
|
+
- **Exclude tool-calling turns from the noProgress stall detector (#16).** `lowOutputLooksStalled` could fire on a reasoning-heavy model doing a pure tool call (small prose output, reasoning tokens only): `latestText` is empty and `latestOutputTokens` is below the 50-token threshold, matching the stall condition. Added `!latestHasToolCall` to `lowOutputLooksStalled` so a turn that invoked any tool is never counted as stalled regardless of prose output. `latestHasToolCall` is now hoisted above both the `noProgress` and `noToolCall` blocks so both gates share the same computation.
|
|
82
|
+
|
|
5
83
|
## 0.4.0 — 2026-06-21
|
|
6
84
|
|
|
7
85
|
- **Expose agent-facing goal tools (`get_goal`, `get_goal_history`, `set_goal`, `update_goal`, `clear_goal`)** when the host provides `@opencode-ai/plugin` (a new *optional* peer dependency, loaded via a cached dynamic import so the zero-runtime-dependency posture is preserved). `set_goal` is constrained by its description to explicit user requests (so the agent does not set goals on its own); it accepts optional `maxTurns` / `maxTokens` / `maxDurationMs` overrides plus `successCriteria` / `constraints` / `mode`. `update_goal` supports objective edits and `complete` / `blocked` / `paused` / `resumed` transitions (with `evidence` / `blocker`). Tools create, replace, and clear goals through the **multi-goal registry** (the same `buildGoalState` → `registerSessionGoal` → `focusGoal` path the `/goal` command uses), so tool-created goals persist, appear in `/goal list`, and are driven by the idle handler; `complete` archives with evidence and auto-promotes the next goal in an ordered (sisyphus) sequence. Registration is skipped gracefully when the package is absent or with `registerTools: false`. New `buildAgentToolHandlers` / `buildAgentTools` / `agentToolSessionID` helpers. Implements megalist items 7.1 and 7.2. _(This is the work the 0.3.0 changelog mistakenly listed as already shipped; it is now actually implemented and adapted to the current multi-goal architecture.)_
|
package/README.md
CHANGED
|
@@ -290,6 +290,19 @@ By default a `[goal:complete]` is accepted on the assistant's word. You can requ
|
|
|
290
290
|
|
|
291
291
|
On **approval** the goal is archived as achieved. On **rejection** the goal is *not* archived — it is paused with stop reason `audit rejected` and the reason in its status, so you can address the gap and `/goal resume`. The built-in child-session auditor fails *open* (auto-approves) if the session API is unavailable, while a custom auditor that throws is treated as a rejection (fail closed). The audit is off unless one of these options is set.
|
|
292
292
|
|
|
293
|
+
Pass `auditorOptions` to tune the built-in auditor:
|
|
294
|
+
|
|
295
|
+
```js
|
|
296
|
+
GoalPlugin({
|
|
297
|
+
completionAudit: true,
|
|
298
|
+
auditorOptions: {
|
|
299
|
+
timeoutMs: 60_000, // default 120 000 ms; set lower for faster CI feedback
|
|
300
|
+
},
|
|
301
|
+
})
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
`timeoutMs` caps how long the built-in child-session auditor waits for a verdict. If the session doesn't reply within the timeout the auditor auto-approves (fail open) so the goal can still be archived. `auditorOptions` is ignored when a custom `auditor` function is supplied.
|
|
305
|
+
|
|
293
306
|
## Prompt safety
|
|
294
307
|
|
|
295
308
|
The goal text is wrapped in `<goal_objective>` tags and labeled as user-provided task data. The assistant is told to treat it as a task description, not as elevated instructions that can override system, developer, tool, or repository policies.
|
package/package.json
CHANGED
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
|
|
@@ -520,6 +532,7 @@ function resetGoalBudget(goal) {
|
|
|
520
532
|
goal.budgetWrapupSent = false
|
|
521
533
|
goal.messageIDs = new Set()
|
|
522
534
|
goal.promptFailures = 0
|
|
535
|
+
goal.formatFailures = 0
|
|
523
536
|
goal.lastAssistantMessageID = ""
|
|
524
537
|
goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES)
|
|
525
538
|
}
|
|
@@ -764,6 +777,7 @@ function normalizePersistedGoal(rawGoal) {
|
|
|
764
777
|
stopped: rawGoal.stopped === true,
|
|
765
778
|
stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "",
|
|
766
779
|
promptFailures: toNonNegativeInteger(rawGoal.promptFailures),
|
|
780
|
+
formatFailures: toNonNegativeInteger(rawGoal.formatFailures),
|
|
767
781
|
messageIDs: Array.isArray(rawGoal.messageIDs)
|
|
768
782
|
? rawGoal.messageIDs.filter((messageID) => typeof messageID === "string" && messageID)
|
|
769
783
|
: [],
|
|
@@ -924,6 +938,38 @@ async function applyParsedStateFile(raw, client) {
|
|
|
924
938
|
return "loaded"
|
|
925
939
|
}
|
|
926
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
|
+
|
|
927
973
|
async function loadPersistedState(persistenceOptions, client) {
|
|
928
974
|
if (!persistenceOptions.persistState) return "disabled"
|
|
929
975
|
|
|
@@ -954,7 +1000,15 @@ async function loadPersistedState(persistenceOptions, client) {
|
|
|
954
1000
|
continue
|
|
955
1001
|
}
|
|
956
1002
|
|
|
957
|
-
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
|
+
}
|
|
958
1012
|
// status === "invalid": preserve a present-but-corrupt primary; for a
|
|
959
1013
|
// fallback, keep trying the next candidate.
|
|
960
1014
|
if (primary) return "invalid"
|
|
@@ -1178,6 +1232,17 @@ const STRUCTURAL_TAGS = [
|
|
|
1178
1232
|
"next_step",
|
|
1179
1233
|
"completion_audit",
|
|
1180
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",
|
|
1181
1246
|
]
|
|
1182
1247
|
const STRUCTURAL_OPEN_TAG_RE = new RegExp(`<(${STRUCTURAL_TAGS.join("|")})\\b`, "gi")
|
|
1183
1248
|
|
|
@@ -1320,14 +1385,17 @@ function buildCompactionProgressSummary(goal, { maxCheckpoints = 3, maxEvents =
|
|
|
1320
1385
|
if (checkpoints.length) {
|
|
1321
1386
|
lines.push("Recent checkpoints (oldest first):")
|
|
1322
1387
|
for (const checkpoint of checkpoints) {
|
|
1323
|
-
|
|
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))}`)
|
|
1324
1392
|
}
|
|
1325
1393
|
}
|
|
1326
1394
|
const events = Array.isArray(goal.history) ? goal.history.slice(-maxEvents) : []
|
|
1327
1395
|
if (events.length) {
|
|
1328
1396
|
lines.push("Recent lifecycle events (oldest first):")
|
|
1329
1397
|
for (const event of events) {
|
|
1330
|
-
lines.push(`- ${event.type}: ${summarizeText(event.detail, 160)}`)
|
|
1398
|
+
lines.push(`- ${event.type}: ${escapeGoalText(summarizeText(event.detail, 160))}`)
|
|
1331
1399
|
}
|
|
1332
1400
|
}
|
|
1333
1401
|
return lines
|
|
@@ -1338,14 +1406,19 @@ function buildCompactionContext(goal) {
|
|
|
1338
1406
|
// this, a compaction can drop the goal objective and budget state from the
|
|
1339
1407
|
// working context, so the assistant loses the thread mid-run even though the
|
|
1340
1408
|
// plugin still re-injects via system.transform afterward.
|
|
1341
|
-
|
|
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)
|
|
1342
1415
|
return [
|
|
1343
1416
|
"An OpenCode goal is active for this session. Preserve it across compaction.",
|
|
1344
1417
|
"The summary below is reconstructed deterministically from the plugin's persisted goal record, not from chat memory.",
|
|
1345
1418
|
buildGoalBlock(goal),
|
|
1346
1419
|
`Goal status: ${goal.stopped ? goal.stopReason || "stopped" : "active"}.`,
|
|
1347
1420
|
`Auto-continues used: ${goal.turnCount}/${goal.options.maxTurns}. Context tokens: ${goal.totalTokens}/${goal.options.maxTokens}. Elapsed: ${elapsedSeconds}s.`,
|
|
1348
|
-
goal.lastCheckpoint ? `Latest checkpoint: ${goal.lastCheckpoint.summary}` : null,
|
|
1421
|
+
goal.lastCheckpoint ? `Latest checkpoint: ${escapeGoalText(goal.lastCheckpoint.summary)}` : null,
|
|
1349
1422
|
...buildCompactionProgressSummary(goal),
|
|
1350
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.",
|
|
1351
1424
|
]
|
|
@@ -1583,6 +1656,7 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
|
|
|
1583
1656
|
stopped: false,
|
|
1584
1657
|
stopReason: "",
|
|
1585
1658
|
promptFailures: 0,
|
|
1659
|
+
formatFailures: 0,
|
|
1586
1660
|
messageIDs: new Set(),
|
|
1587
1661
|
history: [],
|
|
1588
1662
|
checkpoints: [],
|
|
@@ -1599,7 +1673,11 @@ const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed
|
|
|
1599
1673
|
// result. Goal creation/replacement routes through the multi-goal registry
|
|
1600
1674
|
// (buildGoalState + registerSessionGoal + focusGoal) exactly like the command
|
|
1601
1675
|
// path, so tool-created goals persist and are driven by the idle handler.
|
|
1602
|
-
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
|
|
1603
1681
|
async function getGoal(sessionID) {
|
|
1604
1682
|
const goal = goalStates.get(sessionID)
|
|
1605
1683
|
if (goal) return formatStatus(goal)
|
|
@@ -1636,6 +1714,17 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
|
1636
1714
|
const objective = typeof args.objective === "string" ? args.objective.trim() : ""
|
|
1637
1715
|
if (!objective) return "No objective provided. Pass a non-empty `objective`."
|
|
1638
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
|
+
|
|
1639
1728
|
const options = normalizeOptions({
|
|
1640
1729
|
...defaultGoalOptions,
|
|
1641
1730
|
...(Number.isFinite(args.maxTurns) ? { maxTurns: args.maxTurns } : {}),
|
|
@@ -1662,25 +1751,48 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
|
1662
1751
|
registerSessionGoal(goal)
|
|
1663
1752
|
focusGoal(sessionID, goal)
|
|
1664
1753
|
await persist()
|
|
1665
|
-
|
|
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)}`
|
|
1666
1759
|
}
|
|
1667
1760
|
|
|
1668
1761
|
async function updateGoal(sessionID, args = {}) {
|
|
1669
1762
|
const goal = goalStates.get(sessionID)
|
|
1670
1763
|
if (!goal) return "No active goal to update. Use set_goal first."
|
|
1671
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
|
+
|
|
1672
1780
|
const messages = []
|
|
1673
1781
|
|
|
1674
1782
|
if (typeof args.objective === "string" && args.objective.trim()) {
|
|
1675
1783
|
goal.condition = args.objective.trim()
|
|
1676
|
-
goal.stopped
|
|
1677
|
-
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.
|
|
1678
1788
|
goal.blockedReason = ""
|
|
1679
1789
|
goal.budgetWrapupSent = false
|
|
1680
1790
|
goal.noProgressTurns = 0
|
|
1791
|
+
goal.noToolCallTurns = 0
|
|
1792
|
+
goal.formatFailures = 0
|
|
1681
1793
|
goal.lastStatus = "Goal objective updated."
|
|
1682
1794
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(goal.condition, 400)}`)
|
|
1683
|
-
messages.push(`Objective updated: ${goal.condition}`)
|
|
1795
|
+
messages.push(`Objective updated: ${escapeGoalText(goal.condition)}`)
|
|
1684
1796
|
}
|
|
1685
1797
|
|
|
1686
1798
|
if (args.status !== undefined) {
|
|
@@ -1690,6 +1802,27 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
|
1690
1802
|
}
|
|
1691
1803
|
if (status === "complete") {
|
|
1692
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
|
+
}
|
|
1693
1826
|
goal.lastStatus = "Goal completed."
|
|
1694
1827
|
pushHistory(
|
|
1695
1828
|
goal,
|
|
@@ -1700,15 +1833,18 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
|
1700
1833
|
cleanupGoal(sessionID)
|
|
1701
1834
|
// Advance an ordered (sisyphus) sequence just like the marker path does.
|
|
1702
1835
|
if (sessionOrdered.has(sessionID)) promoteNextOrderedGoal(sessionID)
|
|
1703
|
-
await
|
|
1836
|
+
await persistFinal("completion")
|
|
1704
1837
|
return "Goal marked complete and archived."
|
|
1705
1838
|
}
|
|
1706
1839
|
if (status === "blocked") {
|
|
1707
|
-
|
|
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
|
|
1708
1844
|
goal.stopped = true
|
|
1709
1845
|
goal.stopReason = "blocked"
|
|
1710
1846
|
goal.lastStatus = "Assistant reported blocked."
|
|
1711
|
-
pushHistory(goal, "blocked", goal.blockedReason
|
|
1847
|
+
pushHistory(goal, "blocked", goal.blockedReason)
|
|
1712
1848
|
messages.push("Goal marked blocked.")
|
|
1713
1849
|
} else if (status === "paused") {
|
|
1714
1850
|
goal.stopped = true
|
|
@@ -1717,15 +1853,15 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
|
1717
1853
|
pushHistory(goal, "paused", "Paused via agent tool.")
|
|
1718
1854
|
messages.push("Goal paused.")
|
|
1719
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."
|
|
1720
1858
|
const previousGoalId = goal.goalId
|
|
1721
1859
|
resetGoalBudget(goal)
|
|
1722
|
-
// resetGoalBudget rotates goalId
|
|
1723
|
-
//
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
focusGoal(sessionID, goal)
|
|
1728
|
-
}
|
|
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)
|
|
1729
1865
|
goal.stopped = false
|
|
1730
1866
|
goal.stopReason = ""
|
|
1731
1867
|
goal.blockedReason = ""
|
|
@@ -1743,11 +1879,17 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist }) {
|
|
|
1743
1879
|
}
|
|
1744
1880
|
|
|
1745
1881
|
async function clearGoal(sessionID) {
|
|
1746
|
-
// 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.")
|
|
1747
1888
|
sessionOrdered.delete(sessionID)
|
|
1889
|
+
sessionGoals.delete(sessionID)
|
|
1748
1890
|
cleanupGoal(sessionID)
|
|
1749
1891
|
lastGoalResults.delete(sessionID)
|
|
1750
|
-
await
|
|
1892
|
+
await persistFinal("clear")
|
|
1751
1893
|
return "Goal cleared."
|
|
1752
1894
|
}
|
|
1753
1895
|
|
|
@@ -1923,9 +2065,9 @@ function extractAuditVerdictText(response) {
|
|
|
1923
2065
|
// so a missing/broken auditor pipeline never blocks legitimate completions.
|
|
1924
2066
|
// NOTE: the exact child-session SDK shape should be confirmed against a live
|
|
1925
2067
|
// OpenCode; the orchestration around it is what the tests cover.
|
|
1926
|
-
function createChildSessionAuditor(client, { agent = "build" } = {}) {
|
|
2068
|
+
function createChildSessionAuditor(client, { agent = "build", timeoutMs = 120_000 } = {}) {
|
|
1927
2069
|
return async ({ goal, sessionID, latestText }) => {
|
|
1928
|
-
|
|
2070
|
+
const run = async () => {
|
|
1929
2071
|
const sessionApi = client?.session
|
|
1930
2072
|
if (!sessionApi?.create || !sessionApi?.prompt) {
|
|
1931
2073
|
return { approved: true, reason: "child-session API unavailable; auto-approved" }
|
|
@@ -1946,8 +2088,23 @@ function createChildSessionAuditor(client, { agent = "build" } = {}) {
|
|
|
1946
2088
|
verdictText = getText(findLatestAssistantMessage(messages?.data)?.parts)
|
|
1947
2089
|
}
|
|
1948
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
|
|
1949
2104
|
} catch (error) {
|
|
1950
2105
|
return { approved: true, reason: `auditor error (auto-approved): ${error?.message || error}` }
|
|
2106
|
+
} finally {
|
|
2107
|
+
clearTimeout(timerID)
|
|
1951
2108
|
}
|
|
1952
2109
|
}
|
|
1953
2110
|
}
|
|
@@ -1959,7 +2116,14 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
1959
2116
|
cwd: pluginOptions.cwd,
|
|
1960
2117
|
})
|
|
1961
2118
|
const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
|
|
1962
|
-
|
|
2119
|
+
// Serialize all persist() calls through a promise chain so concurrent callers
|
|
2120
|
+
// never race on the temp-file rename. persistState returns a boolean and never
|
|
2121
|
+
// rejects, so the chain cannot stall on a thrown error.
|
|
2122
|
+
let persistChain = Promise.resolve(true)
|
|
2123
|
+
const persist = () => {
|
|
2124
|
+
persistChain = persistChain.then(() => persistState(persistenceOptions, client))
|
|
2125
|
+
return persistChain
|
|
2126
|
+
}
|
|
1963
2127
|
|
|
1964
2128
|
// Fail-closed (item 2.5): when persisting a terminal state (complete/blocked)
|
|
1965
2129
|
// fails, surface it loudly. The terminal event is already in the append-only
|
|
@@ -2021,7 +2185,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2021
2185
|
await persist()
|
|
2022
2186
|
}
|
|
2023
2187
|
|
|
2024
|
-
const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist })
|
|
2188
|
+
const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState, completionAuditor })
|
|
2025
2189
|
|
|
2026
2190
|
const hooks = {
|
|
2027
2191
|
"command.execute.before": async (input, output) => {
|
|
@@ -2074,7 +2238,16 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2074
2238
|
}
|
|
2075
2239
|
|
|
2076
2240
|
if (CLEAR_COMMANDS.has(args)) {
|
|
2241
|
+
// Record the clear in the ledger before cleanupGoal removes the goal
|
|
2242
|
+
// object, so reconstructFromLedger can identify cleared goals and skip
|
|
2243
|
+
// them rather than reconstructing them after a missing state file.
|
|
2244
|
+
// sessionGoals.delete clears ALL backgrounded goals so they do not
|
|
2245
|
+
// resurrect as the focused goal on restart (cleanupGoal only removes the
|
|
2246
|
+
// focused one; background goals from `/goal add` would survive otherwise).
|
|
2247
|
+
const goalBeforeClear = goalStates.get(sessionID)
|
|
2248
|
+
if (goalBeforeClear) pushHistory(goalBeforeClear, "cleared", "User cleared the goal.")
|
|
2077
2249
|
sessionOrdered.delete(sessionID)
|
|
2250
|
+
sessionGoals.delete(sessionID)
|
|
2078
2251
|
cleanupGoal(sessionID)
|
|
2079
2252
|
lastGoalResults.delete(sessionID)
|
|
2080
2253
|
await persist()
|
|
@@ -2110,14 +2283,11 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2110
2283
|
|
|
2111
2284
|
const previousGoalId = goal.goalId
|
|
2112
2285
|
resetGoalBudget(goal)
|
|
2113
|
-
// resetGoalBudget rotates goalId
|
|
2114
|
-
//
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
registerSessionGoal(goal)
|
|
2119
|
-
focusGoal(sessionID, goal)
|
|
2120
|
-
}
|
|
2286
|
+
// resetGoalBudget always rotates goalId via randomUUID; unconditionally
|
|
2287
|
+
// re-key the registry so a later clear/replace removes the right entry.
|
|
2288
|
+
removeSessionGoal(sessionID, previousGoalId)
|
|
2289
|
+
registerSessionGoal(goal)
|
|
2290
|
+
focusGoal(sessionID, goal)
|
|
2121
2291
|
goal.stopped = false
|
|
2122
2292
|
goal.stopReason = ""
|
|
2123
2293
|
goal.blockedReason = ""
|
|
@@ -2154,6 +2324,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2154
2324
|
goal.blockedReason = ""
|
|
2155
2325
|
goal.budgetWrapupSent = false
|
|
2156
2326
|
goal.noProgressTurns = 0
|
|
2327
|
+
goal.noToolCallTurns = 0
|
|
2328
|
+
goal.formatFailures = 0
|
|
2157
2329
|
goal.lastStatus = "Goal objective updated."
|
|
2158
2330
|
pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`)
|
|
2159
2331
|
await persist()
|
|
@@ -2356,7 +2528,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2356
2528
|
|
|
2357
2529
|
// Replace the focused goal (cleanupGoal discards it); backgrounded goals
|
|
2358
2530
|
// for this session are preserved. Use `/goal add` to keep the current
|
|
2359
|
-
// goal and add another.
|
|
2531
|
+
// goal and add another. Clear any ordered-sequence flag so the new
|
|
2532
|
+
// standalone goal does not trigger sisyphus auto-promotion of old sequence
|
|
2533
|
+
// goals that may still be in the registry (matches the agent setGoal path).
|
|
2534
|
+
sessionOrdered.delete(sessionID)
|
|
2360
2535
|
cleanupGoal(sessionID)
|
|
2361
2536
|
lastGoalResults.delete(sessionID)
|
|
2362
2537
|
registerSessionGoal(goal)
|
|
@@ -2396,6 +2571,14 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2396
2571
|
const currentMessageID = messageID(message)
|
|
2397
2572
|
if (!currentMessageID) return
|
|
2398
2573
|
|
|
2574
|
+
// Skip stale re-deliveries from a prior budget window or a replaced goal.
|
|
2575
|
+
// resetGoalBudget and cleanupGoal both leave seenTokens entries in place
|
|
2576
|
+
// so this guard can fire: if an ID is already recorded in seenTokens but
|
|
2577
|
+
// is absent from the current goal.messageIDs, it belongs to a previous
|
|
2578
|
+
// budget epoch or a different goal that was replaced, and the event must
|
|
2579
|
+
// not re-inflate totalTokens.
|
|
2580
|
+
if (seenTokens.has(currentMessageID) && !goal.messageIDs.has(currentMessageID)) return
|
|
2581
|
+
|
|
2399
2582
|
let changed = false
|
|
2400
2583
|
const currentOutputTokens = outputTokensForMessage(message)
|
|
2401
2584
|
const previousOutputTokens = seenOutputTokens.get(currentMessageID) || 0
|
|
@@ -2436,7 +2619,8 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2436
2619
|
if (!goal || goal.stopped || activeContinues.has(sessionID)) return
|
|
2437
2620
|
const goalID = goal.goalId
|
|
2438
2621
|
|
|
2439
|
-
|
|
2622
|
+
const continueToken = randomUUID()
|
|
2623
|
+
activeContinues.set(sessionID, continueToken)
|
|
2440
2624
|
try {
|
|
2441
2625
|
const messages = await client.session.messages({
|
|
2442
2626
|
path: { id: sessionID },
|
|
@@ -2492,6 +2676,11 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2492
2676
|
sessionID,
|
|
2493
2677
|
`Auditing goal completion: verifying "${summarizeText(activeGoalAfterMessages.condition, 120)}" is satisfied before archiving.`,
|
|
2494
2678
|
)
|
|
2679
|
+
// Re-check liveness: announceAudit is async and can yield long enough
|
|
2680
|
+
// for the user to /goal clear or replace the goal. If it's gone,
|
|
2681
|
+
// bail out without archiving — archiving a cleared goal would resurrect
|
|
2682
|
+
// it in memory and potentially in the persisted state.
|
|
2683
|
+
if (!activeGoal(sessionID, goalID)) return
|
|
2495
2684
|
// Optional independent auditor (item 2.2): an approved verdict
|
|
2496
2685
|
// archives; a rejected verdict restores (pauses) the goal instead.
|
|
2497
2686
|
if (completionAuditor) {
|
|
@@ -2503,7 +2692,18 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2503
2692
|
verdict = { approved: false, reason: "auditor error" }
|
|
2504
2693
|
}
|
|
2505
2694
|
const auditedGoal = activeGoal(sessionID, goalID)
|
|
2506
|
-
if (!auditedGoal)
|
|
2695
|
+
if (!auditedGoal) {
|
|
2696
|
+
// The goal was cleared or replaced while the auditor was running.
|
|
2697
|
+
// If the verdict was approved, surface the loss so the user knows
|
|
2698
|
+
// the completion was verified but not recorded — they can re-engage.
|
|
2699
|
+
if (verdict && verdict.approved === true) {
|
|
2700
|
+
await announceAudit(
|
|
2701
|
+
sessionID,
|
|
2702
|
+
"Audit result: completion was approved but the goal was modified while the audit ran — completion not recorded.",
|
|
2703
|
+
)
|
|
2704
|
+
}
|
|
2705
|
+
return
|
|
2706
|
+
}
|
|
2507
2707
|
if (!verdict || verdict.approved !== true) {
|
|
2508
2708
|
const reason = (verdict && verdict.reason) || "completion not substantiated"
|
|
2509
2709
|
auditedGoal.stopped = true
|
|
@@ -2523,8 +2723,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2523
2723
|
)
|
|
2524
2724
|
}
|
|
2525
2725
|
activeGoalAfterMessages.lastStatus = "Goal completed."
|
|
2526
|
-
// pushHistory
|
|
2527
|
-
//
|
|
2726
|
+
// pushHistory attempts to append the terminal event to the ledger before
|
|
2727
|
+
// the state write below. Note: ledger write failures are silent (bare
|
|
2728
|
+
// catch in emitLedgerEvent), and the ledger only enables recovery when
|
|
2729
|
+
// the state file is absent — a stale state file always takes precedence.
|
|
2528
2730
|
pushHistory(
|
|
2529
2731
|
activeGoalAfterMessages,
|
|
2530
2732
|
"completed",
|
|
@@ -2600,18 +2802,48 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2600
2802
|
return
|
|
2601
2803
|
}
|
|
2602
2804
|
|
|
2805
|
+
// Hoist tool-call check so both the noProgress and noToolCall gates can
|
|
2806
|
+
// use it. A tool call is evidence of real work even when prose output
|
|
2807
|
+
// is tiny (e.g. a thinking model that calls a tool with < 50 output
|
|
2808
|
+
// tokens), so it resets noProgressTurns the same way the noToolCall
|
|
2809
|
+
// gate already resets noToolCallTurns.
|
|
2810
|
+
const latestHasToolCall = messageHasToolCall(latestAssistant)
|
|
2811
|
+
// A turn that produced only reasoning tokens (no prose, no tool calls)
|
|
2812
|
+
// is an extended-thinking pass, not a stall. latestOutputTokens counts
|
|
2813
|
+
// prose output only; reasoning tokens are tracked separately. Without
|
|
2814
|
+
// this guard a pure-thinking turn matches lowOutputTurn (output=0 < threshold)
|
|
2815
|
+
// and latestText is empty, so it would false-positively look stalled.
|
|
2816
|
+
const latestHasThinkingTokens =
|
|
2817
|
+
toNonNegativeInteger(messageTokens(latestAssistant).reasoning) > 0
|
|
2818
|
+
|
|
2603
2819
|
const lowOutputTurn =
|
|
2604
2820
|
activeGoalAfterMessages.turnCount > 0 &&
|
|
2605
2821
|
latestOutputTokens !== null &&
|
|
2606
2822
|
latestOutputTokens < activeGoalAfterMessages.options.noProgressTokenThreshold
|
|
2823
|
+
// A turn that used a tool is never stalled even with low output tokens:
|
|
2824
|
+
// reasoning-heavy models often produce small prose output while doing
|
|
2825
|
+
// real work via tool calls. Excluding tool-call turns prevents false
|
|
2826
|
+
// noProgress pauses on thinking models.
|
|
2607
2827
|
const lowOutputLooksStalled =
|
|
2608
|
-
lowOutputTurn &&
|
|
2828
|
+
lowOutputTurn &&
|
|
2829
|
+
!latestHasToolCall &&
|
|
2830
|
+
!latestHasThinkingTokens &&
|
|
2831
|
+
(assistantRepeated || !latestText || !assistantChanged)
|
|
2609
2832
|
if (lowOutputLooksStalled) {
|
|
2610
2833
|
activeGoalAfterMessages.noProgressTurns += 1
|
|
2611
2834
|
if (
|
|
2612
2835
|
activeGoalAfterMessages.noProgressTurns >=
|
|
2613
2836
|
activeGoalAfterMessages.options.noProgressTurnsBeforePause
|
|
2614
2837
|
) {
|
|
2838
|
+
// Accumulate format-validation failures even when the stall gate fires
|
|
2839
|
+
// first and returns early, so the formatFailures cap remains reachable
|
|
2840
|
+
// for low-output unverified completions. Without this, a model that
|
|
2841
|
+
// repeatedly emits bare [goal:complete] with low output tokens causes
|
|
2842
|
+
// the stall gate to fire before formatFailures can accumulate, and
|
|
2843
|
+
// /goal resume resets it to zero, making the cap permanently unreachable.
|
|
2844
|
+
if (completionUnverified || blockerUnstated) {
|
|
2845
|
+
activeGoalAfterMessages.formatFailures += 1
|
|
2846
|
+
}
|
|
2615
2847
|
activeGoalAfterMessages.stopped = true
|
|
2616
2848
|
activeGoalAfterMessages.stopReason = "no progress"
|
|
2617
2849
|
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.`
|
|
@@ -2630,7 +2862,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2630
2862
|
"warning",
|
|
2631
2863
|
`Observed a low-progress turn below ${activeGoalAfterMessages.options.noProgressTokenThreshold} output tokens; grace count ${activeGoalAfterMessages.noProgressTurns}/${activeGoalAfterMessages.options.noProgressTurnsBeforePause}.`,
|
|
2632
2864
|
)
|
|
2633
|
-
} else if (latestOutputTokens !== null || assistantChanged) {
|
|
2865
|
+
} else if (latestOutputTokens !== null || assistantChanged || !latestAssistant) {
|
|
2634
2866
|
activeGoalAfterMessages.noProgressTurns = 0
|
|
2635
2867
|
}
|
|
2636
2868
|
|
|
@@ -2640,10 +2872,14 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2640
2872
|
// configured grace window. Complements the low-output check above:
|
|
2641
2873
|
// a turn can be high-output yet still make no real progress because it
|
|
2642
2874
|
// never touched a tool.
|
|
2643
|
-
|
|
2875
|
+
// Guard on !lowOutputLooksStalled: if the noProgress gate already fired
|
|
2876
|
+
// for this turn, the noToolCall counter must NOT also increment. Without
|
|
2877
|
+
// this guard, the effective grace window is min(noProgress, noToolCall)
|
|
2878
|
+
// rather than two independent limits — the user's higher noProgress
|
|
2879
|
+
// threshold gets silently overridden by the lower noToolCall threshold.
|
|
2644
2880
|
const noToolCallContinuation =
|
|
2645
2881
|
activeGoalAfterMessages.turnCount > 0 && Boolean(latestAssistant) && !latestHasToolCall
|
|
2646
|
-
if (noToolCallContinuation) {
|
|
2882
|
+
if (noToolCallContinuation && !lowOutputLooksStalled) {
|
|
2647
2883
|
activeGoalAfterMessages.noToolCallTurns += 1
|
|
2648
2884
|
if (
|
|
2649
2885
|
activeGoalAfterMessages.noToolCallTurns >=
|
|
@@ -2667,7 +2903,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2667
2903
|
"warning",
|
|
2668
2904
|
`Observed a continuation turn with no tool calls; grace count ${activeGoalAfterMessages.noToolCallTurns}/${activeGoalAfterMessages.options.noToolCallTurnsBeforePause}.`,
|
|
2669
2905
|
)
|
|
2670
|
-
} else if (latestHasToolCall) {
|
|
2906
|
+
} else if (latestHasToolCall || !latestAssistant) {
|
|
2671
2907
|
activeGoalAfterMessages.noToolCallTurns = 0
|
|
2672
2908
|
}
|
|
2673
2909
|
|
|
@@ -2688,20 +2924,53 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2688
2924
|
activeGoalBeforePrompt.stopped = true
|
|
2689
2925
|
activeGoalBeforePrompt.stopReason = "budget wrap-up requested"
|
|
2690
2926
|
activeGoalBeforePrompt.lastStatus = "Budget threshold reached; requested final handoff."
|
|
2927
|
+
// Persist before sending the wrapup prompt so that a crash during
|
|
2928
|
+
// promptAsync doesn't cause a duplicate wrapup on resume. This mirrors
|
|
2929
|
+
// the hard-limit path which also persists before its promptAsync call.
|
|
2930
|
+
pushHistory(activeGoalBeforePrompt, "budget-wrapup", "Budget threshold reached; sending final handoff prompt.")
|
|
2931
|
+
await persist()
|
|
2691
2932
|
}
|
|
2692
2933
|
|
|
2693
2934
|
activeGoalBeforePrompt.turnCount += 1
|
|
2694
2935
|
activeGoalBeforePrompt.lastContinueAt = Date.now()
|
|
2695
2936
|
if (!budgetWrapup) {
|
|
2696
2937
|
if (completionUnverified) {
|
|
2938
|
+
activeGoalBeforePrompt.formatFailures += 1
|
|
2697
2939
|
activeGoalBeforePrompt.lastStatus = `Rejected an unverified [goal:complete] (no [goal:evidence]); re-prompting for evidence on turn ${activeGoalBeforePrompt.turnCount}.`
|
|
2698
2940
|
} else if (blockerUnstated) {
|
|
2941
|
+
activeGoalBeforePrompt.formatFailures += 1
|
|
2699
2942
|
activeGoalBeforePrompt.lastStatus = `Rejected a [goal:blocked] with no concrete blocker; re-prompting on turn ${activeGoalBeforePrompt.turnCount}.`
|
|
2700
2943
|
} else {
|
|
2944
|
+
// Decrement rather than reset: an alternating bad/good/bad pattern
|
|
2945
|
+
// should not indefinitely bypass the consecutive-failure cap. A model
|
|
2946
|
+
// that produces one clean turn for every violation keeps formatFailures
|
|
2947
|
+
// pinned near 1, which still accumulates toward the cap over time.
|
|
2948
|
+
activeGoalBeforePrompt.formatFailures = Math.max(
|
|
2949
|
+
0,
|
|
2950
|
+
activeGoalBeforePrompt.formatFailures - 1,
|
|
2951
|
+
)
|
|
2701
2952
|
activeGoalBeforePrompt.lastStatus = latestText
|
|
2702
2953
|
? `Continuing after assistant turn ${activeGoalBeforePrompt.turnCount}.`
|
|
2703
2954
|
: `Continuing after idle event ${activeGoalBeforePrompt.turnCount}.`
|
|
2704
2955
|
}
|
|
2956
|
+
|
|
2957
|
+
// Pause after too many consecutive format-validation failures. Unlike
|
|
2958
|
+
// promptFailures (which counts network/protocol errors), this counts turns
|
|
2959
|
+
// where the model signalled completion or a blocker but omitted the required
|
|
2960
|
+
// evidence or concrete-blocker line. The same maxPromptFailures cap applies;
|
|
2961
|
+
// resume resets the counter via resetGoalBudget.
|
|
2962
|
+
if (activeGoalBeforePrompt.formatFailures >= activeGoalBeforePrompt.options.maxPromptFailures) {
|
|
2963
|
+
activeGoalBeforePrompt.stopped = true
|
|
2964
|
+
activeGoalBeforePrompt.stopReason = "format validation failures"
|
|
2965
|
+
activeGoalBeforePrompt.lastStatus = `Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s) (missing [goal:evidence] or concrete blocker). Run /${commandName} resume to retry.`
|
|
2966
|
+
pushHistory(
|
|
2967
|
+
activeGoalBeforePrompt,
|
|
2968
|
+
"paused",
|
|
2969
|
+
`Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s).`,
|
|
2970
|
+
)
|
|
2971
|
+
await persist()
|
|
2972
|
+
return
|
|
2973
|
+
}
|
|
2705
2974
|
}
|
|
2706
2975
|
|
|
2707
2976
|
const response = await client.session.promptAsync({
|
|
@@ -2736,7 +3005,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2736
3005
|
} else {
|
|
2737
3006
|
const activeGoalAfterPrompt = currentGoal(sessionID, goalID)
|
|
2738
3007
|
if (activeGoalAfterPrompt) {
|
|
2739
|
-
|
|
3008
|
+
// Decrement rather than reset: an alternating error/success pattern
|
|
3009
|
+
// should still accumulate toward the circuit-breaker cap over time,
|
|
3010
|
+
// matching the formatFailures approach for the same reason.
|
|
3011
|
+
activeGoalAfterPrompt.promptFailures = Math.max(0, activeGoalAfterPrompt.promptFailures - 1)
|
|
2740
3012
|
pushHistory(
|
|
2741
3013
|
activeGoalAfterPrompt,
|
|
2742
3014
|
budgetWrapup ? "budget-wrapup" : "auto-continue",
|
|
@@ -2763,7 +3035,10 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2763
3035
|
}
|
|
2764
3036
|
await logPluginError(client, "Auto-continue failed", error)
|
|
2765
3037
|
} finally {
|
|
2766
|
-
|
|
3038
|
+
// Only delete our own entry. If cleanupGoal already removed it (because
|
|
3039
|
+
// the goal completed) and a new handler has since set a fresh token,
|
|
3040
|
+
// we must not clobber the new handler's guard.
|
|
3041
|
+
if (activeContinues.get(sessionID) === continueToken) activeContinues.delete(sessionID)
|
|
2767
3042
|
}
|
|
2768
3043
|
},
|
|
2769
3044
|
|
|
@@ -2776,13 +3051,21 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2776
3051
|
const systemBlocks = Array.isArray(output.system) ? [...output.system] : []
|
|
2777
3052
|
if (systemBlocks.some(systemBlockContainsGoal)) return
|
|
2778
3053
|
|
|
3054
|
+
// Only static content here — volatile fields (limit warnings, turn counters,
|
|
3055
|
+
// token counts, wall-clock values) must not appear in the system prompt.
|
|
3056
|
+
// system.transform fires on every provider request including tool-call
|
|
3057
|
+
// sub-requests; any per-turn drift in the system prompt invalidates the
|
|
3058
|
+
// provider-side prefix cache from byte 0, turning O(1) cache hits into
|
|
3059
|
+
// O(N*turns) full-context misses. Limit warnings are already delivered
|
|
3060
|
+
// on every continuation turn via buildContinueMessage (buildLimitWarning
|
|
3061
|
+
// and <progress_budget>), which is sufficient — the model doesn't need
|
|
3062
|
+
// them in the system prompt mid-turn.
|
|
2779
3063
|
const goalBlock = [
|
|
2780
3064
|
buildGoalBlock(goal),
|
|
2781
3065
|
"Keep working until the goal is fully satisfied.",
|
|
2782
3066
|
"When fully satisfied, put a `[goal:evidence]` line summarizing what you verified immediately before `[goal:complete]`. A `[goal:complete]` without evidence is rejected.",
|
|
2783
3067
|
"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.",
|
|
2784
|
-
|
|
2785
|
-
].filter(Boolean).join("\n")
|
|
3068
|
+
].join("\n")
|
|
2786
3069
|
|
|
2787
3070
|
if (systemBlocks.length === 0) {
|
|
2788
3071
|
output.system = [goalBlock]
|
|
@@ -2808,6 +3091,18 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
|
|
|
2808
3091
|
} else {
|
|
2809
3092
|
output.context = [context]
|
|
2810
3093
|
}
|
|
3094
|
+
// Reset the token high-water mark so the remaining budget reflects the
|
|
3095
|
+
// compacted context size, not the pre-compaction peak. Without this,
|
|
3096
|
+
// Math.max semantics mean totalTokens never decreases: a goal that crossed
|
|
3097
|
+
// the 80% wrapup threshold before compaction would permanently stay above it
|
|
3098
|
+
// even after the context shrinks to a fraction of its prior size.
|
|
3099
|
+
// Move current message IDs to priorMessageIDs so the message.updated guard
|
|
3100
|
+
// ignores stale events for pre-compaction messages.
|
|
3101
|
+
if (!goal.priorMessageIDs) goal.priorMessageIDs = new Set()
|
|
3102
|
+
for (const id of goal.messageIDs) goal.priorMessageIDs.add(id)
|
|
3103
|
+
goal.messageIDs = new Set()
|
|
3104
|
+
goal.totalTokens = 0
|
|
3105
|
+
await persist()
|
|
2811
3106
|
},
|
|
2812
3107
|
|
|
2813
3108
|
"experimental.compaction.autocontinue": async (input, output) => {
|