diff --git a/docs/006-deployment.md b/docs/006-deployment.md index d4e810f..456a472 100644 --- a/docs/006-deployment.md +++ b/docs/006-deployment.md @@ -23,7 +23,7 @@ Both presets run with `danger-full-access` as the operator's OS user and read me - Node.js 22.19+ or 24+, pnpm, git 2.32+, Python 3.8+ and tmux - GitHub CLI (`gh`) 2.48 or later. Source CLI fields must point to `gh` or `lark-cli` wrappers; the host relies on their subcommands and output formats. - A repository on github.com; GitHub Enterprise Server is not supported. To create a checkout per Ticket, keep a local clone from which the prepare command can add worktrees. Install `git-lfs` if that repository uses Git LFS. -- A model provider configured for DeepSeek Harness. dsh-superagent uses the profile's default model unless the Eval or Execution settings name another. +- A model provider configured for DeepSeek Harness. dsh-superagent uses the profile's default model unless the Eval or Execution settings name another (optionally with a reasoning effort); a reused Session is re-pointed to its role's configured model on the next dispatch. - For Feishu: `lark-cli` (tested with 1.0.90) and a Feishu or Lark app with a bot ## Accounts diff --git a/docs/007-architecture.md b/docs/007-architecture.md index 7205daa..b202f22 100644 --- a/docs/007-architecture.md +++ b/docs/007-architecture.md @@ -334,8 +334,8 @@ Some generic modules still refer directly to GitHub or Feishu: | `src/app/sessions.ts` | Sessions | | `src/app/delivery.ts` | `AgentDelivery`: delivers a message to a DeepSeek Harness agent once per request id, also across a restored Session | | `src/app/retry-health.ts` | `RetryHealth`: which Sessions wait on a model budget retry, for the `models` health entry | -| `src/app/role-models.ts` | Resolves the Eval and Executor models, and seeds a new Session with its role's model | -| `src/app/default-model.ts` | `switchDefaultModel`: changes the deployment default model through the Session Controller, which persists it into the profile, and refuses a route a new Session could not use | +| `src/app/role-models.ts` | Resolves the Eval and Executor models: seeds a new Session with its role's model and re-points a reused Session whose durable route drifted | +| `src/app/default-model.ts` | `switchDefaultModel`: changes the deployment default model, optionally with a reasoning effort the catalog offers, through the Session Controller, which persists it into the profile | | `src/app/assessment.ts` | The MasterAssessor, the assessment schema and the window `orchestrator_eval` answers | | `src/app/execution-environment.ts` | The execution environment: its variables, a Ticket's values, the directory root and the lines of the prompt section, which `src/app/prompts.ts` fills into its template | | `src/app/source-context.ts` | `sourceContextOf`: each Source kind's `SourceContext` | diff --git a/src/app/default-model.ts b/src/app/default-model.ts index 7ec8329..162c249 100644 --- a/src/app/default-model.ts +++ b/src/app/default-model.ts @@ -14,7 +14,7 @@ import { RoleModelSchema } from '../policy.ts' /** Why a default-model switch was refused, localized by the panel through `defaultModelError.`. */ export class DefaultModelError extends Error { - constructor(readonly reason: 'unknownProvider' | 'unknownModel' | 'unchanged' | 'unsaved', detail?: string) { + constructor(readonly reason: 'unknownProvider' | 'unknownModel' | 'unknownEffort' | 'unchanged' | 'unsaved', detail?: string) { super(detail ?? reason) } } @@ -52,8 +52,14 @@ export async function switchDefaultModel(ctx: Context, masterId: string, input: const wanted = RoleModelSchema.parse(input) const catalog = await ctx.sessionController.modelCatalog() checked(catalog, wanted.provider, wanted.model) - // The picker sends provider and model only; the Controller resolves the effort, so effort never makes a route "changed". + const entry = catalog.groups.find(group => group.id === wanted.provider)?.models.find(model => model.id === wanted.model) + if (wanted.reasoningEffort && !entry?.reasoning?.efforts.some(effort => effort.id === wanted.reasoningEffort)) { + throw new DefaultModelError('unknownEffort', `Model "${wanted.provider}/${wanted.model}" does not offer effort "${wanted.reasoningEffort}"`) + } + // The Controller resolves an implicit effort to the model's default, so compare against the effort the switch will settle on. + const predicted = wanted.reasoningEffort ?? entry?.reasoning?.defaultEffort const unchanged = catalog.default.provider === wanted.provider && catalog.default.model === wanted.model + && (catalog.default.reasoningEffort ?? '') === (predicted ?? '') if (unchanged) throw new DefaultModelError('unchanged') // The Controller resolves the call config, so the selection it accepts may carry an effort the caller left implicit. const { selected } = await ctx.sessionController.selectModel({ sessionId: masterId as SessionId, provider: wanted.provider, diff --git a/src/client/client.css b/src/client/client.css index eb1ce86..b2388ff 100644 --- a/src/client/client.css +++ b/src/client/client.css @@ -389,3 +389,4 @@ a.dt-btn, a.dt-btn:hover { text-decoration: none; } .dt-model-option:hover:not(:disabled), .dt-model-option:focus-visible { background: var(--dsw-alias-bg-layer-1); } .dt-model-option[aria-selected='true'] { color: var(--dsw-alias-brand-primary); font-weight: 600; } .dt-model-picker-error { color: var(--dsw-alias-state-error-primary); font-size: 13px; } +.dt-model-efforts { position: static; max-height: none; min-width: 0; border: 0; box-shadow: none; background: transparent; padding: 0 0 2px 14px; z-index: auto; } diff --git a/src/client/default-model.tsx b/src/client/default-model.tsx index 0632d12..0fcf8a2 100644 --- a/src/client/default-model.tsx +++ b/src/client/default-model.tsx @@ -2,7 +2,9 @@ * Header-level default-model switcher: one dropdown that changes the DSH deployment default for every new Session. * * The list comes from `GET models` — the same catalog the DSH picker renders — so an operator switches between exactly the - * routes a new Executor or Assessor Session could inherit, and rejects come back as localized reasons. + * routes a new Executor or Assessor Session could inherit, and rejects come back as localized reasons. Models whose catalog + * entry advertises reasoning efforts expand into a second tier of effort choices; the tier's first entry switches the model + * without an explicit effort, which is exactly what DSH resolves when a Session picks the model bare. */ import { useState } from 'react' import { ApiError, usePoll, type ApiResponse } from './api.ts' @@ -13,16 +15,19 @@ import type { T } from './text.ts' export type DefaultModelCatalog = ApiResponse<'GET models'> /** Render a selection the way the operator reads one elsewhere in the panel. */ -const selection = (model: { provider: string; model: string }): string => `${model.provider} · ${model.model}` +const selection = (model: { provider: string; model: string; reasoningEffort?: string }): string => + `${model.provider} · ${model.model}${model.reasoningEffort ? ` · ${model.reasoningEffort}` : ''}` export function DefaultModelPicker() { const { t, call } = usePanel() const { reload: reloadData } = usePanelData() const [open, setOpen] = useState(false) + const [expanded, setExpanded] = useState() const { data: catalog, error, reload } = usePoll(() => call('GET models'), [], 30_000) - const pick = useAction(async (provider: string, model: string) => { + const pick = useAction(async (provider: string, model: string, effort?: string) => { setOpen(false) - await call('PUT default-model', { body: { provider, model } }) + setExpanded(undefined) + await call('PUT default-model', { body: { provider, model, ...effort ? { reasoningEffort: effort } : {} } }) await reload() await reloadData() }, { failure: failure => defaultModelRefusal(t, failure) }) @@ -30,22 +35,46 @@ export function DefaultModelPicker() { if (!catalog) return {t('loading')} const current = catalog.default const options = catalog.groups.flatMap(group => group.models.map(model => - ({ provider: group.id, model: model.id, text: `${group.name} · ${model.name}` }))) + ({ provider: group.id, model, text: `${group.name} · ${model.name}` }))) return
{open &&
    - {options.map(option =>
  • - -
  • )} + {options.map(option => { + const key = `${option.provider}/${option.model.id}` + const isCurrent = option.provider === current.provider && option.model.id === current.model + const efforts = option.model.reasoning?.efforts ?? [] + return
  • + + {expanded === key && efforts.length > 0 &&
      +
    • + +
    • + {efforts.map(effort =>
    • + +
    • )} +
    } +
  • + })}
}
} @@ -55,5 +84,5 @@ export function defaultModelRefusal(t: T, failure: unknown): string { const code = failure instanceof ApiError ? failure.details.code : undefined const reason = code?.startsWith('defaultModel.') ? code.slice('defaultModel.'.length) : undefined const text = failure instanceof Error ? failure.message : String(failure) - return reason ? t(`defaultModelError.${reason as 'unknownProvider' | 'unknownModel' | 'unchanged' | 'unsaved'}`) : text + return reason ? t(`defaultModelError.${reason as 'unknownProvider' | 'unknownModel' | 'unknownEffort' | 'unchanged' | 'unsaved'}`) : text } diff --git a/src/client/locales.ts b/src/client/locales.ts index 09b2283..f6d1c6b 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -424,9 +424,12 @@ const TEXT = { modelCatalogError: ['无法加载模型建议,仍可填写已知的服务商和模型:{error}', 'Model suggestions are unavailable; known Provider and Model names still work: {error}'], defaultModelQuick: ['默认模型', 'Default model'], defaultModelQuickTip: ['切换后,之后新建的执行器与研判 Session 都继承这个默认模型;已有 Session 保持原选择。', 'Switching changes what new Executor and Assessor Sessions inherit; existing Sessions keep their choice.'], + defaultModelEffortTitle: ['思考强度', 'Reasoning effort'], + defaultModelEffortDefault: ['模型默认', 'Model default'], 'defaultModelError.load': ['无法读取默认模型列表', 'Cannot load the default-model list'], 'defaultModelError.unknownProvider': ['这个服务商当前没有可用模型。', 'That Provider has no available models right now.'], 'defaultModelError.unknownModel': ['这个服务商没有提供该模型。', 'That Provider does not offer that model.'], + 'defaultModelError.unknownEffort': ['这个模型不支持该思考强度。', 'That model does not offer that reasoning effort.'], 'defaultModelError.unchanged': ['已经是默认模型。', 'That is already the default.'], 'defaultModelError.unsaved': ['Session 已切换,但 DSH 没有保存成新的默认模型;请重试或检查配置编辑权限。', 'The Session switched, but DSH did not save the new default; retry or check the configuration editor.'], historyIntro: ['每次保存记一条;改变策略版本的保存(接单规则、各来源上的策略、接单模式)通知进行中的工单。', 'Each save is one entry; saves that change the Policy version (rules, the Policy on each Source, accept mode) notify open Tickets.'], diff --git a/tests/default-model.spec.ts b/tests/default-model.spec.ts index 0952cb3..086197f 100644 --- a/tests/default-model.spec.ts +++ b/tests/default-model.spec.ts @@ -11,10 +11,17 @@ const MASTER = 'master-1' /** One model route as the catalog reports it. */ const selection = (provider: string, model: string, reasoningEffort?: string): ModelSelection => ({ provider, model, ...reasoningEffort ? { reasoningEffort } : {} }) -/** A catalog whose default is `current` and whose routable providers offer exactly `models`, keyed by provider id. */ -function catalog(current: ModelSelection, models: Record = { host: ['default', 'next'] }): ModelCatalog { +/** Reasoning metadata offered by an exact `provider/model` route, keyed by `${provider}/${model}`. */ +type ReasoningByRoute = Record +/** A catalog whose default is `current` and whose routable providers offer exactly `models`, keyed by provider id; + * `reasoning` attaches selectable-effort metadata to the exact `provider/model` routes it names. */ +function catalog(current: ModelSelection, models: Record = { host: ['default', 'next'] }, + reasoning: ReasoningByRoute = {}): ModelCatalog { return { default: current, routableProviders: Object.keys(models), failures: [], - groups: Object.entries(models).map(([id, ids]) => ({ id, name: id, models: ids.map(model => ({ id: model, name: model })) })) } + groups: Object.entries(models).map(([id, ids]) => ({ id, name: id, models: ids.map(model => { + const route = `${id}/${model}` + return reasoning[route] ? { id: model, name: model, reasoning: reasoning[route] } : { id: model, name: model } + }) })) } } /** * A fake Host context: `modelCatalog` keeps reporting `initial` until `selectModel` runs, when it starts reporting `saved` @@ -88,13 +95,60 @@ describe('default model switch', () => { }) it('forwards reasoning effort as a branded id and reports the switch saved only when it matches', async () => { - const host = controller(catalog(selection('host', 'default')), catalog(selection('host', 'next', 'high'))) + const efforts = [{ id: 'high', name: 'High' }] + const host = controller(catalog(selection('host', 'default'), { host: ['default', 'next'] }, { 'host/next': { efforts } }), + catalog(selection('host', 'next', 'high'), { host: ['default', 'next'] }, { 'host/next': { efforts } })) await expect(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next', reasoningEffort: 'high' })) .resolves.toEqual(selection('host', 'next', 'high')) const request = host.selectModel.mock.calls[0]?.[0] expect(request?.reasoningEffort).toBe(ReasoningEffortId('high')) expect(String(request?.reasoningEffort)).toBe('high') }) + + it('switches onto a model with a supported effort, branding the selectModel request', async () => { + const efforts = [{ id: 'low', name: 'Low' }, { id: 'high', name: 'High' }] + const host = controller(catalog(selection('host', 'default'), { host: ['default', 'next'] }, { 'host/next': { efforts } }), + catalog(selection('host', 'next', 'low'))) + await expect(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next', reasoningEffort: 'low' })) + .resolves.toEqual(selection('host', 'next', 'low')) + expect(host.selectModel).toHaveBeenCalledOnce() + expect(host.selectModel).toHaveBeenCalledWith({ sessionId: MASTER, provider: 'host', model: 'next', reasoningEffort: ReasoningEffortId('low') }) + }) + + it('refuses an effort the model does not offer, without touching the Session selection', async () => { + const efforts = [{ id: 'low', name: 'Low' }, { id: 'high', name: 'High' }] + const host = controller(catalog(selection('host', 'default'), { host: ['default', 'next'] }, { 'host/next': { efforts } })) + const failure = await refused(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next', reasoningEffort: 'ultra' })) + expect(failure.reason).toBe('unknownEffort') + expect(host.selectModel).not.toHaveBeenCalled() + expect(host.modelCatalog).toHaveBeenCalledOnce() + }) + + it('refuses a reasoning effort for a model that offers none', async () => { + const host = controller(catalog(selection('host', 'default'))) + const failure = await refused(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next', reasoningEffort: 'low' })) + expect(failure.reason).toBe('unknownEffort') + expect(host.selectModel).not.toHaveBeenCalled() + }) + + it('treats an effort change alone as a valid switch away from the model default', async () => { + const efforts = [{ id: 'low', name: 'Low' }, { id: 'high', name: 'High' }] + const host = controller(catalog(selection('host', 'next', 'high'), { host: ['default', 'next'] }, { 'host/next': { efforts, defaultEffort: 'high' } }), + catalog(selection('host', 'next', 'low'))) + await expect(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next', reasoningEffort: 'low' })) + .resolves.toEqual(selection('host', 'next', 'low')) + expect(host.selectModel).toHaveBeenCalledTimes(1) + expect(host.selectModel).toHaveBeenCalledWith({ sessionId: MASTER, provider: 'host', model: 'next', reasoningEffort: ReasoningEffortId('low') }) + }) + + it('refuses an unchanged route whose implicit effort settles on the current default', async () => { + const efforts = [{ id: 'low', name: 'Low' }, { id: 'high', name: 'High' }] + const host = controller(catalog(selection('host', 'next', 'high'), { host: ['default', 'next'] }, { 'host/next': { efforts, defaultEffort: 'high' } })) + const failure = await refused(switchDefaultModel(host.ctx, MASTER, { provider: 'host', model: 'next' })) + expect(failure.reason).toBe('unchanged') + expect(host.selectModel).not.toHaveBeenCalled() + expect(host.modelCatalog).toHaveBeenCalledOnce() + }) }) describe('default model refusal', () => { @@ -105,6 +159,11 @@ describe('default model refusal', () => { expect(defaultModelRefusal(t, failure)).toBe('[defaultModelError.unknownProvider]') }) + it('localizes a refusal the host tagged with an unknownEffort code', () => { + const failure = new ApiError('Model "host/next" does not offer effort "ultra"', 400, { code: 'defaultModel.unknownEffort' }) + expect(defaultModelRefusal(t, failure)).toBe('[defaultModelError.unknownEffort]') + }) + it('returns error text that names no defaultModel code unchanged', () => { const failure = new Error('Provider "ghost" has no available models') expect(defaultModelRefusal(t, failure)).toBe('Provider "ghost" has no available models')