/** * Agent-scoped model selection shared by runtime entry points. * @module @deepseek-ai/dsh-agent/model-selection */ import type { Context } from '@deepseek-ai/cordis' import { boundContextSummary, createUserMessage, type LlmCallConfig, type ReasoningEffortId, } from '@deepseek-ai/dsh-llm' import type { PreStepDecision } from './runtime-types.ts' /** Complete provider, model, and optional reasoning effort selected for one live Agent. */ export interface ModelSelection { /** Registered provider route. */ provider: string /** Provider-owned model id. */ model: string /** Adapter-owned reasoning effort, or provider/default behavior when absent. */ reasoningEffort?: ReasoningEffortId } /** Mutable model selection plus the value captured for the current step. */ export interface ModelSelectionRef { /** Model selected for the next step that enters prompt assembly. */ current: ModelSelection | undefined /** Selection captured when the current step entered prompt assembly. */ assembled: ModelSelection | undefined } function sameRoute(left: ModelSelection, right: ModelSelection): boolean { return left.provider === right.provider && left.model === right.model } function routeLabel(route: ModelSelection, other: ModelSelection): string { return route.provider === other.provider ? route.model : `${route.provider}/${route.model}` } function modelSwitchNotice(previous: ModelSelection, selected: ModelSelection) { const from = routeLabel(previous, selected) const to = routeLabel(selected, previous) return createUserMessage({ content: [{ type: 'text' as const, text: `[model changed: assistant turns above this point were generated by ${from}; the session continues with ${to}]`, }], source: { kind: 'plugin' as const, plugin: 'model-selection', form: 'notice' as const, summary: boundContextSummary(`${from} → ${to}`), }, }) } /** * Couple one mutable selection to Agent-scoped prompt assembly and request routing. * Prompt assembly snapshots the selected model before delegating, then applies * its provider/model pair and effort to request config so a * concurrent switch takes effect on a later step instead of splitting the two * surfaces. An absent selected effort clears any inherited effort, restoring * the selected model's provider/default behavior. * * A provider/model change appends a durable user-role notice to the next * admitted request. It compares the assembled selection with the latest * request header; effort-only changes and empty no-request decisions add no * notice. Failure before header persistence repeats the notice on the next * request. * * @param agentCtx - The selected Agent's scoped context. * @param selection - Mutable selection owned by the calling entry point. * @returns Disposer for all scoped waterfall listeners. */ export function installModelSelection(agentCtx: Context, selection: ModelSelectionRef): () => void { const disposeAssembly = agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => { const selected = selection.current const assembled = await next() selection.assembled = selected if (selected === undefined) return assembled return { ...assembled, variables: { ...assembled.variables, provider: selected.provider, model: selected.model, }, } }) const disposeRequest = agentCtx.on( 'agent/request', async (_payload, next): Promise => { const resolved = await next() const selected = selection.assembled if (selected === undefined) return resolved const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved return { ...withoutInheritedEffort, provider: selected.provider, model: selected.model, ...selected.reasoningEffort === undefined ? {} : { reasoningEffort: selected.reasoningEffort }, } }, ) const disposeNotice = agentCtx.on( 'agent/pre-step', async ({ agent, messages, signal, step }, next): Promise => { const decision = await next() if (decision.kind === 'reject' || signal.aborted) return decision // The loop skips an empty first step and an emptied offered continuation. if (decision.messages.length === 0 && (step === 1 || messages.length > 0)) return decision const selected = selection.assembled const previous = agent.session.requestHeader()?.config if (selected === undefined || previous === undefined || sameRoute(selected, previous)) return decision return { ...decision, messages: [...decision.messages, modelSwitchNotice(previous, selected)] } }, { prepend: true }, ) return () => { disposeAssembly() disposeRequest() disposeNotice() } }