diff --git a/src/app/role-models.ts b/src/app/role-models.ts index 5b01e6b..3250ff2 100644 --- a/src/app/role-models.ts +++ b/src/app/role-models.ts @@ -11,6 +11,30 @@ export async function resolveRoleModel(ctx: Context, model: RoleModel): Promise< return { provider: value.provider, model: value.model, ...(value.reasoningEffort ? { reasoningEffort: value.reasoningEffort } : {}) } } +/** The route a Session would take on its next request: its latest durable selection or request header, whichever came later. */ +function lastSelection(agent: Agent): ModelSelection | undefined { + for (const event of [...agent.session.snapshotEvents()].reverse()) { + if (event.type === 'model/selection') return event.data as ModelSelection + if (event.type === 'request/header') return (event.data as { header?: { config?: ModelSelection } }).header?.config + } + return undefined +} + +/** Re-point a reused Session whose durable route drifted from the configured role model, so a settings change takes effect on the next dispatch instead of only on brand-new Sessions. */ +export async function alignRoleModel(ctx: Context, agent: Agent, model: RoleModel | null): Promise { + if (!model) return + const current = lastSelection(agent) + 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) + 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) +} + /** Seed a role's configured model onto a brand-new session before its first request, leaving later manual or durable selections intact. */ export async function initializeRoleModel(ctx: Context, agent: Agent, model: RoleModel | null): Promise { if (!model) return diff --git a/src/app/sessions.ts b/src/app/sessions.ts index 77ac73e..1acaf68 100644 --- a/src/app/sessions.ts +++ b/src/app/sessions.ts @@ -13,7 +13,7 @@ import { AgentDelivery, messageId } from './delivery.ts' import type { Effects } from './effects.ts' import type { Variable } from './execution-environment.ts' import { RetryHealth } from './retry-health.ts' -import { initializeRoleModel } from './role-models.ts' +import { alignRoleModel, initializeRoleModel } from './role-models.ts' import { bundledSkills } from './skill-files.ts' import { SKILL_PREFIX, type PromptVersion, type Prompts } from './prompts.ts' import type { Store } from './store.ts' @@ -157,7 +157,9 @@ export class Sessions { } if ('error' in result) throw result.error const settings = this.host.store.settings() - await initializeRoleModel(ctx, result.agent, role === 'orchestrator' ? settings.eval.model : settings.executor.model) + const roleModel = role === 'orchestrator' ? settings.eval.model : settings.executor.model + await initializeRoleModel(ctx, result.agent, roleModel) + if (!created) await alignRoleModel(ctx, result.agent, roleModel) await this.decorate(result.agent) ctx.permissionPresets.set(result.agent.session, 'danger-full-access') await ctx.sessionController.rename({ sessionId: id as SessionId, title }) diff --git a/tests/role-models.spec.ts b/tests/role-models.spec.ts index 1be1405..8b559d4 100644 --- a/tests/role-models.spec.ts +++ b/tests/role-models.spec.ts @@ -1,10 +1,10 @@ -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { agentEvents, installModelSelection, type Agent, type ModelSelection, type ModelSelectionRef } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import { initializeRoleModel } from '../src/app/role-models.ts' +import { alignRoleModel, initializeRoleModel } from '../src/app/role-models.ts' const contexts: Context[] = [] afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())); vi.restoreAllMocks() }) async function fixture() { @@ -63,3 +63,64 @@ it('inherits the host when unset and fails unavailable routes before recording a await expect(initializeRoleModel(ctx, agent, { provider: 'unknown', model: 'x' })).rejects.toThrow('unknown provider') expect(agent.session.snapshotEvents().some(event => event.type === 'model/selection')).toBe(false) }) +describe('alignRoleModel', () => { + const selections = (agent: Agent) => agent.session.snapshotEvents().filter(event => event.type === 'model/selection') + it('re-points a reused Session whose durable header drifted from the configured model', async () => { + const { ctx, agent, flush, record } = await fixture() + record({ provider: 'deepseek-official', model: 'deepseek-flash', reasoningEffort: ReasoningEffortId('low') }) + await alignRoleModel(ctx, agent, { provider: 'wp', model: 'wm' }) + const events = selections(agent) + expect(events).toHaveLength(1) + expect(events[0]?.data).toEqual({ provider: 'wp', model: 'wm' }) + expect(flush).toHaveBeenCalledOnce() + }) + it('is an idempotent no-op when the durable selection already matches the configured model', async () => { + const { ctx, agent, resolve, flush } = await fixture() + agent.session.append('model/selection', { provider: 'wp', model: 'wm' }) + flush.mockClear() + await alignRoleModel(ctx, agent, { provider: 'wp', model: 'wm' }) + expect(resolve).toHaveBeenCalledOnce() + expect(selections(agent)).toHaveLength(1) + expect(flush).not.toHaveBeenCalled() + }) + it('returns without resolving or recording anything when no model is configured', async () => { + const { ctx, agent, resolve, flush } = await fixture() + await alignRoleModel(ctx, agent, null) + expect(resolve).not.toHaveBeenCalled() + expect(selections(agent)).toHaveLength(0) + expect(flush).not.toHaveBeenCalled() + }) + it('treats an equal configured reasoning effort as the same route', async () => { + const { ctx, agent, flush } = await fixture() + agent.session.append('model/selection', { provider: 'wp', model: 'wm', reasoningEffort: ReasoningEffortId('low') }) + await alignRoleModel(ctx, agent, { provider: 'wp', model: 'wm', reasoningEffort: 'low' }) + expect(selections(agent)).toHaveLength(1) + expect(flush).not.toHaveBeenCalled() + }) + it('re-points when only the reasoning effort differs and records the configured effort', async () => { + const { ctx, agent, flush } = await fixture() + agent.session.append('model/selection', { provider: 'wp', model: 'wm', reasoningEffort: ReasoningEffortId('low') }) + await alignRoleModel(ctx, agent, { provider: 'wp', model: 'wm', reasoningEffort: 'high' }) + const events = selections(agent) + expect(events).toHaveLength(2) + expect(events[1]?.data).toEqual({ provider: 'wp', model: 'wm', reasoningEffort: ReasoningEffortId('high') }) + expect(flush).toHaveBeenCalledOnce() + }) + it('follows a selection that supersedes an older header instead of the header', async () => { + const { ctx, agent, flush, record } = await fixture() + record({ provider: 'old', model: 'header' }) + agent.session.append('model/selection', { provider: 'wp', model: 'wm' }) + flush.mockClear() + await alignRoleModel(ctx, agent, { provider: 'wp', model: 'wm' }) + expect(selections(agent)).toHaveLength(1) + expect(flush).not.toHaveBeenCalled() + }) + it('treats a Session with no durable route as drift and seeds the configured model', async () => { + const { ctx, agent, flush } = await fixture() + await alignRoleModel(ctx, agent, { provider: 'wp', model: 'wm' }) + const events = selections(agent) + expect(events).toHaveLength(1) + expect(events[0]?.data).toEqual({ provider: 'wp', model: 'wm' }) + expect(flush).toHaveBeenCalledOnce() + }) +})