Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
getAutoSuspendEnabled,
getMaxActiveWorktrees,
getPreventSleepWhileRunning,
getSubagentModel,
getWorktreeLocation,
setAutoSuspendAfterDays,
setAutoSuspendEnabled,
setMaxActiveWorktrees,
setPreventSleepWhileRunning,
setSubagentModel,
setWorktreeLocation,
} from "../services/settingsStore";

Expand Down Expand Up @@ -59,4 +61,12 @@ export class ElectronWorkspaceSettings implements IWorkspaceSettings {
setPreventSleepWhileRunning(value: boolean): void {
setPreventSleepWhileRunning(value);
}

getSubagentModel(): string | null {
return getSubagentModel();
}

setSubagentModel(model: string | null): void {
setSubagentModel(model);
}
}
14 changes: 14 additions & 0 deletions apps/code/src/main/services/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface SettingsSchema {
discordPresenceEnabled: boolean;
discordPresenceShowTaskTitle: boolean;
discordPresenceShowRepoName: boolean;
subagentModel: string | null;
}

function getDefaultWorktreeLocation(): string {
Expand Down Expand Up @@ -99,6 +100,10 @@ const schema = {
type: "boolean" as const,
default: false,
},
subagentModel: {
type: ["string", "null"] as const,
default: null,
},
};

export const settingsStore = new Store<SettingsSchema>({
Expand All @@ -114,6 +119,7 @@ export const settingsStore = new Store<SettingsSchema>({
discordPresenceEnabled: false,
discordPresenceShowTaskTitle: false,
discordPresenceShowRepoName: false,
subagentModel: null,
},
});

Expand Down Expand Up @@ -192,3 +198,11 @@ export function getPreventSleepWhileRunning(): boolean {
export function setPreventSleepWhileRunning(value: boolean): void {
settingsStore.set("preventSleepWhileRunning", value);
}

export function getSubagentModel(): string | null {
return settingsStore.get("subagentModel", null);
}

export function setSubagentModel(model: string | null): void {
settingsStore.set("subagentModel", model);
}
1 change: 1 addition & 0 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1758,6 +1758,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
onProcessSpawned: this.options?.onProcessSpawned,
onProcessExited: this.options?.onProcessExited,
effort,
subagentModel: meta?.subagentModel,
enrichmentDeps: this.enrichment?.deps,
enrichedReadCache: this.enrichedReadCache,
cloudMode: cloudRun,
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/adapters/claude/session/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export const DEFAULT_MODEL = "opus";
// at spawn, so this must stay distinct from the alias form used for DEFAULT_MODEL.
export const FALLBACK_MODEL = "claude-opus-4-8";

export const DEFAULT_SUBAGENT_MODEL = "sonnet";

// Default thinking level when the user hasn't picked one. Adaptive-only models
// like claude-fable-5 reject the SDK's no-effort `thinking: { type: "disabled" }`
// shape, so effort-capable models default to high to keep thinking enabled.
Expand Down
73 changes: 73 additions & 0 deletions packages/agent/src/adapters/claude/session/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,79 @@ describe("buildSessionOptions", () => {
});
});

