fix(subagent): preserve empty restored model selection

Detect explicit empty seeds through the existing end-seed marker and remove the redundant Session.seeded API.
This commit is contained in:
_Kerman 2026-08-28 14:55:46 +08:00
parent 47e6448e23
commit 32d681f023
17 changed files with 20 additions and 43 deletions

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md
2026-08-24-user-authorized-subagent-model-routes.md: fce0026f6504298d2212ae52ad11aac862fc7e89
2026-08-24-user-authorized-subagent-model-routes.zh.md: 2cdf42dd3394ea69072dd3cc5bcc2c90fd0b5e48
2026-08-24-user-authorized-subagent-model-routes.md: ef6f90d2cc1ec79e1381d75fd8cc25c568af3067
2026-08-24-user-authorized-subagent-model-routes.zh.md: 23519bfc6619718e1cbf98dbad65eccd90353c0a

View file

@ -12,7 +12,7 @@ Registering an LLM adapter makes its routes reachable, but does not authorize an
The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `session/modelCatalog`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process.
A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled.
A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a legacy Session without the event remains disabled, including an explicitly empty restored Session.
The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice.

View file

@ -12,7 +12,7 @@ Status: implemented
Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `session/modelCatalog` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。
设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。
设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而没有该事件的旧 Session 仍保持禁用,包括显式为空的恢复 Session。
固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session.md
session.md: 208820e006b148e2eb0f346dc29967171737569c
session.zh.md: 7eecf562595e92778017b954fb45dd86c6dcb9cf
session.md: 9e19596f702f5ca7f219be46fb122ec9f00659af
session.zh.md: 238638f868047fba31c1891ca678a29a84d5f834

View file

