toolcall_corruption.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. #!/usr/bin/env python3
  2. """Greedy decoding corrupts tokens inside a tool call at long context.
  3. Standard library only. Point it at a running vLLM OpenAI server:
  4. python toolcall_corruption.py --url http://127.0.0.1:8000 --model glm53
  5. Builds a 42272-token coding-agent transcript (47 tools, 24 tool-call/result
  6. pairs, all generated from fixed seeds so every run sends identical bytes), then
  7. sends it N times at temperature 0 with a fixed seed. The correct continuation is
  8. a Bash call repeating a command from earlier in the conversation, so any
  9. difference between runs is visible token by token.
  10. Observed on 4x GB10 (sm121) at TP=4, GLM-5.3-Flash-NVFP4. One run of 40, all at
  11. temperature 0 with the same seed, gave 5 distinct completions:
  12. 30 runs correct
  13. 4 runs pos 23 ' identical' for '>&' -> pytest -q 2 identical 2>&1
  14. 3 runs pos 26 ' pytest' for ' tail' -> ... 2>&1 | pytest -q 2>&1 | tail -40
  15. 2 runs pos 23 ' Bash' for '>&' -> pytest -q 2 Bash 2>&1
  16. 1 run pos 31 ' Read' for 'description' -> <arg_key> Read more: https://...
  17. A corrupted tool NAME is worse than a corrupted argument: the GLM parser runs
  18. with validate_tool_names=True, so an unknown name emits zero deltas and the
  19. request finishes `stop` with no content and no tool calls. Clients report that
  20. as "completed response with no content".
  21. Reducing the transcript weakens it. A short prompt (2k tokens) is bit-stable
  22. over 16 runs, and a transcript whose padding repeats the command's own output
  23. is stable over 40, because every token of the command is then high-margin.
  24. """
  25. import argparse
  26. import collections
  27. import json
  28. import queue
  29. import random
  30. import threading
  31. import time
  32. import urllib.request
  33. SYSTEM = """You are a coding agent operating inside a developer's repository. You help by reading files, searching the codebase, running commands, and editing code. Always investigate before you change anything. Prefer small, verifiable steps. When you need information from the repository, call a tool rather than guessing. When a task is complete, summarize what you did in plain language.
  34. Guidelines:
  35. - Never invent file contents. Read them.
  36. - Use Grep to locate symbols and Glob to locate files by name.
  37. - Use Bash only for commands that cannot be done with the other tools.
  38. - Keep edits minimal and targeted; do not reformat unrelated code.
  39. - If a command fails, read the error and decide the next step; do not repeat the same command blindly.
  40. - Report absolute paths.
  41. """
  42. def tool(name, desc, props, req):
  43. return {"type": "function", "function": {"name": name, "description": desc,
  44. "parameters": {"type": "object", "properties": props, "required": req}}}
  45. TOOLS = [
  46. tool("Read", "Read a file from the filesystem. Returns the content with line numbers.",
  47. {"file_path": {"type": "string", "description": "Absolute path"},
  48. "offset": {"type": "integer"}, "limit": {"type": "integer"}}, ["file_path"]),
  49. tool("Write", "Write a file, overwriting it.",
  50. {"file_path": {"type": "string"}, "content": {"type": "string"}}, ["file_path", "content"]),
  51. tool("Edit", "Exact string replacement in a file.",
  52. {"file_path": {"type": "string"}, "old_string": {"type": "string"},
  53. "new_string": {"type": "string"}, "replace_all": {"type": "boolean"}},
  54. ["file_path", "old_string", "new_string"]),
  55. tool("Bash", "Run a shell command and return stdout/stderr.",
  56. {"command": {"type": "string"}, "timeout": {"type": "integer"},
  57. "description": {"type": "string"}}, ["command"]),
  58. tool("Grep", "Search file contents with a regex (ripgrep).",
  59. {"pattern": {"type": "string"}, "path": {"type": "string"}, "glob": {"type": "string"},
  60. "output_mode": {"type": "string", "enum": ["content", "files_with_matches", "count"]},
  61. "-n": {"type": "boolean"}, "-i": {"type": "boolean"}}, ["pattern"]),
  62. tool("Glob", "Find files by glob pattern.",
  63. {"pattern": {"type": "string"}, "path": {"type": "string"}}, ["pattern"]),
  64. tool("WebFetch", "Fetch a URL and return its text.",
  65. {"url": {"type": "string"}, "prompt": {"type": "string"}}, ["url", "prompt"]),
  66. tool("WebSearch", "Search the web.", {"query": {"type": "string"}}, ["query"]),
  67. tool("TodoWrite", "Replace the todo list.",
  68. {"todos": {"type": "array", "items": {"type": "object", "properties": {
  69. "content": {"type": "string"}, "status": {"type": "string",
  70. "enum": ["pending", "in_progress", "completed"]}, "activeForm": {"type": "string"}},
  71. "required": ["content", "status", "activeForm"]}}}, ["todos"]),
  72. tool("Agent", "Launch a sub-agent for a multi-step task.",
  73. {"description": {"type": "string"}, "prompt": {"type": "string"},
  74. "subagent_type": {"type": "string"}}, ["description", "prompt"]),
  75. tool("NotebookEdit", "Edit a Jupyter notebook cell.",
  76. {"notebook_path": {"type": "string"}, "cell_id": {"type": "string"},
  77. "new_source": {"type": "string"}, "cell_type": {"type": "string"},
  78. "edit_mode": {"type": "string"}}, ["notebook_path", "new_source"]),
  79. tool("AskUserQuestion", "Ask the user a clarifying question.",
  80. {"questions": {"type": "array", "items": {"type": "object"}}}, ["questions"]),
  81. tool("KillShell", "Kill a background shell.", {"shell_id": {"type": "string"}}, ["shell_id"]),
  82. tool("TaskOutput", "Read output of a background task.",
  83. {"task_id": {"type": "string"}, "block": {"type": "boolean"}}, ["task_id"]),
  84. tool("EnterPlanMode", "Switch to planning mode.", {}, []),
  85. tool("ExitPlanMode", "Leave planning mode with a plan.", {"plan": {"type": "string"}}, ["plan"]),
  86. tool("LSP", "Language server query.",
  87. {"operation": {"type": "string"}, "filePath": {"type": "string"},
  88. "line": {"type": "integer"}, "character": {"type": "integer"}}, ["operation", "filePath"]),
  89. tool("SlashCommand", "Run a slash command.", {"command": {"type": "string"}}, ["command"]),
  90. ]
  91. FILE_A = "\n".join(f"{i:>6}\t" + line for i, line in enumerate("""#!/usr/bin/env python3
  92. import json
  93. import os
  94. import sys
  95. import time
  96. from dataclasses import dataclass, field
  97. from pathlib import Path
  98. @dataclass
  99. class Job:
  100. name: str
  101. command: list[str]
  102. retries: int = 0
  103. env: dict = field(default_factory=dict)
  104. def load_jobs(path: Path) -> list[Job]:
  105. with path.open() as f:
  106. raw = json.load(f)
  107. jobs = []
  108. for item in raw["jobs"]:
  109. jobs.append(Job(name=item["name"], command=item["command"],
  110. retries=item.get("retries", 0), env=item.get("env", {})))
  111. return jobs
  112. def run_job(job: Job) -> int:
  113. import subprocess
  114. env = dict(os.environ)
  115. env.update(job.env)
  116. attempt = 0
  117. while True:
  118. started = time.monotonic()
  119. proc = subprocess.run(job.command, env=env, capture_output=True, text=True)
  120. elapsed = time.monotonic() - started
  121. if proc.returncode == 0:
  122. print(f"ok {job.name} in {elapsed:.1f}s")
  123. return 0
  124. attempt += 1
  125. print(f"fail {job.name} rc={proc.returncode} attempt={attempt}", file=sys.stderr)
  126. print(proc.stderr, file=sys.stderr)
  127. if attempt > job.retries:
  128. return proc.returncode
  129. def main() -> int:
  130. if len(sys.argv) != 2:
  131. print("usage: runner.py JOBS.json", file=sys.stderr)
  132. return 2
  133. jobs = load_jobs(Path(sys.argv[1]))
  134. worst = 0
  135. for job in jobs:
  136. rc = run_job(job)
  137. worst = max(worst, rc)
  138. return worst
  139. if __name__ == "__main__":
  140. raise SystemExit(main())
  141. """.splitlines(), 1))
  142. TEST_OUT = """============================= test session starts ==============================
  143. platform linux -- Python 3.12.3, pytest-8.3.2
  144. collected 14 items
  145. tests/test_runner.py ..........F... [100%]
  146. =================================== FAILURES ===================================
  147. ____________________________ test_retry_env_isolated ___________________________
  148. def test_retry_env_isolated(tmp_path, monkeypatch):
  149. monkeypatch.setenv("SHARED", "outer")
  150. job = Job(name="x", command=["sh", "-c", "test \\"$SHARED\\" = inner"], retries=1, env={"SHARED": "inner"})
  151. > assert run_job(job) == 0
  152. E assert 1 == 0
  153. tests/test_runner.py:61: AssertionError
  154. ----------------------------- Captured stderr call -----------------------------
  155. fail x rc=1 attempt=1
  156. fail x rc=1 attempt=2
  157. =========================== short test summary info ============================
  158. FAILED tests/test_runner.py::test_retry_env_isolated - assert 1 == 0
  159. ========================= 1 failed, 13 passed in 0.41s =========================
  160. """
  161. GREP_OUT = """/home/dev/proj/runner.py:32: env = dict(os.environ)
  162. /home/dev/proj/runner.py:33: env.update(job.env)
  163. /home/dev/proj/tests/test_runner.py:55:def test_retry_env_isolated(tmp_path, monkeypatch):
  164. /home/dev/proj/tests/test_runner.py:57: job = Job(name="x", command=["sh", "-c", "test \\"$SHARED\\" = inner"], retries=1, env={"SHARED": "inner"})
  165. /home/dev/proj/docs/design.md:14:Each job's `env` overlays the process environment; keys set to null must unset."""
  166. def tc(name, args, cid):
  167. return {"id": cid, "type": "function", "function": {"name": name, "arguments": json.dumps(args)}}
  168. def extra_tools(n):
  169. out = []
  170. for i in range(n):
  171. out.append(tool(
  172. f"mcp__server{i%5}__op{i}",
  173. f"Operation {i} on server {i%5}: queries the {['inventory','billing','metrics','tickets','deploys'][i%5]} system and returns a JSON document. Use when the user asks about {['stock levels','invoices','latency','support cases','releases'][i%5]}.",
  174. {"query": {"type": "string", "description": "Free-text query"},
  175. "limit": {"type": "integer", "description": "Max rows", "default": 50},
  176. "since": {"type": "string", "description": "ISO-8601 timestamp"},
  177. "fields": {"type": "array", "items": {"type": "string"}}},
  178. ["query"]))
  179. return out
  180. TOOLS47 = TOOLS + extra_tools(47 - len(TOOLS))
  181. def big_file(seed, lines=220):
  182. rnd = random.Random(seed)
  183. words = "config loader parse validate schema emit retry backoff socket frame header payload checksum rotate flush queue worker shard replica leader follower epoch term".split()
  184. out = []
  185. for i in range(1, lines + 1):
  186. k = rnd.randint(2, 7)
  187. ident = "_".join(rnd.choice(words) for _ in range(2))
  188. out.append(f"{i:>6}\t" + (" " * rnd.randint(0, 3)) + rnd.choice([
  189. f"def {ident}(self, {', '.join(rnd.choice(words) for _ in range(k))}):",
  190. f"{ident} = {rnd.choice(words)}.{rnd.choice(words)}({rnd.randint(0, 999)})",
  191. f"if {ident} is None or {rnd.choice(words)} > {rnd.randint(1, 64)}:",
  192. f"return {ident}",
  193. f"# {rnd.choice(words)} {rnd.choice(words)} {rnd.choice(words)} handled below",
  194. f"raise ValueError(f\"{ident} out of range: {{{rnd.choice(words)}}}\")",
  195. f"for {rnd.choice(words)} in {ident}:",
  196. f"self._{ident}[{rnd.choice(words)}] = {rnd.choice(words)}",
  197. "",
  198. ]))
  199. return "\n".join(out)
  200. def build(nonce, pairs=24):
  201. msgs = [{"role": "system", "content": SYSTEM + f"\n\nSession: {nonce}\n"}]
  202. msgs.append({"role": "user", "content": "The nightly job runner in /home/dev/proj is flaky: some jobs report success but their env overrides are dropped on retry, and the metrics exporter shows gaps. Investigate the whole flow (runner.py, the exporter under svc/, and the tests), find the root causes, and fix them. Run the tests when you think you are done."})
  203. files = [f"/home/dev/proj/{p}" for p in ["runner.py", "svc/exporter.py", "svc/metrics.py", "svc/queue.py", "svc/config.py", "tests/test_runner.py", "tests/test_exporter.py", "svc/retry.py", "svc/shard.py", "svc/leader.py"]]
  204. rnd = random.Random(nonce)
  205. for i in range(pairs):
  206. cid = f"call_{i+1}"
  207. kind = i % 6
  208. if kind == 0:
  209. call = tc("Bash", {"command": "cd /home/dev/proj && python -m pytest -q 2>&1 | tail -40", "description": "Run tests"}, cid)
  210. result = TEST_OUT
  211. elif kind in (1, 2, 4):
  212. f = files[(i * 3) % len(files)]
  213. call = tc("Read", {"file_path": f}, cid)
  214. result = FILE_A if f.endswith("runner.py") else big_file(f + nonce[:2])
  215. elif kind == 3:
  216. call = tc("Grep", {"pattern": rnd.choice(["env", "retry", "flush", "shard", "epoch"]), "path": "/home/dev/proj", "output_mode": "content", "-n": True}, cid)
  217. result = GREP_OUT + "\n" + "\n".join(f"/home/dev/proj/svc/{rnd.choice(['exporter','queue','retry'])}.py:{rnd.randint(1,300)}: {rnd.choice(['env','retry','flush'])} = {rnd.choice(['self','cfg','job'])}.{rnd.choice(['get','pop','update'])}(\"{rnd.choice(['SHARED','TIMEOUT','SHARD'])}\")" for _ in range(25))
  218. else:
  219. call = tc(f"mcp__server{i%5}__op{i%47}", {"query": "job runner gaps last 24h", "limit": 20}, cid)
  220. result = json.dumps({"rows": [{"ts": f"2026-09-0{1+(j%6)}T0{j%9}:1{j%5}:00Z", "job": f"nightly-{j}", "status": rnd.choice(["ok", "retry", "fail"]), "env_keys": rnd.randint(0, 5), "latency_ms": rnd.randint(50, 9000)} for j in range(20)], "total": 20})
  221. msgs.append({"role": "assistant", "content": "", "tool_calls": [call]})
  222. msgs.append({"role": "tool", "tool_call_id": cid, "content": result})
  223. return msgs
  224. def post(base, path, payload, timeout=1800):
  225. request = urllib.request.Request(
  226. base + path, data=json.dumps(payload).encode(),
  227. headers={"content-type": "application/json"})
  228. with urllib.request.urlopen(request, timeout=timeout) as response:
  229. return json.loads(response.read())
  230. def run_once(base, model, messages, max_tokens):
  231. """Return the generated tokens, including the tool-call control tokens."""
  232. body = {"model": model, "messages": messages, "tools": TOOLS47,
  233. "temperature": 0, "seed": 1234, "max_tokens": max_tokens,
  234. "logprobs": True, "top_logprobs": 1}
  235. choice = post(base, "/v1/chat/completions", body)["choices"][0]
  236. return [t["token"] for t in (choice.get("logprobs") or {}).get("content") or []]
  237. def main():
  238. ap = argparse.ArgumentParser()
  239. ap.add_argument("--url", default="http://127.0.0.1:8000")
  240. ap.add_argument("--model", required=True)
  241. ap.add_argument("--max-tokens", type=int, default=40)
  242. ap.add_argument("--runs", type=int, default=40)
  243. ap.add_argument("--concurrency", type=int, default=1)
  244. a = ap.parse_args()
  245. base = a.url.rstrip("/")
  246. messages = build("presstest")
  247. n_tokens = post(base, "/tokenize",
  248. {"model": a.model, "messages": messages, "tools": TOOLS47,
  249. "add_generation_prompt": True}, timeout=300)["count"]
  250. print(f"prompt: {n_tokens} tokens, {a.runs} runs, temperature 0, seed 1234",
  251. flush=True)
  252. runs, lock = [], threading.Lock()
  253. work = queue.Queue()
  254. for i in range(a.runs):
  255. work.put(i)
  256. def worker():
  257. while True:
  258. try:
  259. work.get_nowait()
  260. except queue.Empty:
  261. return
  262. started = time.time()
  263. try:
  264. tokens = run_once(base, a.model, messages, a.max_tokens)
  265. except Exception as exc:
  266. tokens = [f"<error {exc!r}>"]
  267. with lock:
  268. runs.append(tokens)
  269. print(f" {len(runs):>3}/{a.runs} {time.time() - started:5.1f}s "
  270. f"{''.join(tokens)[:80]!r}", flush=True)
  271. threads = [threading.Thread(target=worker) for _ in range(a.concurrency)]
  272. for t in threads:
  273. t.start()
  274. for t in threads:
  275. t.join()
  276. texts = ["".join(t) for t in runs]
  277. groups = collections.Counter(texts)
  278. majority = groups.most_common(1)[0][0]
  279. reference = runs[texts.index(majority)]
  280. divergent = sum(n for text, n in groups.items() if text != majority)
  281. print(f"\n{divergent} of {a.runs} runs diverge from the majority "
  282. f"({len(groups)} distinct completions)")
  283. print(f"\nmajority ({groups[majority]} runs):\n {majority!r}")
  284. for text, n in groups.most_common()[1:]:
  285. tokens = runs[texts.index(text)]
  286. pos = next((i for i in range(min(len(tokens), len(reference)))
  287. if tokens[i] != reference[i]), min(len(tokens), len(reference)))
  288. got = tokens[pos] if pos < len(tokens) else "<end>"
  289. want = reference[pos] if pos < len(reference) else "<end>"
  290. print(f"\n{n} run(s) diverge at token {pos}: got {got!r}, expected {want!r}"
  291. f"\n {text!r}")
  292. raise SystemExit(0 if divergent == 0 else 1)
  293. if __name__ == "__main__":
  294. main()