Skip to content

fix(provider): honor the host's tool set so compaction can summarize - #91

Merged
justin-carper merged 1 commit into
mainfrom
fix/no-tools-turn-tool-display
Aug 3, 2026
Merged

fix(provider): honor the host's tool set so compaction can summarize#91
justin-carper merged 1 commit into
mainfrom
fix/no-tools-turn-tool-display

Conversation

@justin-carper

Copy link
Copy Markdown
Collaborator

Problem

Compaction fails on Cursor models:

Tool call not allowed while generating summary: cursor_context-mode_ctx_search

A failed compaction hard-errors the turn, so an oversized session cannot be compacted at all — the user is stuck.

Root cause

Three verified links:

1. The guard is opencode's. From the opencode 1.18.11 binary (SessionProcessor):

case "tool-input-start":
  if (a.assistantMessage.summary) throw Error(`Tool call not allowed while generating summary: ${c.name}`);
case "tool-call":
  if (a.assistantMessage.summary) throw Error(`Tool call not allowed while generating summary: ${c.name}`);

2. opencode declares zero tools on that turn. SessionCompaction.process builds the assistant message with summary: true and calls process({ ..., tools: {}, system: [] }). The bundled ai-sdk then drops the tool fields entirely — an empty tool record short-circuits before activeTools filtering:

function K1(K){ return K != null && Object.keys(K).length > 0 }
async function Q1({tools:K, toolChoice:Q, activeTools:Y}){ if(!K1(K)) return { tools: void 0, toolChoice: void 0 }; ... }

So the provider receives options.tools === undefined.

3. The provider never looked. grep -rn "\.tools\b|toolChoice|activeTools" src/ returned nothing before this change. Every turn ran the Cursor agent with its full native toolset, and cursorEventsToStream mapped that activity to provider-executed parts in the default "blocks" mode. The cursor_-prefixed generic name matches the reported cursor_context-mode_ctx_search exactly.

Chain: opencode declares no tools → Cursor agent uses its own tools anyway → provider forwards them as tool-input-start/tool-call → opencode's summary guard throws → compaction fails.

Why not restrict tools on the Cursor run instead

AgentOptions, LocalAgentOptions, and LocalSendOptions in @cursor/sdk@1.0.24 have no tool allowlist, denylist, or tool-disable field. mode: "plan" restricts writes but not reads/search. The only tools?: string[] in the package is a message field, not a run control. Suppression has to happen at the mapping boundary this plugin owns.

Fix

Honor the host's declared tool set. If opencode offered no tools, it cannot accept tool parts — so route that turn through the already existing and already tested toolDisplay: "reasoning" path, which renders Cursor's activity as reasoning text.

Turn options.tools Effective toolDisplay
Normal chat / subagent non-empty array configured value (default blocks) — unchanged
Compaction / summary undefined reasoning
Title generation undefined reasoning
activeTools filters everything out [] reasoning

The empty-array row matters: ai-sdk's early return only covers a null tool set, so a non-empty tools narrowed by activeTools arrives as [].

No new config knob, no env var, no changes to MCP forwarding, session pooling, or the skills mirror.

Why it surfaced now

Not a code regression in 0.7.1-next.0, but that release is what switched the failing path on. opencode merges the plugin's config-channel entry over its models.dev entry; Cursor models are absent from models.dev, so the fallback applies:

limit: { context: C.limit?.context ?? _?.limit?.context ?? 0, ... }

and auto-compaction is gated on exactly that value:

if (e.model.limit.context === 0) return !1;

git show be7a36e^:src/model-discovery.ts | grep limit → no matches. Pre-#89 the config channel carried no limit, so context resolved to 0 and auto-compaction could never fire for a Cursor model. #89 published real windows, so it now does.

Manual /compact has no such gate and was affected all along.

Compaction with an agent-backed provider

Worth recording, since Cursor keeps its own server-side conversation state: opencode-side compaction is genuinely effective here, and the existing pool design already produces the right lifecycle.

  • The summary turn has system: [], so classifyTurn returns side-call → fresh ephemeral agent, pool untouched.
  • The next real turn has collapsed history, so isStrictPrefix fails → divergenceresumeAgentId is never set → a fresh agent is seeded with the compacted transcript and re-pooled.

So the bloated Cursor agent is retired and context genuinely shrinks. The only thing that was broken is the tool-part leak on the summary turn.

Tests

507 → 514. Every new test was mutation-checked, not merely observed green:

  • Revert both call sites to this.config.toolDisplay → the 2 no-tools regression tests fail.
  • Gut the reasoning fold (tool parts suppressed but nothing emitted) → only the new positive assertions fail (expected '' to contain 'context-mode_ctx_search'), confirming they test the fold rather than mere absence.
  • Drop || tools.length === 0 → the empty-array unit test fails.

Coverage: 4 unit tests for effectiveToolDisplay; a no-tools doStream emits none of tool-input-start/tool-input-delta/tool-input-end/tool-call/tool-result and folds the activity into reasoning while the summary text still reaches the host; a with-tools doStream still emits tool-call (positive control against silently killing normal tool blocks); a no-tools doGenerate mirror.

Verification

npx tsc --noEmit clean · npx vitest run 514 passed (35 files) · npm run build succeeds.

Not included

The EXC_GUARD / guarded-fd process kill observed shortly after a compaction is not addressed here and is not yet attributed to this plugin. A userland double-close cannot produce that signature — probed directly on Bun 1.3.5 and Node, where double close on file, socket, and stdio fds all yield EBADF, never EXC_GUARD — so it originates in native code, and the crash frames are unsymbolized. Investigation and its defensive hardening are deliberately held back rather than shipped on a guess.

opencode calls the model with `tools: {}` on a compaction/summary turn, and
the bundled ai-sdk converts an empty tool record to `options.tools ===
undefined`. The Cursor agent runs its own tools regardless of what the host
declared, and the provider forwarded that activity as provider-executed
`tool-call` / `tool-input-start` parts. opencode's SessionProcessor rejects
those on a summary turn:

    case "tool-input-start":
    case "tool-call":
      if (assistantMessage.summary)
        throw Error(`Tool call not allowed while generating summary: ${name}`)

so the turn hard-errored and the session could not be compacted at all.

A host that declared no tools cannot accept tool parts, so route those turns
through the existing `"reasoning"` tool-display path: Cursor's tool activity
is folded into reasoning text instead of crossing the tool-execution
boundary. Turns that do declare tools are untouched and still render
structured blocks.

The empty-array case matters as well as `undefined`: ai-sdk's early return
only covers a null tool set, so a non-empty `tools` filtered down by
`activeTools` arrives as `[]`.

Manual `/compact` has been affected all along. Auto-compaction became
reachable only in 0.7.1-next.0, because #89 published real per-model context
windows — before that opencode resolved `limit.context` to 0 for every Cursor
model, and a zero context limit structurally disables the auto-compaction
trigger.
@justin-carper
justin-carper merged commit 731ae8d into main Aug 3, 2026
8 checks passed
@justin-carper
justin-carper deleted the fix/no-tools-turn-tool-display branch August 3, 2026 18:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant