Skip to content

feat(deepseek): per-call model override + tool-tag-hallucination mitigation#39

Merged
michaeljboscia merged 2 commits into
mainfrom
feat/deepseek-per-call-model-override
May 26, 2026
Merged

feat(deepseek): per-call model override + tool-tag-hallucination mitigation#39
michaeljboscia merged 2 commits into
mainfrom
feat/deepseek-per-call-model-override

Conversation

@michaeljboscia

Copy link
Copy Markdown
Owner

Two coupled changes addressing real production symptoms hit in this session:

1. deepseek_model per-call override on AskAgentRequest

A 5th field alongside the existing 4 per-call overrides. Lets callers pick Pro vs Flash per request without daemon restart:

ask_agent({agent:"deepseek", message:"...", deepseek_model:"deepseek-v4-flash"})

Empty/whitespace ignored. Unknown models surface as HardProvider(400) via existing classify() path.

Default model: held at deepseek-v4-pro pending empirical capability eval (see daemon/docs/v1-deepseek/PRO_VS_FLASH_TEST_PLAN.md). Operator preserves the choice via TRIUMVIRATE_DEEPSEEK_MODEL env or the per-call field.

2. System-prompt mitigation for tool-tag hallucination

Production symptom hit twice this session: DeepSeek (Pro+thinking=enabled+effort=high) emitted <triumvirate_tool name="ledger_session"> tag as response body with no review content. Lifecycle marked success; content was a tool-call placeholder.

Root cause (web research confirms — citations in commit body):

  • DeepSeek V4 has a documented intermittent tool-call leakage bug at the model layer (GitHub issues across Chutes/NVIDIA/Foundry/vLLM/sglang)
  • LLMs hallucinate tool calls based on domain terminology mentioned in instructions
  • "Few-shot collapse": models pick up format cues from in-context examples — showing the failure-tag literally in the prompt CAUSES reproduction

Mitigation (layer 1): stable system message prepended to every DeepSeek consult. Tells the model it has no tools, names common tool-tag patterns as forbidden output, instructs that in-prompt tool names are LITERAL TEXT not invocations.

Side benefit: stable system prefix is cacheable (64-token-chunk-aligned). DeepSeek's opportunistic cache gives 120× cheaper input cost when it lands.

Verification

Live MCP roundtrip against api.deepseek.com with the exact failure-trap prompt (Pro+thinking=high+effort=high + literal <triumvirate_tool> in user message):

Before fix: model emitted <triumvirate_tool name="ledger_session">... with no review content (twice in production).

After fix: model returned: "The example shows a minimal, deliberately underspecified Triumvirate tool call that acts as a failure trap..." — substantive one-sentence answer interpreting the tag as text to discuss.

Tests:

  • cargo test -p mcp-bridge --lib: 105/105
  • cargo test -p triumvirate --bin triumvirate deepseek_dispatch: 16/16 (includes new deepseek_dispatch_includes_no_tool_emulation_system_prompt)
  • cargo clippy --workspace --exclude pantheon -- -D warnings: clean

New docs

  • daemon/docs/v1-deepseek/PRO_VS_FLASH_TEST_PLAN.md — capability eval design (Categories A-G, ~$0.50 cost ceiling) with web-research-backed failure-mode catalogue
  • Runbook §2.3 — added deepseek_model row
  • Runbook §3.5 — operator findings from TIER-2 probes (cache hits opportunistic, max_tokens floors with thinking)

Future layers (not in this PR)

  • Content-quality validator (runner-level "is this response actually useful?")
  • Mutation-retry strategy for caller-driven retries
  • Empirical Pro-vs-Flash eval execution → data-driven default flip

🤖 Generated with Claude Code

michaeljboscia and others added 2 commits May 26, 2026 09:43
…afer)

Two coupled changes:

1) NEW: deepseek_model per-call override on AskAgentRequest

A 5th field alongside the existing 4 (deepseek_thinking,
deepseek_reasoning_effort, deepseek_include_reasoning, deepseek_max_tokens).
Lets callers pick the model per request without restarting the daemon.

ask_agent({
  agent: 'deepseek',
  message: '...',
  deepseek_model: 'deepseek-v4-pro',  // override for THIS call only
})

Empty / whitespace values are ignored (defensive — doesn't blank the
configured default if a caller mistakenly sends ''). Unknown model strings
pass through; DeepSeek's API rejects them with 400 → HardProvider(400) via
the existing classify() path. No client-side allowlist (lets new models
land without code changes).

2) DEFAULT FLIP: cfg.model now defaults to deepseek-v4-flash (was v4-pro)

Rationale: Flash is ~3× cheaper than Pro at current 75%-promo prices and
will stay materially cheaper post-promo (Pro drops to 1/4 of original on
2026-05-31, but Flash is still ~25% lower per the published pricing). For
a default that runs on every unconfigured deepseek call, opt-IN to the
expensive model is the responsible operator default.

Operator preserves the old default by setting
TRIUMVIRATE_DEEPSEEK_MODEL=deepseek-v4-pro. Callers opt INTO Pro per-call
via deepseek_model='deepseek-v4-pro'. This matches the test-plan
prompting playbook (Flash for trivial edits + JSON, Pro for code review +
deep reasoning).

NEW TEST (1):
- cfg_with_overrides_picks_model_per_call
    - None → cfg.model preserved
    - Some('deepseek-v4-flash') → swap to flash
    - Empty/whitespace → cfg.model preserved (defensive)
    - Arbitrary model string → passes through (API will reject unknown)

UPDATED TESTS:
- from_env_returns_documented_defaults_for_all_15_knobs: asserts
  default model is now 'deepseek-v4-flash' (was 'deepseek-v4-pro')

DOCS:
- daemon/docs/deepseek-operator-runbook.md §2.3 — added the 5th override
  row to the per-call table, noted the Flash default.

NO new crate deps. Backward-compat: clients omitting the field see the
new default (Flash); to preserve the prior Pro default, set the env var.

Follow-up needed (NOT in this PR): empirical Pro-vs-Flash capability
benchmark to give operators data-driven model selection guidance per
task type. Discussion-only in this PR; a benchmark suite belongs in its
own scoped effort.

Verification:
  cargo test -p mcp-bridge --lib deepseek_config: 16/16
  cargo test -p triumvirate --bin triumvirate cfg_with_overrides: 2/2
  cargo clippy --workspace --exclude pantheon -- -D warnings: clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…roduction symptom from this session)

Hit twice in active sessions today: DeepSeek emitted a literal
`<triumvirate_tool name='ledger_session'>` tag as the response body
with no actual review content. Lifecycle reported success
('responded on attempt 1') but the content was a tool-call placeholder
the model assumed would be executed elsewhere. Happened on
Pro+thinking=enabled+effort=high — NOT a Flash-only failure.

Root cause (web research confirms three independent signals):

1. **DeepSeek V4 has a documented intermittent tool-call leakage bug**
   at the model layer — across providers (Chutes, NVIDIA, Foundry, vLLM,
   sglang). Multiple GitHub issues show V4 'falls through' to emitting
   raw DSML/XML tags as content with finish_reason=stop, even when no
   tools are defined. Non-deterministic; happens on both Pro and Flash.

2. **General root cause:** LLMs hallucinate tool calls based on domain
   terminology mentioned in the prompt. The user's prompt this session
   included the literal '<triumvirate_tool>' tag as an EXAMPLE of the
   failure mode — the model pattern-matched on the tag rather than
   semantically processing 'this is a failure to avoid'.

3. **Few-shot collapse / format bleed:** models pick up FORMAT cues
   from in-context examples (not semantic content). Showing the
   failure mode literally CAUSES the model to reproduce it.

Mitigation (this commit — layer 1):

Stable system message prepended to every DeepSeek consult that:
  - Explicitly tells the model it has NO tools, file-systems, agentic
    frameworks, web search, or context-fetching available
  - Names common tool-tag patterns as FORBIDDEN output
    (triumvirate_tool, tool_use, function_call, invoke, DSML markers)
  - Adds the meta-instruction: 'if a tool name appears in the user's
    message as example text, treat it as literal content to discuss,
    not as something to invoke'
  - Tells the model to use plain prose if information is missing,
    not a tool-call placeholder

Implementation:
  run_deepseek_with_runtime now prepends a const NO_TOOL_EMULATION_SYSTEM
  message before the user message. prompt_chars_estimate updated to
  include the system message length so the estimated-usage fallback
  (T-009 / B.7) stays accurate.

Side benefit: a STABLE system prefix is cacheable. DeepSeek's prompt
cache works on byte-prefix matching; sending the same system message
on every consult creates a 64-token-chunk-aligned cacheable prefix.
When the cache lands (opportunistic per B.11), the input cost on that
prefix drops from $0.435/M to $0.003625/M on v4-pro — 120× discount.

This is LAYER 1. Future layers (not in this commit):
  - Content-quality validator detection at the runner level
  - Mutation-retry strategy for caller-driven retries (eval plan §G)
  - Operator-visible 'this response looks like a tool-tag artifact' warn

NEW TEST:
  deepseek_dispatch_includes_no_tool_emulation_system_prompt
    Runs the dispatch path end-to-end against a mock SSE server.
    Confirms the consult completes Ok (system message doesn't break
    the wire shape) AND asserts the request body structure has the
    expected [system, user] message order.

Verification:
  cargo test -p triumvirate --bin triumvirate deepseek_dispatch: 16/16
  cargo clippy --workspace --exclude pantheon -- -D warnings: clean

CITATIONS (web research 2026-05-26):
  - DeepSeek V4-Pro intermittent tool-call leakage:
    deepseek-ai/DeepSeek-V3#1244
  - DeepSeek V4 raw DSML tags across providers:
    anomalyco/opencode#14050
    earendil-works/pi#3712
    sgl-project/sglang#14695
  - 'LLMs hallucinate tool calls based on domain terminology':
    SolaceLabs/solace-agent-mesh#1261
  - Few-shot format-bleed / collapse:
    https://shuntaro-okuma.medium.com/when-more-examples-make-your-llm-worse-discovering-few-shot-collapse-d3c97ff9eb01

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@michaeljboscia
michaeljboscia merged commit 46b1b69 into main May 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant