Roles: re-point reused Sessions when a role model drifts
A role model only seeded brand-new Sessions, so Workers created before a settings change kept their old route forever — the operator's complaint that the default model 'did not take effect'. On dispatch, ensure() now compares the durable selection or header of an existing Session against the configured role model and appends a model/selection event when they drift, leaving the deployment default alone. Seven unit cases cover drift, idempotence, effort, and precedence.
This commit is contained in:
@@ -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 } : {}) }
|
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<void> {
|
||||||
|
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. */
|
/** 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<void> {
|
export async function initializeRoleModel(ctx: Context, agent: Agent, model: RoleModel | null): Promise<void> {
|
||||||
if (!model) return
|
if (!model) return
|
||||||
|
|||||||
+4
-2
@@ -13,7 +13,7 @@ import { AgentDelivery, messageId } from './delivery.ts'
|
|||||||
import type { Effects } from './effects.ts'
|
import type { Effects } from './effects.ts'
|
||||||
import type { Variable } from './execution-environment.ts'
|
import type { Variable } from './execution-environment.ts'
|
||||||
import { RetryHealth } from './retry-health.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 { bundledSkills } from './skill-files.ts'
|
||||||
import { SKILL_PREFIX, type PromptVersion, type Prompts } from './prompts.ts'
|
import { SKILL_PREFIX, type PromptVersion, type Prompts } from './prompts.ts'
|
||||||
import type { Store } from './store.ts'
|
import type { Store } from './store.ts'
|
||||||
@@ -157,7 +157,9 @@ export class Sessions {
|
|||||||
}
|
}
|
||||||
if ('error' in result) throw result.error
|
if ('error' in result) throw result.error
|
||||||
const settings = this.host.store.settings()
|
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)
|
await this.decorate(result.agent)
|
||||||
ctx.permissionPresets.set(result.agent.session, 'danger-full-access')
|
ctx.permissionPresets.set(result.agent.session, 'danger-full-access')
|
||||||
await ctx.sessionController.rename({ sessionId: id as SessionId, title })
|
await ctx.sessionController.rename({ sessionId: id as SessionId, title })
|
||||||
|
|||||||
@@ -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 { Context } from '@deepseek-ai/cordis'
|
||||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||||
import { agentEvents, installModelSelection, type Agent, type ModelSelection, type ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
import { agentEvents, installModelSelection, type Agent, type ModelSelection, type ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
||||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
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[] = []
|
const contexts: Context[] = []
|
||||||
afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())); vi.restoreAllMocks() })
|
afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())); vi.restoreAllMocks() })
|
||||||
async function fixture() {
|
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')
|
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)
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user