Skip to content

Commit 681b83f

Browse files
committed
fix(provider): pin per-model default params on subagent turns
Subagents inherit the primary agent's model but reach the provider with the model's opencode options.params dropped, so Cursor's server-side `fast: true` default silently applied (e.g. composer-2.5 ran "fast" in subagent calls). Thread each discovered model's defaultModelParams through provider options (a per-provider channel that survives opencode's subagent param-drop) and re-apply them as a lowest-precedence floor in resolveControls, so `fast: "false"` holds even when per-request params are absent. Explicit variant/static params still win. Add an OPENCODE_CURSOR_DEBUG log of the resolved modelSelection to confirm per-turn behavior.
1 parent 3c3f1bd commit 681b83f

6 files changed

Lines changed: 126 additions & 2 deletions

File tree

‎src/plugin/index.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Auth } from "@opencode-ai/sdk/v2";
33
import type { McpServerConfig } from "@cursor/sdk";
44
import { resolveCursorApiKey } from "../api-key.js";
55
import { discoverModels, toOpencodeModels } from "../model-discovery.js";
6+
import { defaultModelParams } from "../model-variants.js";
67
import { buildModelV2Map, PROVIDER_ID, providerNpm } from "./model-v2.js";
78
import {
89
findUnshareableOAuthServers,
@@ -117,6 +118,19 @@ export const CursorPlugin: Plugin = async (input) => {
117118
? { ...userMcp, ...translateMcpServers(config.mcp) }
118119
: userMcp;
119120

121+
// Per-model floor params (e.g. { "composer-2.5": { fast: "false" } }).
122+
// opencode merges each model's own `options.params` on the normal chat
123+
// path, but a subagent that inherits its parent agent's model can reach
124+
// the provider with the bare model id and no params. Threading these
125+
// defaults through the (per-provider, not per-request) provider options
126+
// lets the provider re-apply them as a floor so `fast` never silently
127+
// falls back to Cursor's server-side `true`.
128+
const modelParamDefaults: Record<string, Record<string, string>> = {};
129+
for (const item of models) {
130+
const params = defaultModelParams(item);
131+
if (Object.keys(params).length > 0) modelParamDefaults[item.id] = params;
132+
}
133+
120134
// One canonical cwd for the provider's rule write and our dispose
121135
// cleanup: an explicit user option wins, else the plugin directory.
122136
const optionCwd = existingOptions["cwd"];
@@ -133,6 +147,9 @@ export const CursorPlugin: Plugin = async (input) => {
133147
...existingOptions,
134148
cwd: resolvedCwd,
135149
...(Object.keys(mcpServers).length > 0 ? { mcpServers } : {}),
150+
...(Object.keys(modelParamDefaults).length > 0
151+
? { modelParamDefaults }
152+
: {}),
136153
},
137154
models: { ...toOpencodeModels(models), ...(existing.models ?? {}) },
138155
};

‎src/provider/controls.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ export interface StaticControls {
55
mode: AgentModeOption;
66
/** Default Cursor model params (id -> value), e.g. { thinking: "high" }. */
77
params?: Record<string, string>;
8+
/**
9+
* Per-model floor params applied UNDER {@link params} and per-request options.
10+
* Carries this model's non-reasoning boolean defaults (e.g. `{ fast: "false" }`)
11+
* so a call that reaches the provider with the bare model id and no params —
12+
* notably an opencode subagent inheriting its parent's model — still pins
13+
* `fast` off instead of inheriting Cursor's server-side `fast: true` default.
14+
* The normal chat path already carries these via the model's opencode
15+
* `options.params`, so re-applying them here is a no-op there.
16+
*/
17+
defaults?: Record<string, string>;
818
}
919

1020
export interface ResolvedControls {
@@ -52,7 +62,10 @@ export function resolveControls(
5262

5363
const mode: AgentModeOption = isMode(po["mode"]) ? po["mode"] : staticControls.mode;
5464

55-
const params: Record<string, string> = { ...(staticControls.params ?? {}) };
65+
const params: Record<string, string> = {
66+
...(staticControls.defaults ?? {}),
67+
...(staticControls.params ?? {}),
68+
};
5669
if (isRecord(po["params"])) {
5770
for (const [key, value] of Object.entries(po["params"])) {
5871
if (value != null) params[key] = String(value);

‎src/provider/index.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ export interface CursorProviderOptions {
3333
mode?: AgentModeOption;
3434
/** Default Cursor model params (id -> value), e.g. { thinking: "high" }. */
3535
params?: Record<string, string>;
36+
/**
37+
* Per-model floor params keyed by model id (e.g. `{ "composer-2.5": { fast:
38+
* "false" } }`). Seeded by the plugin's `config` hook from the discovered
39+
* catalog so subagents that inherit a model without its options.params still
40+
* pin Cursor's boolean toggles to their opencode defaults. Applied under
41+
* `params` and per-request options.
42+
*/
43+
modelParamDefaults?: Record<string, Record<string, string>>;
3644
/**
3745
* MCP servers to make available to the Cursor agent, keyed by name. The
3846
* plugin's `config` hook populates this by translating opencode's configured
@@ -98,6 +106,9 @@ export function createCursor(options: CursorProviderOptions = {}): ProviderV3 {
98106
cwd: options.cwd ?? process.cwd(),
99107
mode: options.mode ?? "agent",
100108
...(options.params ? { params: options.params } : {}),
109+
...(options.modelParamDefaults
110+
? { modelParamDefaults: options.modelParamDefaults }
111+
: {}),
101112
...(mcpServers ? { mcpServers } : {}),
102113
...(options.settingSources
103114
? { settingSources: options.settingSources }

‎src/provider/language-model.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ export interface CursorModelConfig {
5555
mode: AgentModeOption;
5656
/** Default Cursor model params (id -> value); overridable per-request. */
5757
params?: Record<string, string>;
58+
/**
59+
* Per-model floor params keyed by model id (e.g. `{ "composer-2.5": { fast:
60+
* "false" } }`). Applied under {@link params} and per-request options so a
61+
* call arriving with the bare model id and no params — notably an opencode
62+
* subagent that inherited its parent agent's model — still pins Cursor's
63+
* boolean toggles (like `fast`) to their opencode defaults instead of
64+
* silently inheriting Cursor's server-side `fast: true`. Seeded by the
65+
* plugin's `config` hook from the discovered catalog.
66+
*/
67+
modelParamDefaults?: Record<string, Record<string, string>>;
5868
/** MCP servers forwarded to the Cursor agent from opencode's config. */
5969
mcpServers?: Record<string, McpServerConfig>;
6070
/** Cursor settings layers to load from disk (skills, rules, .cursor/mcp.json). */
@@ -140,9 +150,22 @@ export class CursorLanguageModel implements LanguageModelV3 {
140150
| undefined;
141151
const { mode, modelSelection } = resolveControls(
142152
this.modelId,
143-
{ mode: this.config.mode, params: this.config.params },
153+
{
154+
mode: this.config.mode,
155+
params: this.config.params,
156+
defaults: this.config.modelParamDefaults?.[this.modelId],
157+
},
144158
providerOptions,
145159
);
160+
if (process.env["OPENCODE_CURSOR_DEBUG"] === "1") {
161+
// Root-cause instrument: shows the exact ModelSelection sent to Cursor.
162+
// A subagent that inherited its parent's model with dropped params shows
163+
// up here as a selection missing the `fast: "false"` floor before the
164+
// modelParamDefaults guard re-applies it.
165+
console.error(
166+
`[cursor:debug] model=${this.modelId} selection=${JSON.stringify(modelSelection)}`,
167+
);
168+
}
146169
const sessionID =
147170
typeof providerOptions?.["sessionID"] === "string"
148171
? (providerOptions["sessionID"] as string)

‎test/controls.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,36 @@ describe("resolveControls", () => {
3939
const r = resolveControls("m", { mode: "agent" }, { params: { budget: 1024 } });
4040
expect(r.modelSelection.params).toEqual([{ id: "budget", value: "1024" }]);
4141
});
42+
43+
it("applies per-model defaults as a floor when no params are supplied", () => {
44+
// The subagent path: opencode hands the bare model id with no params, so the
45+
// model's default `fast: "false"` must still be sent (otherwise Cursor's
46+
// server-side `fast: true` default silently applies).
47+
const r = resolveControls(
48+
"composer-2.5",
49+
{ mode: "agent", defaults: { fast: "false" } },
50+
undefined,
51+
);
52+
expect(r.modelSelection.params).toEqual([{ id: "fast", value: "false" }]);
53+
});
54+
55+
it("lets a per-request param override the default floor (fast opt-in)", () => {
56+
const r = resolveControls(
57+
"composer-2.5",
58+
{ mode: "agent", defaults: { fast: "false" } },
59+
{ params: { fast: "true" } },
60+
);
61+
expect(r.modelSelection.params).toEqual([{ id: "fast", value: "true" }]);
62+
});
63+
64+
it("lets static params override the default floor", () => {
65+
const r = resolveControls(
66+
"m",
67+
{ mode: "agent", defaults: { fast: "false" }, params: { fast: "true" } },
68+
undefined,
69+
);
70+
expect(r.modelSelection.params).toEqual([{ id: "fast", value: "true" }]);
71+
});
4272
});
4373

4474
// buildModelVariants behavior is covered in test/model-variants.test.ts.

‎test/language-model.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,36 @@ describe("CursorLanguageModel doStream — resume-aware retry", () => {
322322
expect(getPooledAgentId("s1")).toBeUndefined();
323323
});
324324

325+
it("applies per-model default params (fast:false) when a turn arrives with no params", async () => {
326+
// Simulates an opencode subagent that inherited its parent's model: the
327+
// provider gets the bare model id with no per-request params. The
328+
// modelParamDefaults floor must still pin fast:false so Cursor's
329+
// server-side fast:true default never applies.
330+
const model = new CursorLanguageModel("composer-2.5", {
331+
providerName: "cursor",
332+
cwd: "/tmp",
333+
mode: "agent",
334+
session: "auto",
335+
modelParamDefaults: { "composer-2.5": { fast: "false" } },
336+
});
337+
create.mockResolvedValueOnce(fakeAgent({ agentId: "a1" }));
338+
339+
await collectStream(
340+
streamCall(model, {
341+
prompt: [sys("S"), user("hi")],
342+
providerOptions: { cursor: { sessionID: "s1" } },
343+
} as never),
344+
);
345+
346+
const acquireArgs = create.mock.calls[0]?.[0] as {
347+
model: { id: string; params?: Array<{ id: string; value: string }> };
348+
};
349+
expect(acquireArgs.model).toEqual({
350+
id: "composer-2.5",
351+
params: [{ id: "fast", value: "false" }],
352+
});
353+
});
354+
325355
it("chains the original resume failure as cause when re-acquire throws", async () => {
326356
const model = makeModel();
327357

0 commit comments

Comments
 (0)