Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changeset/secondary-model-thinking-effort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix subagents bound to a configured secondary model ignoring its default thinking effort; [secondary_model].default_effort and the bound model's own default_effort are now honored.
6 changes: 4 additions & 2 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,12 +210,14 @@ default_model = "kimi-code/kimi-for-coding-highspeed"
| `default_model` | `string` | — | The default model for subagents |
| `models` | `table<string, string>` | — | Subagent model pool. Each key is the alias of a configured [`[models]`](#models) entry; each value is the selection hint shown to the main agent |
| `force` | `boolean` | `false` | Pin every subagent to `default_model`, taking the choice away from the main agent |
| `default_effort` | `string` | — | The thinking effort every spawned subagent binds with; outranks the bound model entry's own `default_effort` |

Constraints between the fields:

- `default_model`: required when a `models` table is configured, and must be one of its keys.
- `models`: values may be Chinese or English; an empty string lists the alias with no hint.
- `force`: requires `default_model` and cannot be combined with a `models` table — the table exists to offer a choice, and force removes it.
- `default_effort` is section-wide: every spawn binds it regardless of the chosen pool entry (or the forced model). For per-entry efforts, leave it unset and use model variants (see below).
- `primary` is a reserved alias (see below) and cannot be a pool key.

In the interactive TUI, the [`/secondary-model`](../reference/slash-commands.md) command (alias `/subagent-model`) opens a model selector: the choice is written to `default_model` (when a models table exists and the picked alias is not in it, an entry with an empty description is added), and newly spawned subagents pick up the new default immediately — no session restart needed.
Expand All @@ -240,7 +242,7 @@ Rules for the `model` parameter:

- It accepts any pool alias, or `"primary"` — the model the caller itself is running, always valid even when not in the pool.
- When neither `default_model` nor `models` is configured, the parameter is not advertised and subagents inherit the caller's model.
- Binding a pool alias carries no explicit thinking effort: the subagent resolves it as "global `[thinking]` config → the bound model's default effort" instead of inheriting the caller's level.
- Binding a pool alias does not inherit the caller's thinking effort. The subagent's effort resolves in this order: the section's `default_effort`, the bound model entry's `default_effort`, the global `[thinking]` config, then the middle of the bound model's `support_efforts`.
- `"primary"` inherits both the model and the effort level from the caller.
- A value that is neither a pool alias nor `"primary"` fails the spawn with an error listing the available choices.

Expand Down Expand Up @@ -286,7 +288,7 @@ Two prerequisites:
- The underlying model must declare `support_efforts` (under `managed:kimi-code` only the k3 family currently declares effort levels).
- The variant is a standalone entry and does not inherit fields from the entry it points at — copy `capabilities`, `support_efforts`, and the other metadata over in full, otherwise `default_effort` has no effect (it must be a member of `support_efforts`).

Also note that `default_effort` stays a model-level default: once a global `[thinking].effort` is set, it wins for the main agent and subagents alike, and the variant's default only applies when no global effort is set. Value and fallback rules follow the [`[models]` entry's `default_effort`](#models).
Note the asymmetry between the main agent and pool-bound subagents: for the main agent, a configured global `[thinking].effort` overrides the variant's `default_effort`; for subagents the variant's `default_effort` wins over the global value, and only `[secondary_model].default_effort` outranks it. Value and fallback rules follow the [`[models]` entry's `default_effort`](#models).

::: warning Note
Configuration errors fail loudly instead of falling back silently. Session creation, resume, and fork all fail at startup when:
Expand Down
6 changes: 4 additions & 2 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,12 +210,14 @@ default_model = "kimi-code/kimi-for-coding-highspeed"
| `default_model` | `string` | — | subagent 的默认模型 |
| `models` | `table<string, string>` | — | subagent 模型池。key 是 [`[models]`](#models) 条目的别名,value 是给 main agent 的挑选提示 |
| `force` | `boolean` | `false` | 把所有 subagent 固定到 `default_model`,收回 main agent 的选择权 |
| `default_effort` | `string` | — | 每次派生的 subagent 绑定的 Thinking 档位,优先于所绑定模型条目自己的 `default_effort` |

字段之间的约束:

- `default_model`:配置 `models` 表时必填,且必须是其中的 key。
- `models`:value 中英文均可;空字符串表示只列出别名、不给提示。
- `force`:必须搭配 `default_model`,且不能与 `models` 表同用——表的意义在于提供选择,而 force 取消了选择。
- `default_effort` 是节级设置:无论派生绑定到池中哪个条目(或 force 固定的模型)都生效。想按条目区分档位时不要设置它,改用下文的模型「变体」。
- `primary` 是保留字(含义见下文),不能作为池中 key。

在交互式 TUI 中,也可以用 [`/secondary-model`](../reference/slash-commands.md) 命令(别名 `/subagent-model`)打开模型选择器:选择后写入 `default_model`(已有 models 表而所选别名不在其中时,会一并补一条空描述条目),之后派生的 subagent 立即按新默认值绑定,无需重启会话。
Expand All @@ -240,7 +242,7 @@ default_model = "kimi-code/kimi-for-coding-highspeed"

- 接受池中任意别名,或 `"primary"`——调用方自己正在运行的模型,始终合法,即使不在池中。
- `default_model` 与 `models` 都未配置时该参数不存在,subagent 继承调用方模型。
- 绑定池中别名时不携带显式 Thinking 档位:subagent 按 "全局 `[thinking]` 配置 → 所绑定模型的默认 effort" 解析,不继承调用方的档位。
- 绑定池中别名时不继承调用方的 Thinking 档位,subagent 的档位按以下顺序解析:本节的 `default_effort`、所绑定模型条目的 `default_effort`、全局 `[thinking]` 配置、所绑定模型 `support_efforts` 的中间项。
- `"primary"` 则连模型带档位一起继承调用方。
- 传入的值既不是池中别名也不是 `"primary"` 时,本次派生报错并列出可选值。

Expand Down Expand Up @@ -285,7 +287,7 @@ k3-max = "同一模型的 max Thinking 档位。适合最难的子任务。"
- 底层模型必须声明了 `support_efforts`(`managed:kimi-code` 下目前只有 k3 系列声明了档位)。
- 变体是独立条目,不会继承被指向条目的字段——`capabilities`、`support_efforts` 等元数据要完整照抄,否则 `default_effort` 不生效(它必须是 `support_efforts` 列表中的值)。

另外注意,`default_effort` 只是模型级默认值:全局 `[thinking].effort` 一旦设置,对 main agent 和 subagent 都优先生效,变体的默认档位只在全局未设置时起作用。取值与回落规则同 [`[models]` 条目的 `default_effort`](#models)。
另外注意 main agent 与 subagent 的不对称:对 main agent,全局 `[thinking].effort` 一旦设置就压过变体的 `default_effort`;对绑定池内别名的 subagent,变体的 `default_effort` 优先于全局值,只有 `[secondary_model].default_effort` 的优先级更高。取值与回落规则同 [`[models]` 条目的 `default_effort`](#models)。

::: warning 注意
配置错误一律直接报错,不做静默回退。出现以下情况时,会话的创建、恢复(resume)与 fork 都会在启动时失败:
Expand Down
9 changes: 9 additions & 0 deletions packages/agent-core-v2/src/kosong/model/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ export function defaultThinkingEffortForModel(
return 'on';
}

export function declaredDefaultEffortForModel(
model: ModelThinkingMetadata | undefined,
): ThinkingEffort | undefined {
if (!modelSupportsThinking(model)) return undefined;
const declared = nonEmpty(model?.defaultEffort);
if (declared === undefined) return undefined;
return effortsFor(model).includes(declared) ? (declared as ThinkingEffort) : undefined;
}

export function modelSupportsThinkingEffort(
effort: ThinkingEffort,
model: ModelThinkingMetadata | undefined,
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/src/session/subagent/configSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ export function resolveSubagentBinding(
{ details: { model: requested } },
);
}
return { model: forcedModel };
return { model: forcedModel, thinking: section.defaultEffort };
}
if (requested === PRIMARY_SUBAGENT_MODEL_CHOICE) {
return { model: own.modelAlias, thinking: own.thinkingLevel };
Expand Down Expand Up @@ -279,7 +279,7 @@ export function resolveSubagentBinding(
{ details: { model: choice, availableModels: available } },
);
}
return { model: choice };
return { model: choice, thinking: section?.defaultEffort };
}

export function buildSubagentModelDescriptions(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
import type { Runtime } from '#/runtime/runtime';
import { IConfigService } from '#/app/config/config';
import { IFlagService } from '#/app/flag/flag';
import { IModelCatalog } from '#/kosong/model/catalog';
import { IModelCatalog, type Model } from '#/kosong/model/catalog';
import { declaredDefaultEffortForModel } from '#/kosong/model/thinking';
import { ILogService } from '#/_base/log/log';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView';
Expand Down Expand Up @@ -133,15 +134,16 @@ export class SessionSubagentService extends Service implements ISessionSubagentS
{ modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel },
input.model,
);
let model: Model;
try {
this.modelCatalog.get(binding.model);
model = this.modelCatalog.get(binding.model);
} catch (error) {
throw wrapSubagentModelError(error, binding.model, own.modelAlias);
}
return {
profileName: profile?.name ?? requestedProfileName,
model: binding.model,
thinking: binding.thinking,
thinking: binding.thinking ?? declaredDefaultEffortForModel(model),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the model-default fallback to Tower workers

When Tower mode spawns a worker and [secondary_model].default_effort is unset, TowerSpawnTool calls resolveSubagentBinding directly and passes binding.thinking to agentLifecycle.create (features/tower/tools/spawn/spawnTool.ts:164-169,283-288), so it never reaches this new fallback. Consequently a pool-bound Tower worker still resolves the global [thinking].effort ahead of the bound model's default_effort, contrary to the new documented precedence. Centralize the model-default resolution in the binding path or route Tower spawning through planSpawn.

Useful? React with 👍 / 👎.

@7Sageer 7Sageer Aug 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 3894c38 — TowerSpawnTool.launch now applies the same declaredDefaultEffortForModel fallback after the catalog lookup, so pool-bound tower workers follow the same precedence as planSpawn. Added two spawn-tool tests covering the section default_effort pass-through and the model-entry fallback.

fork,
};
}
Expand Down
21 changes: 20 additions & 1 deletion packages/agent-core-v2/test/app/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1814,7 +1814,7 @@ describe('subagent config section', () => {
});
expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({
model: 'provider/fast',
thinking: undefined,
thinking: 'low',
});
expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'provider/smart')).toThrow(
/Invalid model "provider\/smart"\. Available models: provider\/fast, primary\./,
Expand Down Expand Up @@ -1894,6 +1894,25 @@ describe('subagent config section', () => {
disposables.dispose();
});

it('binds [secondary_model].default_effort as the subagent thinking', async () => {
const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' };
const { config, disposables } = await createConfig(
{},
'[secondary_model]\ndefault_model = "provider/fast"\ndefault_effort = "max"\n',
);

expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({
model: 'provider/fast',
thinking: 'max',
});
expect(resolveSubagentBinding(config, secondaryModelFlags(), own, 'primary')).toEqual({
model: 'provider/main',
thinking: 'medium',
});

disposables.dispose();
});

it('binds every spawn to the forced default_model, rejecting even "primary"', async () => {
const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' };
const { config, disposables } = await createConfig(
Expand Down
19 changes: 19 additions & 0 deletions packages/agent-core-v2/test/kosong/model/thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ProtocolAdapterRegistry } from '#/kosong/provider/protocolAdapterRegist
import '#/kosong/provider/providers/kimi/kimi.contrib';
import '#/kosong/provider/providers/standard.contrib';
import {
declaredDefaultEffortForModel,
defaultThinkingEffortForModel,
drivesThinkingThroughTraits,
modelSupportsThinkingEffort,
Expand Down Expand Up @@ -88,6 +89,24 @@ describe('resolveThinkingEffortForModel', () => {
expect(modelSupportsThinkingEffort('off', thinkingModel, true)).toBe(true);
expect(modelSupportsThinkingEffort('extreme', thinkingModel, false)).toBe(true);
});

it('declaredDefaultEffortForModel returns the declared default only when the model lists it', () => {
expect(declaredDefaultEffortForModel(thinkingModel)).toBe('high');
expect(
declaredDefaultEffortForModel({
capabilities: ['thinking'],
supportEfforts: ['low', 'medium'],
defaultEffort: 'high',
}),
).toBeUndefined();
expect(
declaredDefaultEffortForModel({ capabilities: ['thinking'], supportEfforts: ['low'] }),
).toBeUndefined();
expect(
declaredDefaultEffortForModel({ supportEfforts: ['max'], defaultEffort: 'max' }),
).toBeUndefined();
expect(declaredDefaultEffortForModel(undefined)).toBeUndefined();
});
});

describe('resolveForcedThinkingEffort', () => {
Expand Down
137 changes: 136 additions & 1 deletion packages/agent-core-v2/test/session/subagent/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo
import { IAgentUserToolService } from '#/agent/userTool/userTool';
import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
import { Error2, ErrorCodes, isError2 } from '#/errors';
import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability';
import { IModelCatalog, type Model } from '#/kosong/model/catalog';
import { FakeRuntime } from '#/runtime/fakeRuntime';
import type { RuntimeLease } from '#/runtime/runtime';
Expand Down Expand Up @@ -50,6 +51,7 @@ describe('SessionSubagentService planSpawn and spawn', () => {
let callerData: ProfileData;
let profiles: AgentProfile[];
let modelIds: Set<string>;
let modelMeta: Map<string, Partial<Model>>;
let caller: IAgentScopeHandle;
let createdHandles: Map<string, IAgentScopeHandle>;
let createAgent: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -120,6 +122,7 @@ describe('SessionSubagentService planSpawn and spawn', () => {
}),
];
modelIds = new Set(['main-model']);
modelMeta = new Map();
callerPermissionMode = { mode: 'auto', setMode: vi.fn() };
createdPermissionMode = { mode: 'manual', setMode: vi.fn() };
callerUserTools = userToolsStub();
Expand Down Expand Up @@ -202,7 +205,7 @@ describe('SessionSubagentService planSpawn and spawn', () => {
{ details: { model: alias } },
);
}
return { id: alias } as Model;
return { id: alias, ...modelMeta.get(alias) } as Model;
},
} as unknown as IModelCatalog);
ix.stub(ISessionContext, { _serviceBrand: undefined, cwd: '/repo' } as unknown as ISessionContext);
Expand Down Expand Up @@ -319,6 +322,138 @@ describe('SessionSubagentService planSpawn and spawn', () => {
expect(error.message).toContain('comes from [secondary_model.models]');
});

it('passes [secondary_model].default_effort as the explicit subagent thinking', async () => {
modelIds.add('provider/fast');
const svc = service(
{
[SECONDARY_MODEL_SECTION]: {
defaultModel: 'provider/fast',
models: { 'provider/fast': 'fast model' },
defaultEffort: 'max',
},
},
true,
);

const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' });

expect(plan).toEqual({
profileName: 'coder',
model: 'provider/fast',
thinking: 'max',
fork: false,
});
});

it('prefers [secondary_model].default_effort over the bound model default_effort', async () => {
modelIds.add('provider/fast');
modelMeta.set('provider/fast', {
capabilities: { ...UNKNOWN_CAPABILITY, thinking: true },
supportEfforts: ['low', 'high', 'max'],
defaultEffort: 'high',
});
const svc = service(
{
[SECONDARY_MODEL_SECTION]: {
defaultModel: 'provider/fast',
models: { 'provider/fast': 'fast model' },
defaultEffort: 'max',
},
},
true,
);

const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' });

expect(plan.thinking).toBe('max');
});

it('falls back to the bound model default_effort when the section declares none', async () => {
modelIds.add('provider/fast');
modelMeta.set('provider/fast', {
capabilities: { ...UNKNOWN_CAPABILITY, thinking: true },
supportEfforts: ['low', 'high', 'max'],
defaultEffort: 'max',
});
const svc = service(
{
[SECONDARY_MODEL_SECTION]: {
defaultModel: 'provider/fast',
models: { 'provider/fast': 'fast model' },
},
},
true,
);

const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' });

expect(plan.thinking).toBe('max');
});

it('leaves the subagent thinking unset when the bound model declares no valid default_effort', async () => {
modelIds.add('provider/fast');
modelMeta.set('provider/fast', {
capabilities: { ...UNKNOWN_CAPABILITY, thinking: true },
supportEfforts: ['low', 'high'],
defaultEffort: 'max',
});
const svc = service(
{
[SECONDARY_MODEL_SECTION]: {
defaultModel: 'provider/fast',
models: { 'provider/fast': 'fast model' },
},
},
true,
);

const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' });

expect(plan.thinking).toBeUndefined();
});

it('passes [secondary_model].default_effort with the forced model', async () => {
modelIds.add('provider/fast');
const svc = service(
{
[SECONDARY_MODEL_SECTION]: {
force: true,
defaultModel: 'provider/fast',
defaultEffort: 'max',
},
},
true,
);

const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' });

expect(plan).toEqual({
profileName: 'coder',
model: 'provider/fast',
thinking: 'max',
fork: false,
});
});

it('inherits the caller model and thinking when the secondary-model experiment is off', async () => {
const svc = service({
[SECONDARY_MODEL_SECTION]: {
defaultModel: 'provider/fast',
models: { 'provider/fast': 'fast model' },
defaultEffort: 'max',
},
});

const plan = await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' });

expect(plan).toEqual({
profileName: 'coder',
model: 'main-model',
thinking: 'high',
fork: false,
});
});

it('skips the allowlist check when forking', async () => {
callerData = { ...callerData, profileName: 'coder', subagents: ['explore'] };
const svc = service();
Expand Down
Loading