Skip to content

Commit 30b13e6

Browse files
Merge pull request #71 from stablekernel/subagent-default-model
fix(provider): pin per-model default params on subagent turns
2 parents 3c3f1bd + 7753329 commit 30b13e6

6 files changed

Lines changed: 109 additions & 2 deletions

File tree

‎src/plugin/index.ts‎

Lines changed: 15 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,17 @@ export const CursorPlugin: Plugin = async (input) => {
117118
? { ...userMcp, ...translateMcpServers(config.mcp) }
118119
: userMcp;
119120

121+
// opencode forwards a model's own options.params on the normal chat
122+
// path, but a subagent inheriting its parent's model reaches the provider
123+
// with them dropped — letting Cursor's server-side `fast: true` apply.
124+
// Thread the defaults through provider options (per-provider, survives
125+
// the drop) so the provider can re-apply them as a floor.
126+
const modelParamDefaults: Record<string, Record<string, string>> = {};
127+
for (const item of models) {
128+
const params = defaultModelParams(item);
129+
if (Object.keys(params).length > 0) modelParamDefaults[item.id] = params;
130+
}
131+
120132
// One canonical cwd for the provider's rule write and our dispose
121133
// cleanup: an explicit user option wins, else the plugin directory.
122134
const optionCwd = existingOptions["cwd"];
@@ -133,6 +145,9 @@ export const CursorPlugin: Plugin = async (input) => {
133145
...existingOptions,
134146
cwd: resolvedCwd,
135147
...(Object.keys(mcpServers).length > 0 ? { mcpServers } : {}),
148+
...(Object.keys(modelParamDefaults).length > 0
149+
? { modelParamDefaults }
150+
: {}),
136151
},
137152
models: { ...toOpencodeModels(models), ...(existing.models ?? {}) },
138153
};

‎src/provider/controls.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ 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+
* (an explicit param always wins). Pins Cursor's boolean toggles, e.g.
11+
* `{ fast: "false" }`, when a turn arrives with no params of its own.
12+
*/
13+
defaults?: Record<string, string>;
814
}
915

1016
export interface ResolvedControls {
@@ -52,7 +58,10 @@ export function resolveControls(
5258

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

55-
const params: Record<string, string> = { ...(staticControls.params ?? {}) };
61+
const params: Record<string, string> = {
62+
...(staticControls.defaults ?? {}),
63+
...(staticControls.params ?? {}),
64+
};
5665
if (isRecord(po["params"])) {
5766
for (const [key, value] of Object.entries(po["params"])) {
5867
if (value != null) params[key] = String(value);

‎src/provider/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ 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; applied under `params`
39+
* and per-request options.
40+
*/
41+
modelParamDefaults?: Record<string, Record<string, string>>;
3642
/**
3743
* MCP servers to make available to the Cursor agent, keyed by name. The
3844
* plugin's `config` hook populates this by translating opencode's configured
@@ -98,6 +104,9 @@ export function createCursor(options: CursorProviderOptions = {}): ProviderV3 {
98104
cwd: options.cwd ?? process.cwd(),
99105
mode: options.mode ?? "agent",
100106
...(options.params ? { params: options.params } : {}),
107+
...(options.modelParamDefaults
108+
? { modelParamDefaults: options.modelParamDefaults }
109+
: {}),
101110
...(mcpServers ? { mcpServers } : {}),
102111
...(options.settingSources
103112
? { settingSources: options.settingSources }

‎src/provider/language-model.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ 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, seeded by the plugin's `config`
60+
* hook. Passed as {@link resolveControls}'s `defaults` for the active model.
61+
*/
62+
modelParamDefaults?: Record<string, Record<string, string>>;
5863
/** MCP servers forwarded to the Cursor agent from opencode's config. */
5964
mcpServers?: Record<string, McpServerConfig>;
6065
/** Cursor settings layers to load from disk (skills, rules, .cursor/mcp.json). */
@@ -140,9 +145,18 @@ export class CursorLanguageModel implements LanguageModelV3 {
140145
| undefined;
141146
const { mode, modelSelection } = resolveControls(
142147
this.modelId,
143-
{ mode: this.config.mode, params: this.config.params },
148+
{
149+
mode: this.config.mode,
150+
params: this.config.params,
151+
defaults: this.config.modelParamDefaults?.[this.modelId],
152+
},
144153
providerOptions,
145154
);
155+
if (process.env["OPENCODE_CURSOR_DEBUG"] === "1") {
156+
console.error(
157+
`[cursor:debug] model=${this.modelId} selection=${JSON.stringify(modelSelection)}`,
158+
);
159+
}
146160
const sessionID =
147161
typeof providerOptions?.["sessionID"] === "string"
148162
? (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)