From acacbb2d91c50ac0b76173c49442c0cc01373828 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Mon, 2 Nov 2026 08:02:57 +0800 Subject: [PATCH] Roles: refresh the live selection when re-pointing a reused Session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploying dee76b1 proved a raw durable append insufficient: the 07:54 dispatch recorded the wandering selection, yet the request assembled half a second later still carried the deepseek header, because prompt assembly reads the Session Controller's in-memory selection and the internal Agent controller caches one per Agent. Call its own selectForNextRequest primitive — append plus cache refresh, still without saving the deployment default — and fall back to the public selectModel if the surface moves. Fixture gains the Controller; two cases pin fast-path and fallback. --- src/app/role-models.ts | 14 +++++++++++--- tests/role-models.spec.ts | 23 +++++++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/app/role-models.ts b/src/app/role-models.ts index 3250ff2..5125f44 100644 --- a/src/app/role-models.ts +++ b/src/app/role-models.ts @@ -2,6 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent, ModelSelection } from '@deepseek-ai/dsh-agent' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RoleModel } from '../policy.ts' /** Resolve a configured route through the live provider; catalog membership is not a restriction. */ @@ -27,9 +28,16 @@ export async function alignRoleModel(ctx: Context, agent: Agent, model: RoleMode const selected = await resolveRoleModel(ctx, model) if (current && current.provider === selected.provider && current.model === selected.model && (selected.reasoningEffort === undefined || current.reasoningEffort === selected.reasoningEffort)) return - // Appending the durable event is what a Session-local selection is; the model-selection projection feeds it to the next - // request, and unlike selectModel it leaves the deployment-wide default alone. A mid-session switch is ordinary DSH usage. - agent.session.append('model/selection', selected) + // A durable event alone cannot re-point a live Agent: prompt assembly reads the Session Controller's in-memory selection, + // which a raw append leaves stale. `selectForNextRequest` is that Controller's own primitive — append plus cache refresh — + // and unlike the public selectModel it leaves the deployment-wide default alone. The fallback keeps correctness, at the cost + // of saving the default, if the internal surface ever moves; a mid-session switch itself is ordinary DSH usage. + const installSelection = (ctx.sessionController as unknown as { + agents?: { selectForNextRequest?(agent: Agent, selection: ModelSelection): void } + }).agents?.selectForNextRequest?.bind((ctx.sessionController as unknown as { agents: object }).agents) + if (installSelection) installSelection(agent, selected) + else await ctx.sessionController.selectModel({ sessionId: agent.id as SessionId, provider: selected.provider, model: selected.model, + ...(selected.reasoningEffort ? { reasoningEffort: selected.reasoningEffort } : {}) }) await ctx.sessionPersistence.flush() ctx.logger.info('dsh-superagent re-pointed Session %s from %s/%s to %s/%s', agent.id, current?.provider ?? 'unset', current?.model ?? 'unset', selected.provider, selected.model) diff --git a/tests/role-models.spec.ts b/tests/role-models.spec.ts index 8b559d4..a00812f 100644 --- a/tests/role-models.spec.ts +++ b/tests/role-models.spec.ts @@ -14,12 +14,17 @@ async function fixture() { ctx.reflect.provide('llm', { resolveCallConfig: resolve }) ctx.reflect.provide('sessionPersistence', { flush }) const session = Session.create(SessionId('role-model')) - const agent = { ctx, session } as Agent + const selectModel = vi.fn(async (request: { provider: string; model: string; reasoningEffort?: unknown }) => ({ + selected: { provider: request.provider, model: request.model, ...(request.reasoningEffort === void 0 ? {} : { reasoningEffort: request.reasoningEffort }) } })) + const sessionController: { agents?: { selectForNextRequest(agent: Agent, selection: ModelSelection): void }; selectModel: typeof selectModel } = { + agents: { selectForNextRequest: (target, selection) => { target.session.append('model/selection', selection) } }, selectModel } + ctx.reflect.provide('sessionController', sessionController) + const agent = { ctx, session, id: 'role-model' } as unknown as Agent const host: ModelSelectionRef = { get current() { return session.requestHeader()?.config ?? { provider: 'host', model: 'default', reasoningEffort: ReasoningEffortId('high') } }, assembled: undefined } installModelSelection(ctx, host) const request = () => agentEvents(ctx, agent).waterfall('agent/request', { turn: 1, step: 1, signal: new AbortController().signal }, async () => ({ provider: 'seed', model: 'seed' })) const record = (selection: ModelSelection) => { const event = session.append('request/header', { header: { config: selection }, reason: 'initial' }); ctx.emit('session/event', session, event) } - return { ctx, agent, host, resolve, flush, request, record } + return { ctx, agent, host, resolve, flush, request, record, sessionController } } it('keeps prompt variables and initial routing on the role model without saving the host default', async () => { const { ctx, agent, request, record, flush } = await fixture() @@ -123,4 +128,18 @@ describe('alignRoleModel', () => { expect(events[0]?.data).toEqual({ provider: 'wp', model: 'wm' }) expect(flush).toHaveBeenCalledOnce() }) + it('prefers the internal Controller primitive over the default-saving selectModel', async () => { + const { ctx, agent, sessionController } = await fixture() + const primitive = vi.fn() + sessionController.agents = { selectForNextRequest: primitive } + await alignRoleModel(ctx, agent, { provider: 'wp', model: 'wm' }) + expect(primitive).toHaveBeenCalledWith(agent, { provider: 'wp', model: 'wm' }) + expect(sessionController.selectModel).not.toHaveBeenCalled() + }) + it('falls back to selectModel when the internal Controller surface is absent', async () => { + const { ctx, agent, sessionController } = await fixture() + delete sessionController.agents + await alignRoleModel(ctx, agent, { provider: 'wp', model: 'wm' }) + expect(sessionController.selectModel).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'role-model', provider: 'wp', model: 'wm' })) + }) })