@ -379,13 +379,6 @@ declare class Session {
* holds an ordinary published write.
*/
readonly firstLiveSeq: number;
/**
* Whether construction received a replay, fork, or restored seed.
* Every seeded lifecycle has a `session/end-seed` marker after construction;
* this field distinguishes an explicitly empty seed from a fresh session
* without scanning the log.
*/
readonly seeded: boolean;
/**
* Create a detached session by validating and snapshotting borrowed seed
* events and storage metadata.

View file

@ -381,13 +381,6 @@ declare class Session {
* holds an ordinary published write.
*/
readonly firstLiveSeq: number;
/**
* Whether construction received a replay, fork, or restored seed.
* Every seeded lifecycle has a `session/end-seed` marker after construction;
* this field distinguishes an explicitly empty seed from a fresh session
* without scanning the log.
*/
readonly seeded: boolean;
/**
* Create a detached session by validating and snapshotting borrowed seed
* events and storage metadata.

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/session/README.md
README.md: f490c53c846c00f8d0c91b7ad7fae3f688522a31
README.zh.md: 0f32bc5ba04e6da2bc8907280ef2c8ea9dc81b9a
README.md: b583941ef8f7fb0760070b2e2168a6e9806ba9c2
README.zh.md: 6a1f5d6993af483c027b715d1e3bfdb3b06f7ab4

View file

@ -53,8 +53,6 @@ Surface events (`user/message`, `assistant/message`, `tool/result`) must declare
`session.seq` reads the current log length without materializing an array, and `session.eventAt(seq)` reads one accepted, deeply frozen event by sequence number. `session.snapshotEvents(fromSeq?, toSeqExclusive?)` materializes a frozen, stable snapshot of a half-open range; a complete current snapshot is cached until the next append. Callers that only need a length or one event use `seq` or `eventAt()`.
`session.seeded` distinguishes construction with a replay, fork, or restored seed—including an explicitly empty seed—from a fresh session without scanning for `session/end-seed`.
### Fork a session
`ctx.sessions.fork(source, boundary?, childSessionId?)` selects source events through an inclusive `boundary` seq (default: the current last event), requires the prefix to end outside an open turn, and creates a live child session with lineage metadata. A tool-time delegation that must branch mid-turn clips to a completed prefix instead.

View file

@ -53,8 +53,6 @@ session.deriveMessages() // the derived model history
`session.seq` 无需物化数组即可读取当前日志长度,`session.eventAt(seq)` 按序列号读取单个已接受且深度冻结的事件。`session.snapshotEvents(fromSeq?, toSeqExclusive?)` 会物化半开区间的冻结稳定快照;当前完整快照会缓存到下一次追加。只需要长度或单个事件的调用方使用 `seq` 或 `eventAt()`。
`session.seeded` 可区分以 replay、fork 或恢复 seed(包括显式空 seed)构造的会话与全新会话,无需扫描 `session/end-seed`。
### 派生会话的 fork
`ctx.sessions.fork(source, boundary?, childSessionId?)` 选取截至 `boundary` 事件序号(含该事件)的源事件(默认:当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。必须在轮次中途分支的工具时委派会裁剪到已完成前缀。

View file

@ -469,14 +469,6 @@ export class Session {
*/
readonly firstLiveSeq: number
/**
* Whether construction received a replay, fork, or restored seed.
* Every seeded lifecycle has a `session/end-seed` marker after construction;
* this field distinguishes an explicitly empty seed from a fresh session
* without scanning the log.
*/
readonly seeded: boolean
/**
* Create a detached session by validating and snapshotting borrowed seed
* events and storage metadata.
@ -543,7 +535,6 @@ export class Session {
}
}
this.firstLiveSeq = this.log.length
this.seeded = seed !== undefined
this.header = restoredHeader ?? snapshotSessionHeader(id, header)
// Appended here so the marker is already in `events` when a backend
// captures the creation seed: no load-time write. Re-marking is skipped

View file

@ -142,11 +142,9 @@ describe('Session', () => {
it('marks an explicitly empty seed without marking a fresh session', () => {
const fresh = Session.create(SessionId('fresh-empty'))
expect(fresh.seeded).toBe(false)
expect(fresh.snapshotEvents()).toEqual([])
const resumed = Session.create(SessionId('resumed-empty'), [])
expect(resumed.seeded).toBe(true)
expect(resumed.firstLiveSeq).toBe(0)
expect(resumed.snapshotEvents()).toMatchObject([
{ type: 'session/end-seed', seq: 0, data: {} },

View file

@ -4740,7 +4740,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Session',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n readonly seeded: boolean;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n eventAt(seq: number): SessionEvent | undefined;\n snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n eventAt(seq: number): SessionEvent | undefined;\n snapshotEvents(fromSeq: number = 0, toSeqExclusive: number = this.log.length): readonly SessionEvent[];\n get seq(): number;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
},
{
name: 'SessionAddress',

View file

@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/tool-subagent/README.md
README.md: 5520e98ddcdc4532a74cf7a8a812b9e60751eaf3
README.zh.md: 069eca4f8c9dd0bf8fd939be2569c63f88c6be0b
README.md: 8dabfe42e2aa200294820dfb0c3bc366134d6ac9
README.zh.md: b3efaae8355d998cb0f47735fac98a368c16277b

View file

@ -64,7 +64,7 @@ Under `continuable` policy, an omitted or `true` `run_in_background` starts a du
### Selecting a child LLM
Set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection` preference when each top-level Session is composed. When enabled, its non-empty exact provider/model route list is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. The tool then exposes optional `provider`, `model`, and `reasoning_effort` fields and registers the shared `list_subagent_models` tool. This mode requires a backend that advertises `agentOptions`; both in-process backends and DSH SDK support it, while ACP, Codex, and Claude Code reject it rather than ignore it.
Set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection` preference when each fresh top-level Session is composed. A restored Session without a recorded policy remains disabled, including an explicitly empty restore. When enabled, the non-empty exact provider/model route list is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. The tool then exposes optional `provider`, `model`, and `reasoning_effort` fields and registers the shared `list_subagent_models` tool. This mode requires a backend that advertises `agentOptions`; both in-process backends and DSH SDK support it, while ACP, Codex, and Claude Code reject it rather than ignore it.
A call supplies `provider` and `model` together, or supplies only an effort when configured, parent, or provider-owned defaults provide the route. Static `provider.agentRouteDefaults`, when present, form the provider/model baseline; tool configuration and model fields overlay it before route-aware effort merging and exact-route preflight. Providers without these defaults use compatible values from the parent's latest logged request, then the parent's creation options before its first request, while retaining the configured `maxTokens`. Changing the route without an explicit effort clears the inherited route-owned effort, so the selected model resolves its default. The live LLM adapter validates the effective route before child creation. Catalog membership remains advisory, so a model can use an unlisted id when its adapter accepts it.

View file

@ -64,7 +64,7 @@ kind: "package-reference"
### 选择子级 LLM
设置 `modelSelectionSettings: true`,即可在组合每个顶层 Session 时读取宿主的 `subagent-model-selection` 偏好。启用后,非空的精确 provider/model 路由列表会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。工具随后公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。此模式要求后端声明 `agentOptions`;两个进程内后端和 DSH SDK 支持该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。
设置 `modelSelectionSettings: true`,即可在组合每个全新顶层 Session 时读取宿主的 `subagent-model-selection` 偏好。没有已记录策略的恢复 Session 会保持禁用,包括显式为空的恢复。启用后,非空的精确 provider/model 路由列表会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。工具随后公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。此模式要求后端声明 `agentOptions`;两个进程内后端和 DSH SDK 支持该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。
一次调用需同时提供 `provider` 与 `model`;当配置值、父 agent 值或提供方持有的默认值能提供路由时,也可只提供推理等级。静态的 `provider.agentRouteDefaults` 在存在时构成提供方/模型基线;工具配置与模型字段会在路由相关强度合并和确切路由预检前覆盖它。没有这些默认值的提供方会使用父 agent 最新已记录请求中的兼容值,再使用父级首次请求前的创建选项,并保留配置的 `maxTokens`。更改路由但未显式提供推理等级时,会清除继承的路由自有等级,使所选模型解析自己的默认值。实时 LLM 适配器在创建子 agent 前校验有效路由。目录成员资格只提供建议,因此适配器接受时,模型可以使用未列出的 id。

View file

@ -619,6 +619,8 @@ export function apply(ctx: Context, config: Config): void {
}
const selectForAgent = (agent: NonNullable<Context['agent']>): ModelSelectionPolicy | undefined => {
const freshSession = agent.session.firstLiveSeq === 0
&& agent.session.eventAt(0)?.type !== 'session/end-seed'
let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, agent.session)
if (allowedModels === undefined) {
const parentId = agent.session.header.origin === 'subagent'
@ -629,7 +631,7 @@ export function apply(ctx: Context, config: Config): void {
allowedModels = parent === undefined
? undefined
: subagentModelSelectionPolicy(ctx.sessionProjections, parent.session)
} else if (agent.session.firstLiveSeq === 0) {
} else if (freshSession) {
const current = settings.current()
allowedModels = current.enabled ? current.allowedModels : undefined
}

View file

@ -314,6 +314,10 @@ describe('SubagentModelSelectionConfig', () => {
enabled: true,
allowedModels: ALLOWED_MODELS,
})
const resumedEmpty = await createAgent(ctx, 'resumed-empty', { seed: [] })
expect(selectable(ctx, resumedEmpty)).toBe(false)
expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedEmpty.session)).toBeUndefined()
const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.snapshotEvents() })
expect(selectable(ctx, resumedDisabled)).toBe(false)
expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedDisabled.session)).toBeUndefined()