Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions devlog/2026-09-18_nudge-prompt-align-bcp/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# REQ - Align nudge/system prompt discipline with billion-context-pi (no speculative acp_status pre-check)

- Task ID: `2026-09-18_nudge-prompt-align-bcp`
- Home Repo: `opencode-acp`
- Created: 2026-09-18
- Status: InProgress
- Priority: P1
- Owner: ranxianglei
- References: https://github.com/ranxianglei/opencode-acp/issues/436 ; billion-context-pi `src/system-prompt.ts` + acp-kernel `src/nudge-text.ts` (v0.0.69, pinned by bcp 0.1.71)

## 1. Background & Problem Statement

- **Context**: With `compress.candidates` enabled, ACP lists executor-validated MICRO/EPISODE candidates in compression nudges. The cost of each extra model round trip on local models is 15–23s, so any prompt wording that makes the model call `acp_status` *before* compressing adds a full redundant inference turn per compression event.
- **Current behavior (symptom)**: The standing prompt and nudge guidance actively induce a pre-check turn:
- `lib/prompts/context-limit-nudge.ts` (CANDIDATE_GUIDANCE): "Use `acp_status` for a fresh candidate view if the list is stale or missing."
- `lib/prompts/system.ts` (COMPRESSION CANDIDATES section): "call `acp_status` for a fresh view."
- `lib/prompts/system.ts` (MULTI-TIER section): "If you are unsure which `mNNNNN` refs are still compressible … call `acp_status` first."
- `lib/prompts/compress-range.ts` (CANDIDATE GUIDANCE): "use `acp_status` when the candidate list is stale."
Observed in the field: nudge → `acp_status` → `compress` (two tool turns) instead of nudge → `compress` (one).
- **Expected behavior**: The nudged candidate list is authoritative — the model submits it directly with `compress`. `acp_status` remains available as an optional diagnostic/refresh path but is only mandated in two narrow cases: verifying stale refs from past compress calls, and recovering from a failed `compress` call (re-issue in the same turn using only reported refs).
- **Impact**: One full local-model inference turn (tens of seconds on large contexts) saved per compression event; also reduces prompt-prefix invalidation churn since fewer small intermediate turns occur.

## 2. Evidence / Prior Art

- billion-context-pi (bcp 0.1.71) ships acp-kernel 0.0.69 whose prompt design already avoids this round trip:
- Kernel nudge text (`renderNudgeText`) lists actionable ranges with exact refs + "Compress all ranges in one call" and contains **zero** `acp_status` mentions.
- bcp standing prompt restricts `acp_status` to (a) not reusing historical refs without verification, and (b) failure recovery ("run acp_status, then re-issue the compress in the same turn using only the refs it reports").
- opencode-acp already has the stronger property bcp lacks: candidates are pre-validated against the real range executor at nudge construction (`planCompressionCandidates()` → `prepareExecutableRangePlans()`, `lib/messages/inject/candidates.ts`). So only the prompt discipline needs porting — no new arming/validation mechanism required.
- Ref semantics differ from kernel: opencode-acp refs are sticky per raw message ID (`lib/message-ids.ts` keeps existing refs), so the kernel sentence "Every successful compress renumbers the remaining refs" was deliberately NOT ported verbatim; the ported recovery line is phrased generically ("fails because a ref is stale or unknown") to match opencode-acp's actual error paths.

## 3. Constraints & Non-Goals

