From f41541edaba98cc6ce06930f04ee1c5c71a796ab Mon Sep 17 00:00:00 2001 From: "user.email" Date: Thu, 8 Oct 2026 22:06:21 -0700 Subject: [PATCH 1/4] feat(provider): add deepseek-v4 per-family adapter Lift the DeepSeek V4 wire quirks out of the generic openai-compatible adapter into a bifrost-style deepseek-v4 adapter, registered in the AdapterManifest and selected by a deepseek-v4 provider id stamped on the source for /^deepseek-v4/i models. Removes the V4 branch from the generic adapter, keeping the pre-V4 reasoning_content strip and the NIM null-delta patch. Relocates the V4 effort/settings tests. --- src/config/index.ts | 11 +- src/config/inference-sources.test.ts | 30 +++ src/provider/deepseek-v4-adapter.test.ts | 208 ++++++++++++++++++ src/provider/deepseek-v4-adapter.ts | 71 ++++++ src/provider/inference-dependencies.ts | 8 + .../openai-compatible-adapter.test.ts | 194 ---------------- src/provider/openai-compatible-adapter.ts | 57 +---- 7 files changed, 328 insertions(+), 251 deletions(-) create mode 100644 src/provider/deepseek-v4-adapter.test.ts create mode 100644 src/provider/deepseek-v4-adapter.ts diff --git a/src/config/index.ts b/src/config/index.ts index 18de85188..b323cf977 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -59,6 +59,8 @@ import { GROK_SESSION_ID_OPTION, } from "../provider/grok-responses.js"; import { BIFROST_PROVIDER } from "../provider/bifrost-adapter.js"; +import { DEEPSEEK_V4_PROVIDER } from "../provider/deepseek-v4-adapter.js"; +import { isDeepSeekV4Model } from "../provider/deepseek-v4-effort.js"; import { isOllamaProviderId, ollamaOpenAIBaseURL } from "../provider/ollama.js"; import { selectableGoModelIds } from "../provider/model-catalogs.js"; import { @@ -265,8 +267,9 @@ export function resolveMcpServers( } // Build the OpenAI-compatible InferenceSource the runtime consumes. `id` is the -// user-facing name for this source (e.g. "zen"); `provider` is always -// "openai-compatible" so the inference registry routes it to the right adapter. +// user-facing name for this source (e.g. "zen"); `provider` routes to the +// openai-compatible adapter, or the deepseek-v4 per-family adapter for V4 +// models, so the inference registry picks the right adapter. export function buildOpenAISource(fields: { id: string; baseURL: string; @@ -287,7 +290,9 @@ export function buildOpenAISource(fields: { registerSourceSecret(fields.id, fields.apiKey); return { id: fields.id, - provider: "openai-compatible", + provider: isDeepSeekV4Model(fields.model) + ? DEEPSEEK_V4_PROVIDER + : "openai-compatible", baseURL: isOllamaProviderId(fields.id) ? ollamaOpenAIBaseURL(fields.baseURL) : normalizeOpenAICompatibleBaseURL(fields.baseURL), diff --git a/src/config/inference-sources.test.ts b/src/config/inference-sources.test.ts index 3142fb7ea..eca705a60 100644 --- a/src/config/inference-sources.test.ts +++ b/src/config/inference-sources.test.ts @@ -794,3 +794,33 @@ describe("session source bundles", () => { expect(bundle.defaultSource).toBe("openai"); }); }); + +describe("deepseek-v4 provider stamping (PR A)", () => { + function v4SourceFor(model: string) { + return buildInferenceSourceForRef( + { provider: "ds", model }, + { + sessionId: "s1", + catalog: [ + { + name: "ds", + baseURL: "https://ds.example/v1", + apiKey: "k", + models: [model], + }, + ], + }, + undefined, + ); + } + + test("a deepseek-v4-* model stamps provider 'deepseek-v4'", () => { + const source = v4SourceFor("deepseek-v4-pro"); + expect(source?.provider).toBe("deepseek-v4"); + }); + + test("non-V4 custom models stay provider 'openai-compatible'", () => { + const source = v4SourceFor("gpt-5"); + expect(source?.provider).toBe("openai-compatible"); + }); +}); diff --git a/src/provider/deepseek-v4-adapter.test.ts b/src/provider/deepseek-v4-adapter.test.ts new file mode 100644 index 000000000..0c92df8cc --- /dev/null +++ b/src/provider/deepseek-v4-adapter.test.ts @@ -0,0 +1,208 @@ +import { describe, test, expect } from "bun:test"; +import type { ConversationTurn, InferenceOptions } from "@intx/types/runtime"; +import { + createDeepSeekV4Adapter, + DEEPSEEK_V4_PROVIDER, +} from "./deepseek-v4-adapter.js"; +import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; + +const source = { + id: "test", + provider: DEEPSEEK_V4_PROVIDER, + baseURL: "https://example.test", + apiKey: "sk-test", + model: "deepseek-v4-pro", +} as unknown as Parameters[0]; + +const messages: ConversationTurn[] = [ + { + role: "user", + content: [{ type: "text", text: "hi" }], + } as unknown as ConversationTurn, +]; + +function bodyFor(options: InferenceOptions): Record { + const adapter = createDeepSeekV4Adapter(source); + const built = adapter.buildRequest(messages, "deepseek-v4-pro", options); + return JSON.parse(built.body) as Record; +} + +describe("deepseek-v4 adapter effort wiring", () => { + test.each(["xhigh", "max"] as const)( + "wires V4 effort %s through raw and turns thinking on", + (effort) => { + const body = bodyFor({ + providerOptions: { reasoning_effort: effort }, + } as InferenceOptions); + expect(body["reasoning_effort"]).toBe(effort); + expect(body["chat_template_kwargs"]).toEqual({ thinking: true }); + }, + ); + + test("none → chat_template_kwargs.thinking false and NO reasoning_effort", () => { + const body = bodyFor({ + providerOptions: { reasoning_effort: "none" }, + } as InferenceOptions); + expect(body["chat_template_kwargs"]).toEqual({ thinking: false }); + expect("reasoning_effort" in body).toBe(false); + }); + + test("off-ladder effort is left untouched (no high→xhigh coercion)", () => { + const body = bodyFor({ + providerOptions: { reasoning_effort: "high" }, + } as InferenceOptions); + expect(body["reasoning_effort"]).toBe("high"); + expect("chat_template_kwargs" in body).toBe(false); + }); + + test("unset effort does not inject empty chat_template_kwargs", () => { + const body = bodyFor({} as InferenceOptions); + expect("chat_template_kwargs" in body).toBe(false); + expect(body["reasoning_effort"]).toBeUndefined(); + }); +}); + +describe("deepseek-v4 adapter request settings", () => { + test("stream_options include_usage is true", () => { + const body = bodyFor({} as InferenceOptions); + expect(body["stream_options"]).toEqual({ include_usage: true }); + }); + + test("top_p 0.95 and temperature 1.0 defaulted when absent", () => { + const body = bodyFor({} as InferenceOptions); + expect(body["top_p"]).toBe(0.95); + expect(body["temperature"]).toBe(1.0); + }); + + test("top_p and temperature preserved when providerOptions set them", () => { + const body = bodyFor({ + providerOptions: { temperature: 0.7, top_p: 0.9 }, + } as InferenceOptions); + expect(body["top_p"]).toBe(0.9); + expect(body["temperature"]).toBe(0.7); + }); + + test("provider-set stream_options is preserved (absent-only)", () => { + const body = bodyFor({ + providerOptions: { stream_options: { include_usage: false } }, + } as InferenceOptions); + expect(body["stream_options"]).toEqual({ include_usage: false }); + }); + + test("chat_template_kwargs merge preserves existing keys", () => { + const body = bodyFor({ + providerOptions: { + reasoning_effort: "xhigh", + chat_template_kwargs: { some: "existing" }, + }, + } as InferenceOptions); + expect(body["chat_template_kwargs"]).toEqual({ + thinking: true, + some: "existing", + }); + }); + + test("provider-set thinking is preserved (absent-only thinking)", () => { + const noneThenTrue = bodyFor({ + providerOptions: { + reasoning_effort: "none", + chat_template_kwargs: { thinking: true }, + }, + } as InferenceOptions); + expect(noneThenTrue["chat_template_kwargs"]).toEqual({ thinking: true }); + + const xhighThenFalse = bodyFor({ + providerOptions: { + reasoning_effort: "xhigh", + chat_template_kwargs: { thinking: false }, + }, + } as InferenceOptions); + expect(xhighThenFalse["chat_template_kwargs"]).toEqual({ thinking: false }); + }); +}); + +describe("deepseek-v4 adapter inherits base quirks", () => { + const withThinking: ConversationTurn[] = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + model: "deepseek-v4", + content: [ + { type: "thinking", thinking: "ponder" }, + { type: "text", text: "hello" }, + ], + }, + { role: "user", content: [{ type: "text", text: "again" }] }, + ] as unknown as ConversationTurn[]; + + test("keeps reasoning_content on replayed assistant turns (inherited base)", () => { + const adapter = createDeepSeekV4Adapter(source); + const built = adapter.buildRequest( + withThinking, + "deepseek-v4", + {} as InferenceOptions, + ); + const assistant = ( + JSON.parse(built.body) as { messages: Record[] } + ).messages.find((m) => m["role"] === "assistant"); + expect(assistant?.["reasoning_content"]).toBe("ponder"); + }); + + test("non-V4 select a generic body build (base short-circuits, no V4 fields)", () => { + const adapter = createDeepSeekV4Adapter({ + ...source, + model: "gpt-5", + } as typeof source); + const built = adapter.buildRequest( + messages, + "gpt-5", + {} as InferenceOptions, + ); + const body = JSON.parse(built.body) as Record; + expect("top_p" in body).toBe(false); + expect("temperature" in body).toBe(false); + expect("stream_options" in body).toBe(false); + expect("chat_template_kwargs" in body).toBe(false); + expect(body["model"]).toBe("gpt-5"); + }); + + test("keeps reasoning_content for non-DeepSeek models", () => { + const adapter = createDeepSeekV4Adapter({ + ...source, + model: "kimi-k2", + } as typeof source); + const built = adapter.buildRequest( + withThinking, + "kimi-k2", + {} as InferenceOptions, + ); + const assistant = ( + JSON.parse(built.body) as { messages: Record[] } + ).messages.find((m) => m["role"] === "assistant"); + expect(assistant?.["reasoning_content"]).toBe("ponder"); + }); +}); + +describe("deepseek-v4 adapter byte-identical to base for non-V4", () => { + test("non-V4 body matches the patched generic base byte-for-byte", () => { + const baseAdapter = createOpenAICompatibleAdapter({ + ...source, + model: "gpt-5", + } as typeof source); + const baseBuilt = baseAdapter.buildRequest(messages, "gpt-5", { + providerOptions: { reasoning_effort: "none" }, + } as InferenceOptions); + + const adapter = createDeepSeekV4Adapter({ + ...source, + model: "gpt-5", + } as typeof source); + const built = adapter.buildRequest(messages, "gpt-5", { + providerOptions: { reasoning_effort: "none" }, + } as InferenceOptions); + + const baseBody = JSON.parse(baseBuilt.body) as Record; + const body = JSON.parse(built.body) as Record; + expect(body).toEqual(baseBody); + }); +}); diff --git a/src/provider/deepseek-v4-adapter.ts b/src/provider/deepseek-v4-adapter.ts new file mode 100644 index 000000000..24d53b70d --- /dev/null +++ b/src/provider/deepseek-v4-adapter.ts @@ -0,0 +1,71 @@ +import { type ProviderAdapter } from "@intx/inference"; +import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; +import { + DEEPSEEK_V4_MODEL_CARD, + isDeepSeekV4Model, + mapV4Effort, + V4_STREAM_OPTIONS, +} from "./deepseek-v4-effort.js"; + +export const DEEPSEEK_V4_PROVIDER = "deepseek-v4"; + +type AdapterSource = Parameters[0]; + +// Wraps the patched openai-compatible adapter so V4 requests inherit the +// providerOptions merge, NIM null-delta patch, and Accept header; this layer +// only applies the V4 effort/sampling/stream quirks to the built body. +export function createDeepSeekV4Adapter( + source: AdapterSource, +): ProviderAdapter { + const base = createOpenAICompatibleAdapter(source); + + const buildRequest: ProviderAdapter["buildRequest"] = ( + messages, + model, + options, + ) => { + const req = base.buildRequest(messages, model, options); + if (!isDeepSeekV4Model(model)) return req; + const body = JSON.parse(req.body) as Record; + + // V4 effort ladder (none/xhigh/max): none drops reasoning_effort and + // turns thinking off; xhigh/max go out raw with thinking on; off-ladder + // or absent efforts are left untouched. + if ("reasoning_effort" in body) { + const effort = body["reasoning_effort"] as Parameters< + typeof mapV4Effort + >[0]; + const wire = mapV4Effort(effort); + if (wire === null) { + delete body["reasoning_effort"]; + mergeThinking(body, false); + } else if (wire !== undefined) { + body["reasoning_effort"] = wire; + mergeThinking(body, true); + } + } + + if (body["top_p"] === undefined) + body["top_p"] = DEEPSEEK_V4_MODEL_CARD.topP; + if (body["temperature"] === undefined) + body["temperature"] = DEEPSEEK_V4_MODEL_CARD.temperature; + body["stream_options"] ??= V4_STREAM_OPTIONS.stream_options; + + return { ...req, body: JSON.stringify(body) }; + }; + + return { ...base, buildRequest }; +} + +// chat_template_kwargs clone with an absent-only thinking toggle (never mutate +// the caller's providerOptions; provider-set thinking wins). +function mergeThinking(body: Record, thinking: boolean): void { + const existing = + body["chat_template_kwargs"] !== null && + typeof body["chat_template_kwargs"] === "object" + ? (body["chat_template_kwargs"] as Record) + : {}; + const merged = { ...existing }; + merged.thinking ??= thinking; + Object.assign(body, { chat_template_kwargs: merged }); +} diff --git a/src/provider/inference-dependencies.ts b/src/provider/inference-dependencies.ts index 9a28ad486..42060182c 100644 --- a/src/provider/inference-dependencies.ts +++ b/src/provider/inference-dependencies.ts @@ -9,6 +9,7 @@ import * as opencodeGo from "./opencode-go-adapter.js"; import * as codexResponses from "./codex-responses.js"; import * as grokResponses from "./grok-responses.js"; import * as bifrostAdapter from "./bifrost-adapter.js"; +import * as deepseekV4 from "./deepseek-v4-adapter.js"; import * as openaiResponses from "./openai-responses.js"; import * as anthropicSession from "./anthropic-session-adapter.js"; import { @@ -21,6 +22,7 @@ import { withReplaySanitizer } from "./replay-sanitizer.js"; import { isPollOnlyPendingBatch } from "../subagent/poll-exempt.js"; import { OPENCODE_GO_PROVIDER_ID } from "../../packages/opencode-go/src/index.js"; import { BIFROST_PROVIDER } from "./bifrost-adapter.js"; +import { DEEPSEEK_V4_PROVIDER } from "./deepseek-v4-adapter.js"; import { OPENAI_RESPONSES_PROVIDER } from "./openai-responses.js"; import { OPENCODE_GO_MESSAGES_PROVIDER, @@ -58,6 +60,11 @@ const manifest: AdapterManifest = [ specifier: "bifrost-adapter", export: "createBifrostAdapter", }, + { + provider: DEEPSEEK_V4_PROVIDER, + specifier: "deepseek-v4-adapter", + export: "createDeepSeekV4Adapter", + }, { provider: OPENAI_RESPONSES_PROVIDER, specifier: "openai-responses", @@ -81,6 +88,7 @@ const localModules: Record = { "codex-responses": codexResponses, "grok-responses": grokResponses, "bifrost-adapter": bifrostAdapter, + "deepseek-v4-adapter": deepseekV4, "openai-responses": openaiResponses, "anthropic-session-adapter": anthropicSession, }; diff --git a/src/provider/openai-compatible-adapter.test.ts b/src/provider/openai-compatible-adapter.test.ts index 72e9c7c4e..9fcc012d5 100644 --- a/src/provider/openai-compatible-adapter.test.ts +++ b/src/provider/openai-compatible-adapter.test.ts @@ -1,7 +1,6 @@ import { defined } from "../../testkit/defined.js"; import { describe, test, expect } from "bun:test"; import { ProtocolMismatchError } from "@intx/inference"; -import { createOpenAIAdapter } from "@intx/inference/providers"; import type { ConversationTurn, InferenceOptions } from "@intx/types/runtime"; import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; @@ -203,196 +202,3 @@ describe("openai-compatible adapter reasoning_content handling", () => { } }); }); - -describe("openai-compatible adapter V4 effort wiring", () => { - function bodyForModel( - model: string, - options: InferenceOptions, - ): Record { - const adapter = createOpenAICompatibleAdapter({ - ...source, - model, - } as typeof source); - const built = adapter.buildRequest(messages, model, options); - return JSON.parse(built.body) as Record; - } - - test.each(["xhigh", "max"] as const)( - "wires V4 effort %s through raw and turns thinking on", - (effort) => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { reasoning_effort: effort }, - } as InferenceOptions); - expect(body["reasoning_effort"]).toBe(effort); - expect(body["chat_template_kwargs"]).toEqual({ thinking: true }); - }, - ); - - describe("V4 request settings (PR4)", () => { - test("none → chat_template_kwargs.thinking false and NO reasoning_effort", () => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { reasoning_effort: "none" }, - } as InferenceOptions); - expect(body["chat_template_kwargs"]).toEqual({ thinking: false }); - expect("reasoning_effort" in body).toBe(false); - }); - - test("stream_options include_usage is true", () => { - const body = bodyForModel("deepseek-v4-pro", {} as InferenceOptions); - expect(body["stream_options"]).toEqual({ include_usage: true }); - // An unset effort must not force the encoder's thinking pass on nor - // inject an empty chat_template_kwargs object. - expect(body["chat_template_kwargs"]).toBeUndefined(); - }); - - test("top_p 0.95 and temperature 1.0 defaulted when absent", () => { - const body = bodyForModel("deepseek-v4-pro", {} as InferenceOptions); - expect(body["top_p"]).toBe(0.95); - expect(body["temperature"]).toBe(1.0); - // An unset effort must not force the encoder's thinking pass on nor - // inject an empty chat_template_kwargs object. - expect(body["chat_template_kwargs"]).toBeUndefined(); - }); - - test("top_p and temperature preserved when providerOptions set them", () => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { temperature: 0.7, top_p: 0.9 }, - } as InferenceOptions); - expect(body["top_p"]).toBe(0.9); - expect(body["temperature"]).toBe(0.7); - }); - - test("chat_template_kwargs merge preserves existing keys", () => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { - reasoning_effort: "xhigh", - chat_template_kwargs: { some: "existing" }, - }, - } as InferenceOptions); - expect(body["chat_template_kwargs"]).toEqual({ - thinking: true, - some: "existing", - }); - }); - - test("off-ladder effort is left untouched (no high→xhigh coercion)", () => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { reasoning_effort: "high" }, - } as InferenceOptions); - expect(body["reasoning_effort"]).toBe("high"); - expect("chat_template_kwargs" in body).toBe(false); - }); - - test("none merges thinking off into, never replaces, existing kwargs", () => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { - reasoning_effort: "none", - chat_template_kwargs: { some: "existing" }, - }, - } as InferenceOptions); - expect(body["chat_template_kwargs"]).toEqual({ - thinking: false, - some: "existing", - }); - }); - - test("provider-set thinking is preserved (absent-only thinking)", () => { - // A provider-supplied thinking value beats the V4 effort's implied one: - // `none` normally forces thinking off, but an explicit thinking:true - // must be kept (never force-stamped), and vice versa. - const noneThenTrue = bodyForModel("deepseek-v4-pro", { - providerOptions: { - reasoning_effort: "none", - chat_template_kwargs: { thinking: true }, - }, - } as InferenceOptions); - expect(noneThenTrue["chat_template_kwargs"]).toEqual({ thinking: true }); - - const xhighThenFalse = bodyForModel("deepseek-v4-pro", { - providerOptions: { - reasoning_effort: "xhigh", - chat_template_kwargs: { thinking: false }, - }, - } as InferenceOptions); - expect(xhighThenFalse["chat_template_kwargs"]).toEqual({ - thinking: false, - }); - }); - - test("unset effort does not inject empty chat_template_kwargs", () => { - const body = bodyForModel("deepseek-v4-pro", {} as InferenceOptions); - expect("chat_template_kwargs" in body).toBe(false); - expect(body["reasoning_effort"]).toBeUndefined(); - }); - - test("provider-set stream_options is preserved (absent-only)", () => { - const body = bodyForModel("deepseek-v4-pro", { - providerOptions: { - stream_options: { include_usage: false }, - }, - } as InferenceOptions); - expect(body["stream_options"]).toEqual({ include_usage: false }); - }); - }); - - describe("v4-effort is no-op for non-V4", () => { - const models = [ - "gpt-5", - "gpt-5.1", - "kimi-k2", - "grok-4.6", - "claude-sonnet-4.5", - "glm-5.3", - "some-openai-compatible-model", - "deepseek-r1", - "deepseek-v3", - ]; - - test.each( - models.flatMap((model) => - (["high", "none", "max"] as const).map((effort) => [model, effort]), - ), - )( - "merged body for %s with effort %s is byte-identical to a manual base+merge (V4 step no-op)", - (model, effort) => { - const base = bodyForModel(model, {} as InferenceOptions); - const withEffort = bodyForModel(model, { - providerOptions: { reasoning_effort: effort }, - } as InferenceOptions); - // For a non-V4 model the V4 step must not run: the output body equals - // the base body shallow-merged with providerOptions verbatim (no - // effort mapping, no chat_template_kwargs injection) — including V4 - // ladder values like none (delete + thinking:false) and max (verbatim), - // so a regression routing any V4 rung through the V4 branch is caught. - const expected = { - ...base, - ...({ reasoning_effort: effort } as const), - }; - expect(withEffort).toEqual(expected); - }, - ); - - test.each(models)( - "V4 settings (top_p/temperature/stream_options/chat_template_kwargs) not injected for %s", - (model) => { - const body = bodyForModel(model, {} as InferenceOptions); - // Full-body equality against the raw base OpenAI adapter output: for a - // non-V4 model the wrapper must short-circuit the V4 step entirely, so - // the body matches the base build byte-for-byte (a regression that - // injects a 5th field or mutates an existing one would fail here). - const baseAdapter = createOpenAIAdapter({ - ...source, - model, - } as typeof source); - const baseBuilt = baseAdapter.buildRequest( - messages, - model, - {} as InferenceOptions, - ); - expect(body).toEqual( - JSON.parse(baseBuilt.body) as Record, - ); - }, - ); - }); -}); diff --git a/src/provider/openai-compatible-adapter.ts b/src/provider/openai-compatible-adapter.ts index 4f3bcce54..d8a28566b 100644 --- a/src/provider/openai-compatible-adapter.ts +++ b/src/provider/openai-compatible-adapter.ts @@ -1,11 +1,5 @@ import { type BuiltRequest, type ProviderAdapter } from "@intx/inference"; import { createOpenAIAdapter } from "@intx/inference/providers"; -import type { ReasoningEffort } from "../agent/profile-types.js"; -import { - DEEPSEEK_V4_MODEL_CARD, - mapV4Effort, - V4_STREAM_OPTIONS, -} from "./deepseek-v4-effort.js"; import { normalizeNullDeltaFields } from "./null-delta-fields.js"; // The stock OpenAI adapter builds the request body from a fixed set of fields @@ -53,62 +47,17 @@ export function createOpenAICompatibleAdapter( providerOptions !== undefined && Object.keys(providerOptions).length > 0; // DeepSeek returns HTTP 400 if `reasoning_content` appears in input messages, // whereas the base adapter emits it for any model with thinking enabled. V4 - // keeps each turn's reasoning on replay, so only pre-V4 DeepSeek strips it. + // keeps each turn's reasoning on replay (handled by its own adapter), so only + // pre-V4 DeepSeek strips it here. const m = model.toLowerCase().trim(); const isDeepSeek = m.includes("deepseek"); const stripReasoning = isDeepSeek && !/^deepseek-v4/i.test(m); needsDeepSeekPatch = isDeepSeek; - // V4 always runs the settings step below, even with no providerOptions, so - // its sampling/stream defaults reach the wire; non-V4 models without - // providerOptions short-circuit here and stay byte-identical. - if (!hasProviderOptions && !stripReasoning && !/^deepseek-v4/i.test(m)) - return ensureAccept(built); + if (!hasProviderOptions && !stripReasoning) return ensureAccept(built); const body = JSON.parse(built.body) as Record; if (hasProviderOptions) Object.assign(body, providerOptions); - // Merge a thinking toggle into a clone of the existing chat_template_kwargs - // (never overwrite the object wholesale, and never mutate the caller's - // providerOptions ref). The toggle is absent-only: a provider-supplied - // thinking value wins over the V4 effort's implied value. - const applyV4Thinking = (thinking: boolean): void => { - const existing = - body["chat_template_kwargs"] !== null && - typeof body["chat_template_kwargs"] === "object" - ? (body["chat_template_kwargs"] as Record) - : {}; - const merged = { ...existing }; - merged.thinking ??= thinking; - Object.assign(body, { chat_template_kwargs: merged }); - }; - // V4 drives its own effort ladder (none/xhigh/max) and encodes thinking via - // chat_template_kwargs. `none` is a toggle, never a wire effort — drop - // reasoning_effort and tell the encoder to skip the reasoning pass; xhigh/ - // max go out raw. Off-ladder efforts stay untouched, and an absent effort - // is never invented here (no empty chat_template_kwargs either). Sampling - // defaults and stream include_usage are absent-field-only — provider-set - // values always win. - if (/^deepseek-v4/i.test(m)) { - let wireEffort: "xhigh" | "max" | null | undefined; - if ("reasoning_effort" in body) { - const raw = body["reasoning_effort"] as ReasoningEffort; - wireEffort = mapV4Effort(raw); - if (wireEffort === null) { - delete body["reasoning_effort"]; - applyV4Thinking(false); - } else if (wireEffort !== undefined) { - body["reasoning_effort"] = wireEffort; - applyV4Thinking(true); - } - } - if (body["top_p"] === undefined) - body["top_p"] = DEEPSEEK_V4_MODEL_CARD.topP; - if (body["temperature"] === undefined) - body["temperature"] = DEEPSEEK_V4_MODEL_CARD.temperature; - // Absent-only, mirroring the sampling guard: provider-set stream_options - // is never overwritten. - body["stream_options"] ??= V4_STREAM_OPTIONS.stream_options; - } if (stripReasoning && Array.isArray(body["messages"])) { for (const msg of body["messages"]) { if (msg !== null && typeof msg === "object") From b7eea0fa3222332f19d56339878ce33b4ec3abc1 Mon Sep 17 00:00:00 2001 From: "user.email" Date: Thu, 8 Oct 2026 22:07:38 -0700 Subject: [PATCH 2/4] feat(subagent): detect deepseek-v4 family --- src/subagent/provider-family.test.ts | 32 ++++++++++++++++++++++++++++ src/subagent/provider-family.ts | 15 +++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/subagent/provider-family.test.ts b/src/subagent/provider-family.test.ts index 7e2fbf7e2..4a0cffc30 100644 --- a/src/subagent/provider-family.test.ts +++ b/src/subagent/provider-family.test.ts @@ -3,6 +3,7 @@ import { detectModelFamily, isAstraLeafProvider, isClaudeLeafProvider, + isDeepSeekV4LeafProvider, isGptProvider, isKimiLeafProvider, isXaiGrokLeafProvider, @@ -76,12 +77,43 @@ describe("detectModelFamily", () => { [{ providerName: "anthropic", model: "claude-sonnet-4" }, "claude"], [{ providerName: "openai-compat", model: "claude-opus-4-6" }, "claude"], [{ providerName: "openai", model: "gpt-5.6" }, "gpt"], + [ + { providerName: "openai-compat", model: "deepseek-v4-pro" }, + "deepseek-v4", + ], + [ + { providerName: "opencode-go", model: "deepseek-v4-flash" }, + "deepseek-v4", + ], + [ + { providerName: "openai-compat", model: "deepseek-v4-flash-vision-exp" }, + "deepseek-v4", + ], + [{ providerName: "openai-compat", model: "deepseek-coder" }, "default"], [{ providerName: "unknown-provider", model: "unknown-model" }, "default"], ])("resolves %j -> %s", (row, expected) => { expect(detectModelFamily(row)).toBe(expected); }); }); +describe("isDeepSeekV4LeafProvider", () => { + test.each<[ProviderRow, boolean]>([ + [{ providerName: "openai-compat", model: "deepseek-v4-pro" }, true], + [{ providerName: "openai-compat", model: "deepseek-v4-flash" }, true], + [ + { providerName: "openai-compat", model: "deepseek-v4-flash-vision-exp" }, + true, + ], + [{ providerName: "openai-compat", model: "DEEPSEEK-V4-PRO" }, true], + [{ providerName: "xai/default", model: "grok-4.6" }, false], + [{ providerName: "openai", model: "gpt-5.6" }, false], + [{ providerName: "openai-compat", model: "deepseek-v3" }, false], + [{ providerName: "openai-compat" }, false], + ])("matches %j -> %s", (row, expected) => { + expect(isDeepSeekV4LeafProvider(row)).toBe(expected); + }); +}); + describe("isClaudeLeafProvider", () => { test.each<[ProviderRow, boolean]>([ [{ providerName: "anthropic" }, true], diff --git a/src/subagent/provider-family.ts b/src/subagent/provider-family.ts index 8a42497e4..c1e6030a7 100644 --- a/src/subagent/provider-family.ts +++ b/src/subagent/provider-family.ts @@ -1,6 +1,7 @@ import { GROK_RESPONSES_PROVIDER } from "../provider/grok-responses.js"; import { isXaiProviderName } from "../config/xai-providers.js"; import { isCodexProviderName } from "../config/codex-providers.js"; +import { isDeepSeekV4Model } from "../provider/deepseek-v4-effort.js"; /** * True when the leaf inference path is xAI / Grok family. @@ -63,6 +64,18 @@ export function isAstraLeafProvider(input: { return input.model !== undefined && /^gpt-6-astra/i.test(input.model.trim()); } +/** + * True when the model is the DeepSeek V4 family. Single-sourced from the + * shared V4 effort module (isDeepSeekV4Model, /^deepseek-v4/i) so the adapter + * family, effort routing, and model-family key stay consistent. + */ +export function isDeepSeekV4LeafProvider(input: { + providerName: string; + model?: string; +}): boolean { + return input.model !== undefined && isDeepSeekV4Model(input.model); +} + /** * True when the inference path is the GPT family: a Codex provider name * (codex/ OAuth profiles, the codex-responses adapter, bare codex) or a @@ -91,6 +104,7 @@ export type ModelFamily = | "claude" | "gpt" | "astra" + | "deepseek-v4" | "default"; /** @@ -108,6 +122,7 @@ export function detectModelFamily(input: { if (isMuseSparkLeafProvider(input)) return "muse"; if (isClaudeLeafProvider(input)) return "claude"; if (isAstraLeafProvider(input)) return "astra"; + if (isDeepSeekV4LeafProvider(input)) return "deepseek-v4"; if (isGptProvider(input)) return "gpt"; return "default"; } From cd5c1b72d5546fafdf64ad6e46f0b6a802527527 Mon Sep 17 00:00:00 2001 From: "user.email" Date: Thu, 8 Oct 2026 22:08:38 -0700 Subject: [PATCH 3/4] feat(agent): slim deepseek-v4 bake-in via existing prompt seams DeepSeek V4 runs use the winning d1+slim config through the existing promptResidual and role-body override seams, dropping the superseded CORBITS_EXP_* / DSV4_* policy scaffolding: - model-family-policy: a deepseek-v4 branch (broad /^deepseek-v4/i family in provider-family) resolving dispatcher d1 residual, explorer g2 leaf residual, and slim coder/reviewer role bodies via the new leafRoleBody field. - run.ts: feeds the policy's leafRoleBody into the extensions list in place of the stock director body, threading directorId through. - agent-fleet: pins the deepseek-v4 coder leaf effort low unless the profile explicitly pins effort. --- src/agent/model-family-policy.test.ts | 69 ++++++++++++++++++++ src/agent/model-family-policy.ts | 83 +++++++++++++++++++++++++ src/subagent/agent-fleet-effort.test.ts | 50 +++++++++++++++ src/subagent/agent-fleet.ts | 14 ++++- src/subagent/run.ts | 8 ++- 5 files changed, 218 insertions(+), 6 deletions(-) diff --git a/src/agent/model-family-policy.test.ts b/src/agent/model-family-policy.test.ts index 7c6949727..19c18c38e 100644 --- a/src/agent/model-family-policy.test.ts +++ b/src/agent/model-family-policy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { resolveModelFamilyPolicy } from "./model-family-policy.js"; +import { buildSubAgentSystemPrompt } from "./prompts.js"; describe("resolveModelFamilyPolicy", () => { test("defaults are permissive for an unrecognized provider", () => { @@ -388,4 +389,72 @@ describe("resolveModelFamilyPolicy", () => { } } }); + + describe("deepseek-v4 bake-in (CL-10242)", () => { + function v4( + input: { + orchestrator?: boolean; + directorId?: string; + } = {}, + ) { + return resolveModelFamilyPolicy({ + providerName: "vast", + model: "deepseek-v4-flash", + ...(input.orchestrator !== undefined + ? { orchestrator: input.orchestrator } + : {}), + ...(input.directorId !== undefined + ? { directorId: input.directorId } + : {}), + }); + } + + test("orchestrators get the d1 primary residual, no leaf role body", () => { + const p = v4({ orchestrator: true }); + expect(p.family).toBe("deepseek-v4"); + expect(p.promptResidual).toBeDefined(); + expect(p.promptResidual).toContain("DeepSeek V4 Flash"); + expect(p.leafRoleBody).toBeUndefined(); + }); + + test("coder leaves get the tuned slim coder body, no residual", () => { + const p = v4({ orchestrator: false, directorId: "coder" }); + expect(p.family).toBe("deepseek-v4"); + expect(p.leafRoleBody).toBeDefined(); + expect(p.leafRoleBody).toContain("Coder"); + expect(p.promptResidual).toBeUndefined(); + }); + + test("reviewer leaves get the tuned slim reviewer body", () => { + const p = v4({ orchestrator: false, directorId: "reviewer" }); + expect(p.family).toBe("deepseek-v4"); + expect(p.leafRoleBody).toBeDefined(); + expect(p.leafRoleBody).toContain("Reviewer"); + expect(p.promptResidual).toBeUndefined(); + }); + + test("explorer leaves get the g2 leaf residual only", () => { + const p = v4({ orchestrator: false, directorId: "explorer" }); + expect(p.family).toBe("deepseek-v4"); + expect(p.promptResidual).toBeDefined(); + expect(p.promptResidual).toContain("DeepSeek V4 Flash"); + expect(p.leafRoleBody).toBeUndefined(); + }); + + test("a leaf without a tuned director id gets the default policy, no body/residual", () => { + const p = v4({ orchestrator: false }); + expect(p.family).toBe("deepseek-v4"); + expect(p.leafRoleBody).toBeUndefined(); + expect(p.promptResidual).toBeUndefined(); + }); + + test("the slim coder body lands in the assembled sub-agent prompt via the extensions seam", () => { + const p = v4({ orchestrator: false, directorId: "coder" }); + const slimBody = p.leafRoleBody; + expect(slimBody).toBeDefined(); + const prompt = buildSubAgentSystemPrompt([slimBody as string]); + expect(prompt).toContain("You are Coder"); + expect(prompt).toContain("implement one brief"); + }); + }); }); diff --git a/src/agent/model-family-policy.ts b/src/agent/model-family-policy.ts index 5301e8bed..84e8f99b0 100644 --- a/src/agent/model-family-policy.ts +++ b/src/agent/model-family-policy.ts @@ -55,6 +55,13 @@ export interface ModelFamilyPolicy { * disturb the cached prompt prefix. Undefined for families that need none. */ promptResidual?: string | undefined; + /** + * Replaces the director body (formatDirectorSystemPrompt) on a leaf worker + * for families with a tuned slim body for that role. Undefined keeps the + * shared director body. run.ts feeds it into the extensions list in place + * of the stock director body. + */ + leafRoleBody?: string | undefined; } const DEFAULT_WRAP_UP_NUDGE_TEXT = @@ -200,10 +207,66 @@ const ASTRA_POLICY: Omit = { promptResidual: ASTRA_PROMPT_RESIDUAL, }; +// DeepSeek V4 winning d1+slim config, baked in from the handoff prompt files +// (corbits-dsv4-handoff/prompts/): d1_primary.txt, body_coder.txt, +// body_reviewer.txt, g2_leaf.txt. Inline constants — the brief keeps these in +// the policy, not the prompt-variance package. The model-family-policy switch +// routes them; the orchestration/leaf carve-outs mirror the grok branch. +export const DSV4_D1_PRIMARY_RESIDUAL = ` +Operating notes (DeepSeek V4 Flash): +- Your context is the expensive one: workers do the reading, building, and checking. Read only what you need to route and to name files in a brief (one glob or grep, or one or two reads); do not read code to plan the fix yourself. +- Route: any code change (fix, feature, refactor, even one line) goes to coder; verification of that change goes to reviewer after the coder reports; codebase questions that need more than two reads go to explorer; a question answerable from one or two reads you answer directly. Set \`agent\` to the id directly; do not call search_agents. +- Write each brief so the worker can start without rediscovering anything: goal (copy the user's requirements verbatim, including exact formats, names, and messages), files (repo-relative paths to change and to leave alone), constraints in do_not (no edits to tests or locked files, no new deps, no extra tests or files unless asked), success_criteria (observable checks, one per requirement), and the exact test command. +- After the coder reports, spawn reviewer with the same success_criteria, the test command, and the coder's changed paths. If the reviewer reports a defect, send it back to the same coder with send_input, then have the reviewer re-check once. +- Always call wait_agents on a worker you spawned before ending your turn; never end the turn with a worker still running. +- Do not edit files yourself and do not re-read changed files or re-run tests after a report: the reviewer's verdict is the verification. +- Files go through the file tools, not the shell: read, grep, glob. glob refuses ** unless \`path\` is a subdirectory: use {"path": "src", "pattern": "**/*.ts"}, never {"pattern": "src/**/*.ts"}. +- Final reply: what changed (files), who verified it and how, anything left open, in five lines or fewer. Do not restate the request. +`.trim(); + +export const DSV4_BODY_CODER = ` +Identity: agent id \`coder\`. You are Coder, the builder for Corbits Code, running on DeepSeek V4 Flash. You implement one brief, then report. A separate reviewer verifies your work. + +- Start from the brief: it names the goal, the files, the constraints, the success criteria, and the test command. Read those files, make the change, run the test command once, report. +- Read a file before editing it, unless you just created or edited it. Use edit for targeted changes and write for new files. +- Use read, not cat/head/tail in bash; read with no offset/limit returns the whole file, so read each file once. Use grep, not shell grep or rg. Use glob, not find or ls. +- glob refuses ** unless \`path\` is a subdirectory: {"path": "src", "pattern": "**/*.ts"} works; {"pattern": "src/**/*.ts"} is refused. +- Use bash for tests, builds, and programs. A bash result that starts with \`exit code N\` failed: read it and fix the cause before moving on. +- Make the smallest change at the root cause. Keep public signatures and existing behavior the brief does not mention. Add no tests, files, refactors, or docs the brief did not ask for. +- Build structured output (JSON, CSV, YAML) with the language's serializer (JSON.stringify, json.dumps), never by string interpolation. +- Do not re-verify beyond the brief's test command and one direct check per success criterion; the reviewer does the rest. +- Report with the Report envelope (Summary / Findings / Blockers / Paths). In Findings, give each success criterion as pass/fail with the command and its exit status. Paths lists every file you changed. +`.trim(); + +export const DSV4_BODY_REVIEWER = ` +Identity: agent id \`reviewer\`. You are Reviewer, the verifier for Corbits Code, running on DeepSeek V4 Flash. You check a change against the brief's success criteria. You never edit product code. + +- Run the brief's test command once. Then check each success criterion with the smallest direct evidence: read the changed lines, and run one targeted command (call the function or CLI with python3 -c / node -e) for behavior the tests do not cover, including exact output formats. +- Report a defect only with evidence: path:line, the input, expected vs actual. +- Do not add files to the repo; use inline commands and leave the working tree as you found it. +- Use read, grep, and glob for files, not shell cat/grep/find. glob refuses ** unless \`path\` is a subdirectory: {"path": "src", "pattern": "**/*.ts"}. +- A bash result that starts with \`exit code N\` failed: say what failed. +- Report with the Report envelope (Summary / Findings / Blockers / Paths). Summary: PASS or FAIL in one line. Findings: one line per success criterion (pass/fail + evidence), then any defects. Keep the whole report under 15 lines. +`.trim(); + +export const DSV4_G2_LEAF_RESIDUAL = ` +Operating notes (DeepSeek V4 Flash): +- Files go through the file tools, not the shell: read to view a file, grep to search contents, glob to find files. bash is for running tests, builds, and programs only. +- glob refuses ** unless \`path\` is a subdirectory. Put the directory in \`path\`, never at the front of the pattern: {"path": "src", "pattern": "**/*.ts"} works; {"pattern": "src/**/*.ts"}, {"pattern": "tests/**/*"} and {"pattern": "**/*"} are refused. At the root, survey with {"pattern": "*.*"} and {"pattern": "*/*"}. +- read takes a file path, never a directory; list a directory with glob ({"path": "src", "pattern": "*"}). Do not guess file names: glob or grep first. +- read with no offset/limit returns the whole file; read each file once and do not page past its end. +- Do exactly what the brief asks. No extra tests, files, refactors, or docs unless the brief requires them; when the brief or the operator says no tests, write none. +- Produce structured output (JSON, CSV, YAML) with the language's serializer (JSON.stringify, json.dumps), never by string interpolation. +- Stay proportional: a few targeted checks cover a small change. Once the success criteria are verified, stop; do not build harnesses for exotic inputs the brief never mentions. +- Run the project's own test command once after your last edit, then write the report. +`.trim(); + export function resolveModelFamilyPolicy(input: { providerName: string; model?: string; orchestrator?: boolean; + /** Leaf director id (coder, reviewer, explorer, …) for role-tuned families. */ + directorId?: string; }): ModelFamilyPolicy { const family = detectModelFamily(input); const orchestrator = input.orchestrator === true; @@ -240,6 +303,26 @@ export function resolveModelFamilyPolicy(input: { // Primary and leaf alike, like gpt: no orchestrator carve-out. Generic // gpt prompts are byte-identical — only astra carries the residual. return { family, ...ASTRA_POLICY }; + case "deepseek-v4": { + // Winning d1+slim config: d1 primary residual on orchestrators, g2 leaf + // residual on explorer leaves, tuned slim coder/reviewer bodies. + const directorId = input.directorId; + return { + family, + ...DEFAULT_POLICY, + promptResidual: orchestrator + ? DSV4_D1_PRIMARY_RESIDUAL + : directorId === "explorer" + ? DSV4_G2_LEAF_RESIDUAL + : undefined, + leafRoleBody: + !orchestrator && directorId === "coder" + ? DSV4_BODY_CODER + : !orchestrator && directorId === "reviewer" + ? DSV4_BODY_REVIEWER + : undefined, + }; + } default: return { family: "default", ...DEFAULT_POLICY }; } diff --git a/src/subagent/agent-fleet-effort.test.ts b/src/subagent/agent-fleet-effort.test.ts index cdd708931..19e89c746 100644 --- a/src/subagent/agent-fleet-effort.test.ts +++ b/src/subagent/agent-fleet-effort.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { AgentProfile } from "../agent/profile-types.js"; +import type { ReasoningEffort } from "../provider/reasoning-effort.js"; import type { ProviderCatalogEntry } from "../config/index.js"; import { buildSubagentSources } from "../config/inference-sources.js"; import { clearSourceCredentials } from "../config/source-credentials.js"; @@ -350,3 +351,52 @@ describe("custom worker efforts", () => { expect(worker?.provider.reasoningEffort).toBe("max"); }); }); + +describe("deepseek-v4 coder=low bake-in (CL-10242)", () => { + const dsv4: ProviderCatalogEntry = { + name: "dsv4", + baseURL: "https://dsv4.example/v1", + keyless: true, + models: ["deepseek-v4-flash"], + }; + + async function captureEffort(agent: string): Promise { + let worker: RunSubAgentParams | undefined; + const deps = createFleetDeps( + async (params) => { + worker = params; + return { report: "done" }; + }, + { settings: { providers: { dsv4 } }, catalog: [dsv4] }, + ); + deps.provider = { + providerName: dsv4.name, + baseURL: dsv4.baseURL, + model: "deepseek-v4-flash", + }; + const spawn = createSpawnAgentTool(deps); + const args: Record = { + description: "inspect", + prompt: "inspect", + agent, + }; + if (agent === "coder" || agent === "reviewer") { + args.success_criteria = ["done"]; + } + const result = await callFleetToolRaw(spawn, args); + expect(result.isError).not.toBe(true); + await callFleetToolRaw(fleetTools(deps).wait, { timeout_ms: 2000 }); + if (worker === undefined) throw new Error("worker did not start"); + return worker.provider.reasoningEffort as ReasoningEffort; + } + + test("coder pins low on the wire while reviewer/explorer keep their role defaults", async () => { + // V4's native ladder is ["none","xhigh","max"] — the requested low coder + // pin clamps to its lowest rung (none), while unstubbed reviewer/explorer + // leaves resolve their medium role default up to xhigh. The assertion is + // that coder is the cheapest spawn; the others are not pinned. + expect(await captureEffort("coder")).toBe("none"); + expect(await captureEffort("reviewer")).toBe("xhigh"); + expect(await captureEffort("explorer")).toBe("xhigh"); + }); +}); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index cb36e65f1..02b09d3eb 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -66,6 +66,7 @@ import { validateEffort, type ReasoningEffort, } from "../provider/reasoning-effort.js"; +import { isDeepSeekV4Model } from "../provider/deepseek-v4-effort.js"; import type { AgentProfile, CapabilityFilter } from "../agent/profiles.js"; import { DEFAULT_KNOWN_ENGINES, @@ -1183,11 +1184,18 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { settings?.providers[provider.providerName], catalog?.find((entry) => entry.name === provider.providerName), ); + // DeepSeek V4 pins the coder leaf to low unless the profile explicitly + // pins effort (winning config). + const coderEffortPin = + resolved.effortPin ?? + (!orchestrator && + resolved.directorId === "coder" && + isDeepSeekV4Model(provider.model) + ? "low" + : undefined); const effort = resolveEffortForRole({ orchestrator, - ...(resolved.effortPin !== undefined - ? { pin: resolved.effortPin } - : {}), + ...(coderEffortPin !== undefined ? { pin: coderEffortPin } : {}), ...(resolved.roleDefault !== undefined ? { roleDefault: resolved.roleDefault } : {}), diff --git a/src/subagent/run.ts b/src/subagent/run.ts index bec36a337..7533224dc 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -831,6 +831,9 @@ async function runSubAgentInner( providerName: params.provider.providerName, model: params.provider.model, orchestrator: params.orchestrator === true, + ...(params.directorId !== undefined + ? { directorId: params.directorId } + : {}), }); const skillDirs = [...(params.skillDirs ?? [])]; // CL-9010: reuse the dispatcher's catalog when the lane shares its cwd; @@ -1193,10 +1196,9 @@ async function runSubAgentInner( skillDirs, }) : undefined; + const roleBody = modelFamilyPolicy.leafRoleBody ?? params.systemPromptRole; const extensions = [ - ...(params.systemPromptRole !== undefined - ? [params.systemPromptRole] - : []), + ...(roleBody !== undefined ? [roleBody] : []), ...(attachedSection !== undefined ? [attachedSection] : []), ]; const toolProfile = toolProfileForModel(params.provider); From 33b91bb33f2c8032394c42985bfd2125a0d8ed7a Mon Sep 17 00:00:00 2001 From: "user.email" Date: Thu, 8 Oct 2026 22:33:08 -0700 Subject: [PATCH 4/4] feat(subagent): per-role deepseek-v4 effort policy Wire a per-role V4 effort table (CL-10294) through the existing fleet effort-pin seam: dispatch/planner/reviewer max, coder cheapest, other leaves xhigh. coder's low clamps to the V4 none wire rung (thinking off) per the operator's simplest-implementer-as-none intent. Explicit profile effort pins and role defaults still win. Non-V4 models are untouched. --- src/provider/deepseek-v4-effort.ts | 21 ++++++ src/subagent/agent-fleet-effort.test.ts | 99 +++++++++++++++++++++++-- src/subagent/agent-fleet.ts | 23 +++--- 3 files changed, 127 insertions(+), 16 deletions(-) diff --git a/src/provider/deepseek-v4-effort.ts b/src/provider/deepseek-v4-effort.ts index 3a6b76508..fcfed47ec 100644 --- a/src/provider/deepseek-v4-effort.ts +++ b/src/provider/deepseek-v4-effort.ts @@ -1,4 +1,25 @@ import type { ReasoningEffort } from "../agent/profile-types.js"; +import type { DirectorId } from "../agent/directors/types.js"; + +/** + * Per-role V4 effort defaults (CL-10294). dispatch/planner/reviewer dispatch + * and judge — max. coder is the cheapest spawn: 'low' clamps to the V4 'none' + * wire rung (thinking off), per the operator's "simplest implementer = none" + * intent. Every other leaf reasons at xhigh. + */ +export const DEEPSEEK_V4_ROLE_EFFORT: Record = { + dispatch: "max", + planner: "max", + reviewer: "max", + coder: "low", + explorer: "xhigh", + artist: "xhigh", + "qa-lead": "xhigh", + prober: "xhigh", + designer: "xhigh", + shakespeare: "xhigh", + warden: "xhigh", +}; // DeepSeek V4 effort translator shared by the adapter and the effort picker. // V4 ships under several ids across two catalogs — deepseek-v4-pro/-flash/ diff --git a/src/subagent/agent-fleet-effort.test.ts b/src/subagent/agent-fleet-effort.test.ts index 19e89c746..f0584df33 100644 --- a/src/subagent/agent-fleet-effort.test.ts +++ b/src/subagent/agent-fleet-effort.test.ts @@ -352,7 +352,7 @@ describe("custom worker efforts", () => { }); }); -describe("deepseek-v4 coder=low bake-in (CL-10242)", () => { +describe("deepseek-v4 per-role effort policy (CL-10294)", () => { const dsv4: ProviderCatalogEntry = { name: "dsv4", baseURL: "https://dsv4.example/v1", @@ -390,13 +390,98 @@ describe("deepseek-v4 coder=low bake-in (CL-10242)", () => { return worker.provider.reasoningEffort as ReasoningEffort; } - test("coder pins low on the wire while reviewer/explorer keep their role defaults", async () => { - // V4's native ladder is ["none","xhigh","max"] — the requested low coder - // pin clamps to its lowest rung (none), while unstubbed reviewer/explorer - // leaves resolve their medium role default up to xhigh. The assertion is - // that coder is the cheapest spawn; the others are not pinned. + test("planner/reviewer max, coder cheapest, other leaves xhigh", async () => { + // V4's native ladder is ["none","xhigh","max"]. coder's low clamps to the + // cheapest wire rung (none, thinking off) per the operator's "simplest + // implementer = none" intent; planner/reviewer resolve max. dispatch is the + // primary session (not a spawned worker) and already defaults to max for + // V4 via defaultEffortForModel. + expect(await captureEffort("planner")).toBe("max"); + expect(await captureEffort("reviewer")).toBe("max"); expect(await captureEffort("coder")).toBe("none"); - expect(await captureEffort("reviewer")).toBe("xhigh"); expect(await captureEffort("explorer")).toBe("xhigh"); + expect(await captureEffort("artist")).toBe("xhigh"); + expect(await captureEffort("qa-lead")).toBe("xhigh"); + expect(await captureEffort("prober")).toBe("xhigh"); + expect(await captureEffort("designer")).toBe("xhigh"); + expect(await captureEffort("shakespeare")).toBe("xhigh"); + expect(await captureEffort("warden")).toBe("xhigh"); + }); + + test("an explicit profile effortPin outranks the V4 role table", async () => { + let worker: RunSubAgentParams | undefined; + const deps = createFleetDeps( + async (params) => { + worker = params; + return { report: "done" }; + }, + { + settings: { providers: { dsv4 } }, + catalog: [dsv4], + profiles: [ + { + id: "pinned-coder", + inference: { + mode: "pin", + order: [ + { + provider: dsv4.name, + model: "deepseek-v4-flash", + reasoningEffort: "max", + }, + ], + }, + }, + ], + }, + ); + deps.provider = { + providerName: dsv4.name, + baseURL: dsv4.baseURL, + model: "deepseek-v4-flash", + }; + const result = await callFleetToolRaw(createSpawnAgentTool(deps), { + description: "inspect", + prompt: "inspect", + agent: "pinned-coder", + }); + expect(result.isError).not.toBe(true); + await callFleetToolRaw(fleetTools(deps).wait, { timeout_ms: 2000 }); + if (worker === undefined) throw new Error("worker did not start"); + expect(worker.provider.reasoningEffort).toBe("max"); + }); + + test("non-V4 roles are untouched by the V4 table", async () => { + // A non-V4 model spawns the same director without any V4 role pin — the + // leaf role default (medium, here clamped to the custom ladder's low) wins. + let worker: RunSubAgentParams | undefined; + const nonV4: ProviderCatalogEntry = { + name: "plain", + baseURL: "https://plain.example/v1", + keyless: true, + models: ["plain-model"], + reasoningEfforts: ["low", "max"], + }; + const deps = createFleetDeps( + async (params) => { + worker = params; + return { report: "done" }; + }, + { settings: { providers: { plain: nonV4 } }, catalog: [nonV4] }, + ); + deps.provider = { + providerName: nonV4.name, + baseURL: nonV4.baseURL, + model: "plain-model", + }; + const result = await callFleetToolRaw(createSpawnAgentTool(deps), { + description: "inspect", + prompt: "inspect", + intent: "explore", + }); + expect(result.isError).not.toBe(true); + await callFleetToolRaw(fleetTools(deps).wait, { timeout_ms: 2000 }); + if (worker === undefined) throw new Error("worker did not start"); + expect(worker.provider.reasoningEffort).toBe("low"); }); }); diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 02b09d3eb..3fe5f8cfb 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -66,7 +66,10 @@ import { validateEffort, type ReasoningEffort, } from "../provider/reasoning-effort.js"; -import { isDeepSeekV4Model } from "../provider/deepseek-v4-effort.js"; +import { + DEEPSEEK_V4_ROLE_EFFORT, + isDeepSeekV4Model, +} from "../provider/deepseek-v4-effort.js"; import type { AgentProfile, CapabilityFilter } from "../agent/profiles.js"; import { DEFAULT_KNOWN_ENGINES, @@ -1184,18 +1187,20 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { settings?.providers[provider.providerName], catalog?.find((entry) => entry.name === provider.providerName), ); - // DeepSeek V4 pins the coder leaf to low unless the profile explicitly - // pins effort (winning config). - const coderEffortPin = + // DeepSeek V4 pins per-role effort (CL-10294): dispatch/planner/reviewer + // max, coder cheapest, other leaves xhigh. The explicit effortPin and the + // role default still win over this table when supported. + const roleEffortPin = resolved.effortPin ?? - (!orchestrator && - resolved.directorId === "coder" && - isDeepSeekV4Model(provider.model) - ? "low" + (isDeepSeekV4Model(provider.model) && + resolved.directorId in DEEPSEEK_V4_ROLE_EFFORT + ? DEEPSEEK_V4_ROLE_EFFORT[ + resolved.directorId as keyof typeof DEEPSEEK_V4_ROLE_EFFORT + ] : undefined); const effort = resolveEffortForRole({ orchestrator, - ...(coderEffortPin !== undefined ? { pin: coderEffortPin } : {}), + ...(roleEffortPin !== undefined ? { pin: roleEffortPin } : {}), ...(resolved.roleDefault !== undefined ? { roleDefault: resolved.roleDefault } : {}),