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:
parent
47e6448e23
commit
32d681f023
17 changed files with 20 additions and 43 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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` 事件序号(含该事件)的源事件(默认:当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。必须在轮次中途分支的工具时委派会裁剪到已完成前缀。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {} },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue