opencode-goal-plugin 0.4.0 → 0.4.1

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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.1 — 2026-06-29
6
+
7
+ ### Bug fixes
8
+
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+
5
13
  ## 0.4.0 — 2026-06-21
6
14
 
7
15
  - **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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Session-scoped /goal workflow for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./src/goal-plugin.js",
@@ -520,6 +520,7 @@ function resetGoalBudget(goal) {
520
520
  goal.budgetWrapupSent = false
521
521
  goal.messageIDs = new Set()
522
522
  goal.promptFailures = 0
523
+ goal.formatFailures = 0
523
524
  goal.lastAssistantMessageID = ""
524
525
  goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES)
525
526
  }
@@ -1583,6 +1584,7 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
1583
1584
  stopped: false,
1584
1585
  stopReason: "",
1585
1586
  promptFailures: 0,
1587
+ formatFailures: 0,
1586
1588
  messageIDs: new Set(),
1587
1589
  history: [],
1588
1590
  checkpoints: [],
@@ -2600,12 +2602,23 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2600
2602
  return
2601
2603
  }
2602
2604
 
2605
+ // Hoist tool-call check so both the noProgress and noToolCall gates can
2606
+ // use it. A tool call is evidence of real work even when prose output
2607
+ // is tiny (e.g. a thinking model that calls a tool with < 50 output
2608
+ // tokens), so it resets noProgressTurns the same way the noToolCall
2609
+ // gate already resets noToolCallTurns.
2610
+ const latestHasToolCall = messageHasToolCall(latestAssistant)
2611
+
2603
2612
  const lowOutputTurn =
2604
2613
  activeGoalAfterMessages.turnCount > 0 &&
2605
2614
  latestOutputTokens !== null &&
2606
2615
  latestOutputTokens < activeGoalAfterMessages.options.noProgressTokenThreshold
2616
+ // A turn that used a tool is never stalled even with low output tokens:
2617
+ // reasoning-heavy models often produce small prose output while doing
2618
+ // real work via tool calls. Excluding tool-call turns prevents false
2619
+ // noProgress pauses on thinking models.
2607
2620
  const lowOutputLooksStalled =
2608
- lowOutputTurn && (assistantRepeated || !latestText || !assistantChanged)
2621
+ lowOutputTurn && !latestHasToolCall && (assistantRepeated || !latestText || !assistantChanged)
2609
2622
  if (lowOutputLooksStalled) {
2610
2623
  activeGoalAfterMessages.noProgressTurns += 1
2611
2624
  if (
@@ -2640,7 +2653,6 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2640
2653
  // configured grace window. Complements the low-output check above:
2641
2654
  // a turn can be high-output yet still make no real progress because it
2642
2655
  // never touched a tool.
2643
- const latestHasToolCall = messageHasToolCall(latestAssistant)
2644
2656
  const noToolCallContinuation =
2645
2657
  activeGoalAfterMessages.turnCount > 0 && Boolean(latestAssistant) && !latestHasToolCall
2646
2658
  if (noToolCallContinuation) {
@@ -2694,14 +2706,35 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2694
2706
  activeGoalBeforePrompt.lastContinueAt = Date.now()
2695
2707
  if (!budgetWrapup) {
2696
2708
  if (completionUnverified) {
2709
+ activeGoalBeforePrompt.formatFailures += 1
2697
2710
  activeGoalBeforePrompt.lastStatus = `Rejected an unverified [goal:complete] (no [goal:evidence]); re-prompting for evidence on turn ${activeGoalBeforePrompt.turnCount}.`
2698
2711
  } else if (blockerUnstated) {
2712
+ activeGoalBeforePrompt.formatFailures += 1
2699
2713
  activeGoalBeforePrompt.lastStatus = `Rejected a [goal:blocked] with no concrete blocker; re-prompting on turn ${activeGoalBeforePrompt.turnCount}.`
2700
2714
  } else {
2715
+ activeGoalBeforePrompt.formatFailures = 0
2701
2716
  activeGoalBeforePrompt.lastStatus = latestText
2702
2717
  ? `Continuing after assistant turn ${activeGoalBeforePrompt.turnCount}.`
2703
2718
  : `Continuing after idle event ${activeGoalBeforePrompt.turnCount}.`
2704
2719
  }
2720
+
2721
+ // Pause after too many consecutive format-validation failures. Unlike
2722
+ // promptFailures (which counts network/protocol errors), this counts turns
2723
+ // where the model signalled completion or a blocker but omitted the required
2724
+ // evidence or concrete-blocker line. The same maxPromptFailures cap applies;
2725
+ // resume resets the counter via resetGoalBudget.
2726
+ if (activeGoalBeforePrompt.formatFailures >= activeGoalBeforePrompt.options.maxPromptFailures) {
2727
+ activeGoalBeforePrompt.stopped = true
2728
+ activeGoalBeforePrompt.stopReason = "format validation failures"
2729
+ activeGoalBeforePrompt.lastStatus = `Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s) (missing [goal:evidence] or concrete blocker). Run /${commandName} resume to retry.`
2730
+ pushHistory(
2731
+ activeGoalBeforePrompt,
2732
+ "paused",
2733
+ `Paused after ${activeGoalBeforePrompt.formatFailures} consecutive format-validation failure(s).`,
2734
+ )
2735
+ await persist()
2736
+ return
2737
+ }
2705
2738
  }
2706
2739
 
2707
2740
  const response = await client.session.promptAsync({
@@ -2776,13 +2809,21 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2776
2809
  const systemBlocks = Array.isArray(output.system) ? [...output.system] : []
2777
2810
  if (systemBlocks.some(systemBlockContainsGoal)) return
2778
2811
 
2812
+ // Only static content here — volatile fields (limit warnings, turn counters,
2813
+ // token counts, wall-clock values) must not appear in the system prompt.
2814
+ // system.transform fires on every provider request including tool-call
2815
+ // sub-requests; any per-turn drift in the system prompt invalidates the
2816
+ // provider-side prefix cache from byte 0, turning O(1) cache hits into
2817
+ // O(N*turns) full-context misses. Limit warnings are already delivered
2818
+ // on every continuation turn via buildContinueMessage (buildLimitWarning
2819
+ // and <progress_budget>), which is sufficient — the model doesn't need
2820
+ // them in the system prompt mid-turn.
2779
2821
  const goalBlock = [
2780
2822
  buildGoalBlock(goal),
2781
2823
  "Keep working until the goal is fully satisfied.",
2782
2824
  "When fully satisfied, put a `[goal:evidence]` line summarizing what you verified immediately before `[goal:complete]`. A `[goal:complete]` without evidence is rejected.",
2783
2825
  "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
- buildLimitWarning(goal),
2785
- ].filter(Boolean).join("\n")
2826
+ ].join("\n")
2786
2827
 
2787
2828
  if (systemBlocks.length === 0) {
2788
2829
  output.system = [goalBlock]