#!/usr/bin/env python3
"""
Minimal public reproduction: deepseek-v4-pro infinite tool-call loop
under long context + repeated identical tool history.
Deps: pip install openai
Env: DASHSCOPE_API_KEY
Run: python repro_deepseek_loop.py # 8 trials
python repro_deepseek_loop.py 20 # 20 trials
"""
import json, os, sys, time, random
from openai import OpenAI
API_KEY = os.environ.get("DASHSCOPE_API_KEY")
if not API_KEY:
sys.exit("set DASHSCOPE_API_KEY first")
client = OpenAI(api_key=API_KEY,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1")
MODEL = "deepseek-v4-pro"
SYSTEM_PROMPT = (
"You are CodeBot, an interactive CLI coding assistant.\n"
"Use the provided tools to inspect the user's project and help with software tasks.\n"
"# Working style\n"
"- Prefer reading the actual files before answering.\n"
"- When in doubt, run a command to verify rather than guessing.\n"
"- Keep responses short and concrete.\n"
"# Project context\n"
"- Working directory: /home/dev/sample-project\n"
"- Stack: a generic Python + Markdown repo.\n"
)
SYSTEM_PROMPT += "\n\n" + ("# extra style guidance line.\n" * 200)
TOOLS = [
{"type":"function","function":{"name":"run_shell_command",
"description":"Execute a shell command.",
"parameters":{"type":"object","properties":{
"command":{"type":"string"},"description":{"type":"string"}},
"required":["command"]}}},
{"type":"function","function":{"name":"read_file",
"description":"Read a text file.",
"parameters":{"type":"object","properties":{
"absolute_path":{"type":"string"}},
"required":["absolute_path"]}}},
{"type":"function","function":{"name":"grep_search",
"description":"Search files by regex.",
"parameters":{"type":"object","properties":{
"pattern":{"type":"string"},"path":{"type":"string"}},
"required":["pattern"]}}},
{"type":"function","function":{"name":"list_directory",
"description":"List directory.",
"parameters":{"type":"object","properties":{
"path":{"type":"string"}},
"required":["path"]}}},
]
LS_OUTPUT = ("Command: ls\nOutput:\nREADME.md\nCHANGELOG.md\n"
"docs/\nsrc/\ntests/\npyproject.toml\n")
FAKE_README = "# Sample Project\n\n" + "\n".join(
f"- bullet {i}: neutral description." for i in range(60))
FAKE_PY = "import os\n\n" + "\n".join(
f"def f{i}(x):\n return x * 2 + {i}\n" for i in range(20))
def fake_tool_pair(idx, kind):
cid = f"call_pad_{idx}"
if kind == "read":
path = random.choice([
"/home/dev/sample-project/README.md",
"/home/dev/sample-project/src/utils.py",
"/home/dev/sample-project/docs/intro.md",
])
a = {"role":"assistant","content":f"Reading {path.split('/')[-1]}.",
"tool_calls":[{"id":cid,"type":"function","function":{
"name":"read_file",
"arguments":json.dumps({"absolute_path":path})}}]}
body = FAKE_README if path.endswith(".md") else FAKE_PY
r = {"role":"tool","tool_call_id":cid,"content":f"File: {path}\n\n{body}"}
elif kind == "grep":
pat = random.choice(["TODO","FIXME","def ","import "])
a = {"role":"assistant","content":f"Searching {pat}.",
"tool_calls":[{"id":cid,"type":"function","function":{
"name":"grep_search",
"arguments":json.dumps({"pattern":pat,"path":"src"})}}]}
r = {"role":"tool","tool_call_id":cid,
"content":"\n".join(f"src/m_{i}.py:{10+i}: {pat}" for i in range(30))}
elif kind == "list":
path = random.choice(["src","docs","tests","."])
a = {"role":"assistant","content":f"Listing {path}.",
"tool_calls":[{"id":cid,"type":"function","function":{
"name":"list_directory",
"arguments":json.dumps({"path":path})}}]}
r = {"role":"tool","tool_call_id":cid,
"content":"\n".join(f"{path}/entry_{i}.py" for i in range(25))}
else:
cmd = random.choice(["python -m pytest -q","git log --oneline -5",
"wc -l src/*.py"])
a = {"role":"assistant","content":f"Running: {cmd}",
"tool_calls":[{"id":cid,"type":"function","function":{
"name":"run_shell_command",
"arguments":json.dumps({"command":cmd,"description":"check"})}}]}
r = {"role":"tool","tool_call_id":cid,
"content":f"Command: {cmd}\nOutput:\n" +
"\n".join(f"fake line {i}" for i in range(40))}
return a, r
def build_history(target_tokens, n_prior):
msgs = [{"role":"system","content":SYSTEM_PROMPT},
{"role":"user","content":"Tell me what this project is about."}]
random.seed(42)
idx = 0
while idx < 250:
a, r = fake_tool_pair(idx, random.choice(["read","grep","list","shell"]))
msgs += [a, r]; idx += 1
if sum(len(json.dumps(m)) for m in msgs) // 3 >= target_tokens:
break
msgs += [{"role":"user","content":"Got it, thanks."},
{"role":"assistant","content":"You're welcome."}]
msgs.append({"role":"user","content":"What files are in the project root?"})
cid0 = "call_ls_init"
msgs += [{"role":"assistant","content":"Let me list the project root.",
"tool_calls":[{"id":cid0,"type":"function","function":{
"name":"run_shell_command",
"arguments":json.dumps({"command":"ls",
"description":"List project root"})}}]},
{"role":"tool","tool_call_id":cid0,"content":LS_OUTPUT}]
for i in range(n_prior):
cid = f"call_loop_{i}"
msgs += [{"role":"assistant",
"content":"Let me summarize the files clearly.",
"tool_calls":[{"id":cid,"type":"function","function":{
"name":"run_shell_command",
"arguments":json.dumps({"command":"ls",
"description":"Summarize project root"})}}]},
{"role":"tool","tool_call_id":cid,"content":LS_OUTPUT}]
return msgs
def one_turn(messages):
resp = client.chat.completions.create(
model=MODEL, messages=messages, tools=TOOLS,
tool_choice="auto", temperature=0.3, max_tokens=2000)
msg = resp.choices[0].message
text = (msg.content or "").strip()
tcs = [{"name":tc.function.name,"args":tc.function.arguments}
for tc in (msg.tool_calls or [])]
return text, tcs, resp.usage.prompt_tokens, resp.usage.completion_tokens
def classify(text, tcs):
for tc in tcs:
try:
args = json.loads(tc["args"])
except Exception:
args = {}
if tc["name"] == "run_shell_command" and args.get("command","").strip() == "ls":
return "LOOP"
if tcs: return "OTHER"
if text: return "TEXT"
return "EMPTY"
def main():
n_trials = int(sys.argv[1]) if len(sys.argv)>1 else 8
target = int(sys.argv[2]) if len(sys.argv)>2 else 150000
prior = int(sys.argv[3]) if len(sys.argv)>3 else 20
msgs = build_history(target, prior)
print(f"# trials={n_trials} target_ctx~{target} prior={prior} "
f"temp=0.3 model={MODEL}\n")
counts = {"LOOP":0,"OTHER":0,"TEXT":0,"EMPTY":0,"ERR":0}
for i in range(n_trials):
try:
text, tcs, pt, ct = one_turn(msgs)
v = classify(text, tcs); counts[v] += 1
print(f"[t{i+1:>2}] pt={pt:>7} ct={ct:>4} → {v:<5} | "
f"text={text[:60]!r} | tcs={[(t['name'],t['args'][:50]) for t in tcs]}")
except Exception as e:
counts["ERR"] += 1
print(f"[t{i+1:>2}] ERROR: {e!r}")
time.sleep(0.5)
print("\n=== Summary ===")
for k, v in counts.items():
print(f" {k:<5}: {v}/{n_trials}")
if counts["LOOP"] or counts["OTHER"]:
pct = (counts["LOOP"] + counts["OTHER"]) * 100 // n_trials
print(f"\n→ Failed-to-respond rate (LOOP+OTHER): {pct}%")
if __name__ == "__main__":
main()
What happened?
deepseek-v4-pro(via the OpenAI-compatible Bailian / DashScope endpoint) gets stuck in a tool-call loop once the in-context history grows long, inside the model's normal context window on both channels I tested. In a real session of mine (qwen-code v0.17.0), after I asked "what files changed on this branch?", the assistant calledgit status --shortonce normally, and from the second round onward every reply was the same shape:[ { "text": "Let me summarize the changes clearly.", "thought": true }, { "functionCall": { "name": "run_shell_command", "args": { "command": "git status --short", "description": "Git status summary" } }} ]The model even has a kind of self-awareness — it produced
All right, let me stop this loop and give a clear summary.andLet me just provide the final summary.in thethoughtslot — but every "I'm going to stop" line still ships the same tool call, so qwen-code keeps executing it and feeding the result back. There is no client-side circuit breaker, so it ran until I hit ESC.Cost of this single loop:
"Let me summarize the changes clearly."repeatscached_content_token_countNot an over-limit case. I verified the Bailian endpoint actually serves >400k input tokens for this model without rejecting (
prompt_tokens=400,013on a single test request, normal200response, model still recalled the content). The 170k original session and the ~101k public repro below are both comfortably inside the channel's working window. The DashScope/v1/models/deepseek-v4-proendpoint reportsmax_tokens=131072— that limit is a channel-specific surface, the underlying model is being served with a much larger window on the Bailian side. Either way, this is not a "you went over the limit" effect.Related symptom (no repro / no root cause): #4055.
What did you expect to happen?
(tool_name, normalized_args)calls within a single turn and break the loop — e.g. after 3 identical calls, force the model to answer in text instead of calling the same tool again.tool_choice="none"so the model has to produce a textual reply.Minimum reproduction
I wrote a single-file public repro (no business code, only generic README / src / docs /
ls). It only needsDASHSCOPE_API_KEYand theopenaiSDK.Trigger recipe (all three are needed; any one alone does not reproduce):
deepseek-v4-pro(Bailian / DashScope, OpenAI-compatible)thought="Let me summarize" + same tool_callin historyObserved reproduction rate (8 trials, prior=20, temp=0.3, ctx≈101k):
lstool call againlist_directory) but still no textNegative-control table — important, this is why my first guess ("repeated history alone is enough") was wrong:
So this is not "model is broken in OpenAI tool-calling schema", and it is not an over-limit effect (verified — see the >400k test above). It is a long-but-in-window context + self-reinforcing in-context loop effect. Once the model collapses once, the next round's history has one more "previously also called the same tool" example, which pushes the next collapse probability higher. That is the snowball that produced 43 consecutive repeats in the original session.
repro_deepseek_loop.py (single file, ~200 lines, no business code)
Suggested fix direction
Two cheap client-side defenses that would have caught this loop on round 3 instead of round 43:
(tool_name, normalized_args)within the current turn. After ≥3 consecutive identical calls, intercept the next one and inject a system message:You already ran the same tool 3 times with identical arguments. Reply to the user in text — do not call this tool again.This breaks the self-reinforcing in-context loop early.tool_choice="none"so the model has to fall back to a textual reply.Both are model-agnostic — even if DeepSeek upstream tightens the schema later, this defense is worth having for every model in the OpenAI-compatible tool-calling path.
A third (server-side, not in this repo): the Bailian channel reports
cached_content_token_count=0for every round, so 99% of the 8.9M input tokens this loop burned were the same prefix being re-sent. Even if the loop bug stays open, enabling prompt cache would significantly limit the blast radius.Client information
Client Information
Login information
OpenAI-compatible provider, model
bailian/deepseek-v4-pro(same backend asdashscope/deepseek-v4-proused in the public repro above).Anything else we need to know?
bailian/deepseek-v4-prowithprompt_tokens=400,013and got a normal200back with the right content recall. The DashScope/v1/models/deepseek-v4-proendpoint advertisesmax_tokens=131072, but that's a per-channel surface — the model on the Bailian side accepts much more. The 170k of the original session and the ~101k of the public repro are both inside the working window.cached_content_token_count=0on this channel, which is what turned the 50-round loop into 8.9M billed input tokens. Even without the loop fix, enabling prompt cache would significantly cut the blast radius.