describe("CLAUDE_CODE_SUBAGENT_MODEL", () => {
const originalSubagentModel = process.env.CLAUDE_CODE_SUBAGENT_MODEL;

beforeEach(() => {
delete process.env.CLAUDE_CODE_SUBAGENT_MODEL;
});

afterEach(() => {
if (originalSubagentModel === undefined) {
delete process.env.CLAUDE_CODE_SUBAGENT_MODEL;
} else {
process.env.CLAUDE_CODE_SUBAGENT_MODEL = originalSubagentModel;
}
});

it("defaults subagents to sonnet when nothing overrides", () => {
const options = buildSessionOptions(makeParams());

expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("sonnet");
});

it("prefers a pre-set process env value over the default", () => {
process.env.CLAUDE_CODE_SUBAGENT_MODEL = "inherit";

const options = buildSessionOptions(makeParams());

expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("inherit");
});

it("prefers the host-provided subagent model over process env", () => {
process.env.CLAUDE_CODE_SUBAGENT_MODEL = "inherit";

const options = buildSessionOptions({
...makeParams(),
subagentModel: "claude-haiku-4-5",
});

expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("claude-haiku-4-5");
});

it("prefers the merged settings env value over the host-provided model", () => {
const params = makeParams();
vi.spyOn(params.settingsManager, "getSettings").mockReturnValue({
env: { CLAUDE_CODE_SUBAGENT_MODEL: "inherit" },
});

const options = buildSessionOptions({
...params,
subagentModel: "claude-haiku-4-5",
});

expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe("inherit");
});

it.each([
["claude-opus-4-7", "opus"],
["claude-opus-4-8", "opus"],
["claude-sonnet-4-6", "sonnet"],
["claude-haiku-4-5", "claude-haiku-4-5"],
["inherit", "inherit"],
])(
"maps host-provided value %s to env value %s",
(subagentModel, expected) => {
const options = buildSessionOptions({
...makeParams(),
subagentModel,
});

expect(options.env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe(expected);
},
);
});

describe("ANTHROPIC_CUSTOM_HEADERS", () => {
const originalProjectId = process.env.POSTHOG_PROJECT_ID;
const originalCustomHeaders = process.env.ANTHROPIC_CUSTOM_HEADERS;
Expand Down
25 changes: 22 additions & 3 deletions packages/agent/src/adapters/claude/session/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ import type { CodeExecutionMode } from "../tools";
import type { EffortLevel } from "../types";
import { APPENDED_INSTRUCTIONS } from "./instructions";
import { loadUserClaudeJsonMcpServers } from "./mcp-config";
import { DEFAULT_MODEL, FALLBACK_MODEL } from "./models";
import {
DEFAULT_MODEL,
DEFAULT_SUBAGENT_MODEL,
FALLBACK_MODEL,
toSdkModelId,
} from "./models";
import type { SettingsManager } from "./settings";

export interface ProcessSpawnedInfo {
Expand Down Expand Up @@ -72,6 +77,8 @@ export interface BuildOptionsParams {
onProcessSpawned?: (info: ProcessSpawnedInfo) => void;
onProcessExited?: (pid: number) => void;
effort?: EffortLevel;
/** Host-provided model for spawned subagents; gateway id or CLI alias. */
subagentModel?: string;
enrichmentDeps?: FileEnrichmentDeps;
enrichedReadCache?: EnrichedReadCache;
/** Records PostHog product usage from MCP exec calls (deduped, session-wide). */
Expand Down Expand Up @@ -134,7 +141,10 @@ function buildMcpServers(
};
}

function buildEnvironment(gateway?: GatewayEnv): Record<string, string> {
function buildEnvironment(
gateway?: GatewayEnv,
subagentModel?: string,
): Record<string, string> {
// Custom HTTP headers reach the model only through the Claude CLI subprocess,
// which reads them from this env var (newline-delimited `name: value` lines)
// — the SDK has no direct header option. We finalize them here, the single
Expand Down Expand Up @@ -192,6 +202,11 @@ function buildEnvironment(gateway?: GatewayEnv): Record<string, string> {
MCP_CONNECTION_NONBLOCKING: mcpNonblocking,
}),
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
CLAUDE_CODE_SUBAGENT_MODEL: toSdkModelId(
subagentModel ??
process.env.CLAUDE_CODE_SUBAGENT_MODEL ??
DEFAULT_SUBAGENT_MODEL,
),
};
}

Expand Down Expand Up @@ -443,7 +458,11 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
params.mcpServers,
loadUserClaudeJsonMcpServers(params.cwd, params.logger),
),
env: buildEnvironment(params.gatewayEnv),
env: buildEnvironment(
params.gatewayEnv,
params.settingsManager.getSettings().env?.CLAUDE_CODE_SUBAGENT_MODEL ??
params.subagentModel,
),
hooks: buildHooks(
params.userProvidedOptions?.hooks,
params.onModeChange,
Expand Down
36 changes: 36 additions & 0 deletions packages/agent/src/adapters/claude/session/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,39 @@ describe("mergeAvailableModels", () => {
).toEqual(["managed-a"]);
});
});

describe("user settings env merge", () => {
let configDir: string;
let originalConfigDir: string | undefined;

beforeEach(async () => {
configDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "user-settings-env-"),
);
originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = configDir;
});

afterEach(async () => {
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
}
await fs.promises.rm(configDir, { recursive: true, force: true });
});

it("merges a user-layer env block into settings", async () => {
await fs.promises.writeFile(
path.join(configDir, "settings.json"),
JSON.stringify({ env: { CLAUDE_CODE_SUBAGENT_MODEL: "sonnet" } }),
);

const manager = new SettingsManager(configDir);
await manager.initialize();

expect(manager.getSettings().env?.CLAUDE_CODE_SUBAGENT_MODEL).toBe(
"sonnet",
);
});
});
2 changes: 2 additions & 0 deletions packages/agent/src/adapters/claude/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ export type NewSessionMeta = {
allowedDomains?: string[];
/** Model ID to use for this session (e.g. "claude-sonnet-4-6") */
model?: string;
/** Model for spawned subagents (Task tool, workflow fan-outs); gateway id or CLI alias. */
subagentModel?: string;
/** Base branch of the task's repo (e.g. "master"), for the signed-git tools. */
baseBranch?: string;
/**
Expand Down
16 changes: 16 additions & 0 deletions packages/host-router/src/routers/agent.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getGatewayModelsOutput,
getPreviewConfigOptionsInput,
getPreviewConfigOptionsOutput,
getSubagentModelOutput,
listSessionsInput,
listSessionsOutput,
notifySessionContextInput,
Expand All @@ -23,6 +24,7 @@ import {
respondToPermissionInput,
sessionResponseSchema,
setConfigOptionInput,
setSubagentModelInput,
startSessionInput,
subscribeSessionInput,
} from "@posthog/workspace-server/services/agent/schemas";
Expand Down Expand Up @@ -227,4 +229,18 @@ export const agentRouter = router({
.get<AgentService>(AGENT_SERVICE)
.getPreviewConfigOptions(input.apiHost, input.adapter),
),

getSubagentModel: publicProcedure
.output(getSubagentModelOutput)
.query(({ ctx }) =>
ctx.container.get<AgentService>(AGENT_SERVICE).getSubagentModel(),
),

setSubagentModel: publicProcedure
.input(setSubagentModelInput)
.mutation(({ ctx, input }) =>
ctx.container
.get<AgentService>(AGENT_SERVICE)
.setSubagentModel(input.model),
),
});
2 changes: 2 additions & 0 deletions packages/platform/src/workspace-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface IWorkspaceSettings {
setAutoSuspendAfterDays(value: number): void;
getPreventSleepWhileRunning(): boolean;
setPreventSleepWhileRunning(value: boolean): void;
getSubagentModel(): string | null;
setSubagentModel(model: string | null): void;
}

export const WORKSPACE_SETTINGS_SERVICE = Symbol.for(
Expand Down
Loading
Loading