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 +8 -0
- package/package.json +1 -1
- package/src/goal-plugin.js +45 -4
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
package/src/goal-plugin.js
CHANGED
|
@@ -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
|
-
|
|
2785
|
-
].filter(Boolean).join("\n")
|
|
2826
|
+
].join("\n")
|
|
2786
2827
|
|
|
2787
2828
|
if (systemBlocks.length === 0) {
|
|
2788
2829
|
output.system = [goalBlock]
|