- **Constraints**:
- Prompt-only change: no changes to validation, state, or tool behavior. `acp_status` stays fully functional as a diagnostic tool.
- Both modes covered: candidates-on wording lives in candidate sections; the removed MULTI-TIER hedge applied to both modes (it was mode-independent).
- Keep existing test-pinned sentences that remain valid ("suggestions, not mandatory targets", "call the `compress` tool in your next reply", "structurally safe to submit, not a command to compress").
- **Non-Goals**: no "arming" of canonical ranges in SessionState (#436's proposed mechanism deemed unnecessary once prompt discipline matches pi-side); no changes to `acp_status` output format; no version bump (release branch only).

## 4. Acceptance Criteria

- **Correctness**:
- [ ] No prompt text tells the model to call `acp_status` before compressing listed candidates/ranges (no "fresh view"/"call acp_status first" hedging remains in system/nudge/compress prompts).
- [ ] Failure-recovery guidance present: failed compress → run `acp_status` → re-issue in the same turn using only reported refs, batched in one call.
- [ ] Candidates-mode nudge states listed candidates were validated at nudge build time and can be compressed directly.
- **Performance / Stability**:
- [ ] `npm run typecheck`, `npm run format:check`, full test suite green.
- **Regression** (AGENTS.md §5.7):
- [ ] Constant-level regression tests pinning new wording + absence of old hedging.
- [ ] Multi-turn `injectCompressNudges` test (shared SessionState, ≥2 consecutive turns) asserting both `shouldInjectThisTurn` and `lastPerMessageNudgeTokens` after each turn, rendered nudge carries the new direct-action guidance, and one variant uses production-style `preserveRecentMessages > 0`.

## 5. Proposed Approach

- **Affected files**:
- `lib/prompts/context-limit-nudge.ts` — replace "fresh candidate view" hedge with validated-directly + post-failure wording.
- `lib/prompts/system.ts` — (a) COMPRESSION CANDIDATES bullet: validated-at-build-time + "do not call acp_status first"; (b) add failure-recovery bullet under COMPRESSION SUMMARIES IN CONTEXT; (c) delete the MULTI-TIER "call acp_status first" paragraph.
- `lib/prompts/compress-range.ts` — CANDIDATE GUIDANCE tail: no pre-check; post-failure recovery instead of "use acp_status when stale".
- `tests/nudge-text.test.ts`, `tests/compression-candidates.test.ts` — regression + multi-turn tests.
- **Verification**: typecheck + full suite + dev-deploy smoke check of rendered nudge text.
44 changes: 44 additions & 0 deletions devlog/2026-09-18_nudge-prompt-align-bcp/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# WORKLOG - Align nudge/system prompt discipline with billion-context-pi

- Task ID: `2026-09-18_nudge-prompt-align-bcp`
- Branch: `2026-09-18_nudge-prompt-align-bcp` (from `master` @ 06efd39c)
- Date: 2026-09-18
- Status: Done (pending dual-agent review + human merge)
- Issue: https://github.com/ranxianglei/opencode-acp/issues/436

## Changes

Prompt-only alignment with the pi-side (bcp 0.1.71 / acp-kernel 0.0.69) discipline: listed candidates are authoritative at nudge build time; `acp_status` is no longer recommended as a pre-check before compressing. It remains an optional diagnostic/refresh path and is mandated only for failure recovery (re-issue in the same turn using only reported refs). No arming/state/tool-behavior changes were needed: opencode-acp already validates candidates against the real range executor at nudge construction (`planCompressionCandidates()` → `prepareExecutableRangePlans()`, `lib/messages/inject/candidates.ts`), which is stronger than the kernel's static suggestions.

| File | Change |
|------|--------|
| `lib/prompts/context-limit-nudge.ts` | CANDIDATE_GUIDANCE: "Use `acp_status` for a fresh candidate view if the list is stale or missing." → candidates validated at nudge build time, compress directly, post-failure recovery wording. |
| `lib/prompts/system.ts` | (a) COMPRESSION CANDIDATES bullet: validated-at-build-time + "do not call `acp_status` first"; (b) new failure-recovery bullet under COMPRESSION SUMMARIES IN CONTEXT (no arithmetic range adjustment; re-issue same turn from reported refs, batched); (c) deleted MULTI-TIER "call `acp_status` first" paragraph. |
| `lib/prompts/compress-range.ts` | CANDIDATE GUIDANCE tail: removed "use `acp_status` when the candidate list is stale"; added direct-action + post-failure recovery sentence. |
| `tests/prompts.test.ts` | +2 regression tests pinning new system-prompt wording and asserting removal of old hedging phrases. |
| `tests/nudge-text.test.ts` | +2 constant-level tests: CANDIDATE_GUIDANCE direct-action wording; range-mode prompt (candidates on) free of pre-check hedging, legacy off-mode unaffected. |
| `tests/compression-candidates.test.ts` | +`renderedNudgeText` helper and multi-turn test (AGENTS.md §5.7.1): two consecutive `injectCompressNudges` calls sharing one SessionState, asserts `shouldInjectThisTurn`, `lastPerMessageNudgeTokens` (unchanged baseline) and `lastNudgeShownTokens` after each turn, rendered nudge carries the new guidance without old hedging, production-style `preserveRecentMessages: 20`. |

Not ported verbatim from the kernel: "Every successful compress renumbers the remaining refs" — opencode-acp refs are sticky per raw message ID (`lib/message-ids.ts` keeps existing refs), so the recovery line is phrased generically ("fails because a ref is stale or unknown").

## Verification

- `npm run typecheck` — pass.
- `npx prettier --check` on all 6 changed files — pass (repo-wide `format:check` reports pre-existing violations in unrelated files at HEAD as well).
- `npm run build` (tsup + tsc declarations) — pass.
- Full suite `node --import tsx --test tests/*.test.ts`: **1270/1272 pass**. Both failures are environmental in this sandbox (read-only `/tmp`) and reproduce independently of this diff:
- `tests/soft-block.test.ts:14` — top-level `mkdirSync('/tmp/opencode-dcp-dangerous-*')` → EACCES.
- `tests/inactive-block-decompress.test.ts:206` — E2E writes `toFile` output to hardcoded `/tmp/test-inactive-block-decompress.txt`, rejected by the decompress path policy (workspace `.tmp` or `~/.cache/opencode`).
- Touched files in isolation: 35/35 pass (`prompts`, `nudge-text`, `compression-candidates`).
- New multi-turn test verified to exercise the max-limit anchor path: with `maxContextLimit: 1399` between turn-1 usage (100) and turn-2 usage (1400), `contextLimitAnchors` arms on turn 2 and CANDIDATE_GUIDANCE renders into the suffix message.

## Lessons Learned

- The Edit tool can fuzzy-match into a near-identical sibling block (two `config({...})` literals sharing most fields); after any edit into a file with repeated similar blocks, verify the landing site (grep/read) before running. One misfired sequence left a duplicated fragment in the file; recovered by restoring from HEAD and re-appending the appended-only tail, keeping the diff pure additions (+70 lines in that file).
- Nudge internals confirmed while building the multi-turn test: numeric `maxContextLimit`/`minContextLimit` are absolute tokens; `overMaxLimit` is strict `>`; `contextLimitAnchors` populate only when over max (cleared otherwise and after a processed compress); `injectCompressNudges` deliberately does NOT update `lastPerMessageNudgeTokens` (#207 baseline-reset guard) — baseline assertions must account for `setup()` initialization.
- In this sandbox `/tmp` is read-only: temporary artifacts go under `$TMPDIR` (workspace `.tmp/`). Two pre-existing tests assume writable `/tmp` and fail here (reported in issue thread; not fixed in this PR to keep the diff scoped).

## Follow-ups (human decision)

- Dual-agent review of `lib/` + test changes per AGENTS.md §5.3/§5.6, then human merge.
- Optional separate issue (owner-initiated): make the two `/tmp`-dependent tests sandbox-portable.
2 changes: 1 addition & 1 deletion lib/prompts/compress-range.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const CANDIDATE_GUIDANCE_SECTION = `CANDIDATE GUIDANCE
ACP may display a \`COMPRESSION CANDIDATES\` list in a nudge or \`acp_status\` report. A \`MICRO\` entry targets one large message or complete tool transaction; an \`EPISODE\` entry targets a contiguous historical segment. Displayed candidates are independent and non-overlapping, so they may be batched, but they are advisory: compress only entries whose content is no longer needed. When a nudge lists a clearly stale candidate, use its exact IDs and call \`compress\` before continuing. Keep current intent and active work visible, and use \`acp_status\` when the candidate list is stale.`
ACP may display a \`COMPRESSION CANDIDATES\` list in a nudge or \`acp_status\` report. A \`MICRO\` entry targets one large message or complete tool transaction; an \`EPISODE\` entry targets a contiguous historical segment. Displayed candidates are independent and non-overlapping, so they may be batched, but they are advisory: compress only entries whose content is no longer needed. When a nudge lists a clearly stale candidate, use its exact IDs and call \`compress\` before continuing. Keep current intent and active work visible. Do not call \`acp_status\` before compressing listed candidates; if a \`compress\` call fails on a stale ID, run \`acp_status\` and re-issue using only the refs it reports.`

/**
* Build the range-mode compress tool prompt.
Expand Down
2 changes: 1 addition & 1 deletion lib/prompts/context-limit-nudge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ COMPRESSION CANDIDATES:
- EPISODE identifies a contiguous historical segment made from smaller units.
- Candidate entries are independent and non-overlapping, so they can be batched in one \`content[]\` array.
- Candidates are suggestions, not mandatory targets. Choose only content no longer needed for the current task and do not invent a target when none is listed.
- Use \`acp_status\` for a fresh candidate view if the list is stale or missing.
- Listed candidates were validated against the current conversation when this nudge was built. Compress them directly; do not call \`acp_status\` before compressing. If a \`compress\` call fails on a stale ID, run \`acp_status\` once and re-issue using only the refs it reports.
- When the context limit is reached and candidates are listed, select at least one clearly stale candidate and call the \`compress\` tool in your next reply. Batch only additional candidates that are also clearly stale. Do not merely recommend compression.
</system-reminder>
`
5 changes: 2 additions & 3 deletions lib/prompts/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ ACP may append a bounded list headed \`COMPRESSION CANDIDATES\` below a nudge or
- \`MICRO\` is one large plain message or one complete tool transaction, including every message needed to keep its tool call/result structure valid.
- \`EPISODE\` is a contiguous historical segment made from smaller adjacent units.
- Candidate ranges are independent, non-overlapping, and can be submitted together as separate \`content[]\` entries.
- Choose candidates only when their content is no longer needed for the current task. Do not invent a target when no candidate is listed; call \`acp_status\` for a fresh view. If an arbitrary range is unavoidable, verify current visible IDs, protection boundaries, and complete tool-pair coverage first.
- Listed candidates were validated against the current conversation when the nudge or status report was built. Submit them directly with \`compress\`; do not call \`acp_status\` first. Choose candidates only when their content is no longer needed for the current task. Do not invent a target when no candidate is listed. If an arbitrary range is unavoidable, verify current visible IDs, protection boundaries, and complete tool-pair coverage first.
- Keep summaries self-contained and use the existing \`compress\` tool. Candidate labels do not replace the semantic judgment to preserve useful context.
- When a nudge lists a clearly stale candidate, call \`compress\` in that reply before continuing. A repeated nudge means compression was not completed; act on one clearly stale candidate rather than merely recommending compression.`

Expand Down Expand Up @@ -61,6 +61,7 @@ When you see past \`compress\` tool calls in the conversation, their \`summary\`
- Do NOT echo, repeat, or continue summary content as your own output. Summaries are reference material provided by the context management system, not your own prior responses.
- Summaries may contain errors or simplifications. Use \`decompress\` to verify critical details before acting on them.
- The \`startId\`/\`endId\` in past compress calls are historical — do NOT reuse them as targets for new compress calls without verifying via \`acp_status\` that the range is still uncompressed.
- If a \`compress\` call fails because a ref is stale or unknown, do NOT adjust ranges by arithmetic: run \`acp_status\`, then re-issue the \`compress\` in the same turn using only the refs it reports. Submit all target ranges in one batch call.

TOOLS

Expand Down Expand Up @@ -108,8 +109,6 @@ Summaries accumulate as the session grows. When tier-1 summaries pile up, the sy

To compress blocks: use block IDs as boundaries: \`compress({ content: [{ startId: "b3", endId: "b15", summary: "..." }] })\`. Multiple entries create separate blocks: \`compress({ content: [{ startId: "b3", endId: "b10", summary: "..." }, { startId: "b11", endId: "b20", summary: "..." }] })\`. This deactivates the consumed blocks and creates a new higher-tier block per entry. The system prompt at the trigger tells you which rules to follow.

If you are unsure which \`mNNNNN\` refs are still compressible, or which blocks have already consumed which ranges, call \`acp_status\` first. It returns the visible context breakdown and the compressed block list.

CONTEXT BREAKDOWN

When context usage passes a threshold, the system appends a breakdown showing where your context tokens are spent:
Expand Down
82 changes: 82 additions & 0 deletions tests/compression-candidates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,3 +662,85 @@ test("production candidate nudge trusts executable planner candidates over legac

assert.equal(state.nudges.shouldInjectThisTurn, true)
})

function renderedNudgeText(messages: WithParts[]): string {
return messages[messages.length - 1]!.parts.filter((part) => part.type === "text")
.map((part) => (part as any).text)
.join("\n")
}

test("multi-turn candidate nudges carry direct-action guidance under production-style recent protection", () => {
const state = createSessionState()
state.sessionId = SID
state.modelContextLimit = 100_000
const cfg = config({
minContextLimit: 100,
// 1399 must stay between turn-1 usage (100) and turn-2 usage (1400): only crossing maxContextLimit arms contextLimitAnchors, which renders CANDIDATE_GUIDANCE into the nudge.
maxContextLimit: 1399,
minCompressRange: 100,
nudgeGrowthTokens: 1000,
minNudgeGrowthFloor: 100,
minNudgeGrowthRatio: 0.1,
preserveRecentMessages: 20,
})

const turn1: WithParts[] = []
for (let i = 0; i < 11; i++) {
turn1.push(textMessage(`u${i}`, "user", `user ${i}`))
turn1.push(
toolMessage(
`a${i}`,
"assistant",
`call-${i}`,
"bash",
i === 1 ? "x".repeat(800) : "ok",
),
)
}
for (const message of turn1) setTokens(message, 100, 0)
setTokens(turn1[3]!, 2000, 0)
assignMessageRefs(state, turn1)
injectCompressNudges(
state,
cfg,
new Logger(false),
turn1,
{} as any,
undefined,
undefined,
undefined,
turn1,
)
assert.equal(state.nudges.shouldInjectThisTurn, false)
assert.equal(state.nudges.lastPerMessageNudgeTokens, 100)

const turn2: WithParts[] = [
...turn1,
textMessage("u11", "user", "more"),
toolMessage("a11", "assistant", "call-11", "bash", "done"),
]
for (const message of [turn2[turn2.length - 2]!, turn2[turn2.length - 1]!])
setTokens(message, 100, 0)
setTokens(turn2[turn2.length - 1]!, 1400, 0)
assignMessageRefs(state, turn2)
injectCompressNudges(
state,
cfg,
new Logger(false),
turn2,
{} as any,
undefined,
undefined,
undefined,
turn2,
)

const nudgeText = renderedNudgeText(turn2)
assert.equal(state.nudges.shouldInjectThisTurn, true)
assert.equal(state.nudges.lastPerMessageNudgeTokens, 100)
assert.equal(state.nudges.lastNudgeShownTokens, 1400)
assert.match(nudgeText, /do not call `acp_status` before compressing/i)
assert.match(nudgeText, /re-issue using only the refs it reports/i)
assert.doesNotMatch(nudgeText, /fresh candidate view/i)
assert.doesNotMatch(nudgeText, /for a fresh view/i)
})
Loading
Loading