glm47_failclosed.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. """A fail-closed glm47 tool parser, registered as `glm47_failclosed`.
  2. Loaded with `--tool-parser-plugin` and selected with
  3. `--tool-call-parser glm47_failclosed`, so no upstream file is forked or mounted.
  4. ## Why
  5. The forward pass is not bit-reproducible at M >> 1 on this hardware (see
  6. ../../dev/TOPK-CORRUPTION.md), and the token it lands on is occasionally part of a tool
  7. call. Upstream handles a corrupted *name* by dropping the call: the turn ends
  8. `stop` with no content and no `tool_calls`, which clients report as a dead
  9. endpoint. It does not check argument *keys* at all, so a call carrying a garbage
  10. key is emitted as though it were fine, the client echoes it into history, the
  11. chat template re-renders it, and the model imitates its own corruption until a
  12. turn is unparseable.
  13. Containment, not a cure: nothing here stops the first bad token, it only keeps
  14. one from poisoning the rest of the session.
  15. Approach and both of its corrections come from
  16. NNNtrance/GLM-5.3-Flash-EXL3-DGX-Spark, issue #7 and PR #11.
  17. ## What it does
  18. Validates at TOOL_CALL_END, once the whole call is buffered
  19. (`stream_arg_deltas=False`): the name must be shaped like a tool name and resolve
  20. to one the request offered, the arguments must parse to an object, and every key
  21. must appear in that tool's schema.
  22. A call that fails is **re-offered with a sentinel argument key** rather than
  23. dropped or turned into text. Surfacing it as content clears the tool slot, and an
  24. agent loop reads that as a finished turn and stops -- the bug behind PR #11. A
  25. call no client can accept is a retryable error instead, so the loop continues.
  26. The sentinel's value names the specific mistake and lists the valid keys, because
  27. an unexplained rejection invites the model to repeat it.
  28. Name resolution takes an exact match first, then the longest offered name the
  29. emitted one starts with, so `bash</arg_key>...` resolves to `bash` while
  30. `bash1635` does not.
  31. `GLM47_FAIL_CLOSED=0` restores upstream behaviour. `GLM47_REJECT_AS_TEXT=1`
  32. surfaces refusals as content instead, which is the pre-#11 behaviour and ends
  33. agent loops.
  34. """
  35. from __future__ import annotations
  36. import json
  37. import os
  38. import re
  39. # Moved from vllm.entrypoints.openai.engine.protocol on vLLM main; the old
  40. # path is why this plugin failed to register on the 2026-09-22 nightly.
  41. from vllm.entrypoints.generate.base.protocol import (
  42. DeltaFunctionCall,
  43. DeltaToolCall,
  44. ExtractedToolCallInformation,
  45. FunctionCall,
  46. ToolCall,
  47. )
  48. from vllm.logger import init_logger
  49. from vllm.parser.engine.adapters import make_adapters
  50. from vllm.parser.glm47_moe import Glm47MoeParser
  51. from vllm.tool_parsers.abstract_tool_parser import ToolParserManager
  52. from vllm.tool_parsers.utils import find_tool_name, find_tool_properties
  53. logger = init_logger(__name__)
  54. # Tool and argument names as these schemas spell them: a leading letter or
  55. # underscore, then word characters, dots or dashes. Narrower than the JSON that
  56. # reaches us, which is the point.
  57. NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]{0,63}$")
  58. # No real schema declares this, so a client that validates arguments must reject
  59. # the call, and one that does not gets a message saying what happened.
  60. SENTINEL_KEY = "_rejected_by_server"
  61. def _flag(name: str, default: str) -> bool:
  62. return os.environ.get(name, default) not in ("0", "false", "False")
  63. ENABLED = _flag("GLM47_FAIL_CLOSED", "1")
  64. REJECT_AS_TEXT = _flag("GLM47_REJECT_AS_TEXT", "0")
  65. class Glm47FailClosedParser(Glm47MoeParser):
  66. """Glm47MoeParser that refuses any call it cannot fully validate."""
  67. def __init__(self, tokenizer, tools=None, **kwargs) -> None:
  68. super().__init__(tokenizer, tools, **kwargs)
  69. # An argument delta cannot be withdrawn once sent, and the decision needs
  70. # the whole argument object.
  71. self._stream_arg_deltas = False
  72. # Sentinel arguments of refused calls by call id. A non-streaming result
  73. # is rebuilt from each slot's raw arguments, so it needs these too.
  74. self._refused: dict[str, str] = {}
  75. def _reset(self, *args, **kwargs) -> None:
  76. super()._reset(*args, **kwargs)
  77. self._refused = {}
  78. def _args_json(self, idx: int) -> str:
  79. """The slot's arguments as the client would receive them.
  80. GLM writes arguments as <arg_key>/<arg_value> pairs and the slot keeps
  81. that raw text; the engine's converter turns it into JSON. Text the
  82. converter cannot handle is returned raw, and fails as "not JSON".
  83. """
  84. slot = self._tool_slots[idx]
  85. converter = self._arg_converter
  86. if converter is None:
  87. return slot.args
  88. try:
  89. text = converter(slot.args, False)
  90. except (json.JSONDecodeError, ValueError, TypeError):
  91. return slot.args
  92. return self._fix_arg_types(text, slot.name) if text and slot.name else text
  93. def _build_extracted_result(self, *deltas):
  94. result = super()._build_extracted_result(*deltas)
  95. calls = list(result.tool_calls or [])
  96. for call in calls:
  97. if call.id in self._refused:
  98. call.function.arguments = self._refused[call.id]
  99. # Upstream drops a call whose name no tool matches; re-offer it like the
  100. # streaming path does, so the turn is not left empty.
  101. seen = {call.id for call in calls}
  102. for slot in self._tool_slots:
  103. if slot.id in self._refused and slot.id not in seen:
  104. calls.append(ToolCall(id=slot.id, function=FunctionCall(name=slot.name,
  105. arguments=self._refused[slot.id])))
  106. if len(calls) != len(result.tool_calls or []):
  107. result = ExtractedToolCallInformation(tools_called=True, tool_calls=calls, content=result.content)
  108. return result
  109. def _offered_names(self) -> list[str]:
  110. names: list[str] = []
  111. for tool in self._tools or []:
  112. function = getattr(tool, "function", None)
  113. name = getattr(function, "name", None)
  114. if isinstance(name, str) and name:
  115. names.append(name)
  116. return names
  117. def _resolve_name(self, emitted: str) -> str | None:
  118. """The offered tool this name means, or None.
  119. Exact match first. Otherwise the longest offered name the emitted one
  120. starts with, which recovers a name whose tail is corrupted markup without
  121. matching a different tool that merely shares a prefix.
  122. """
  123. if find_tool_name(self._tools, emitted):
  124. return emitted
  125. candidates = [n for n in self._offered_names() if emitted.startswith(n)]
  126. if not candidates:
  127. return None
  128. best = max(candidates, key=len)
  129. tail = emitted[len(best):]
  130. if not tail:
  131. return best
  132. # What follows decides: a name character means a different tool
  133. # (`bash1635`), anything else means corruption after the real name
  134. # (`bash</arg_key>`).
  135. return None if re.match(r"[A-Za-z0-9_.-]", tail) else best
  136. def _reject_reason(self, name: str, args_json: str) -> str | None:
  137. """Why this call cannot be trusted, or None if it can."""
  138. if not NAME_RE.match(name):
  139. return f"tool name {name!r} is not shaped like a tool name"
  140. if self._tools and not find_tool_name(self._tools, name):
  141. return f"tool name {name!r} is not one this request offered"
  142. text = (args_json or "").strip()
  143. if not text:
  144. return None
  145. try:
  146. args = json.loads(text)
  147. except (json.JSONDecodeError, ValueError) as exc:
  148. return f"arguments for {name!r} are not JSON: {exc}"
  149. if not isinstance(args, dict):
  150. return f"arguments for {name!r} are {type(args).__name__}, not an object"
  151. properties = find_tool_properties(self._tools, name) or {}
  152. for key in args:
  153. if not isinstance(key, str) or not NAME_RE.match(key):
  154. return f"argument key {key!r} is not shaped like a key"
  155. # No declared properties means any key is unverifiable rather than
  156. # wrong, so it passes.
  157. if properties and key not in properties:
  158. valid = ", ".join(sorted(properties)) or "none"
  159. return (
  160. f"argument key {key!r} is not in the schema for {name!r}; "
  161. f"valid keys are: {valid}"
  162. )
  163. return None
  164. def _handle_tool_end(self, event, deltas) -> None:
  165. idx = event.tool_index
  166. if not ENABLED or not (0 <= idx < len(self._tool_slots)):
  167. super()._handle_tool_end(event, deltas)
  168. return
  169. slot = self._tool_slots[idx]
  170. emitted = (slot.name or self._try_extract_name(idx) or "").strip()
  171. resolved = self._resolve_name(emitted)
  172. # Converting here leaves the slot and the converter's state alone.
  173. reason = self._reject_reason(resolved or emitted, self._args_json(idx))
  174. if reason is None:
  175. if resolved and resolved != emitted:
  176. slot.name = resolved
  177. super()._handle_tool_end(event, deltas)
  178. return
  179. # Drain this slot's converter so its pending text cannot surface inside
  180. # the next call.
  181. self._flush_arg_converter(idx)
  182. logger.warning("glm47 fail-closed: %s", reason)
  183. if REJECT_AS_TEXT:
  184. # With no tool delta for this event the engine flushes deferred
  185. # content as ordinary content. This ends agent loops; see PR #11.
  186. self._deferred_content += f"\n[tool call refused: {reason}]\n"
  187. slot.name_sent = True
  188. slot.streamed_json = slot.args or ""
  189. return
  190. # Re-offer under the resolved name where there is one, so the client
  191. # recognises the tool and rejects it on the arguments.
  192. name = resolved or emitted if NAME_RE.match(emitted) else (resolved or "unknown_tool")
  193. slot.name = name
  194. slot.name_sent = True
  195. self._ensure_tool_id(slot, name)
  196. arguments = json.dumps({SENTINEL_KEY: reason}, ensure_ascii=False)
  197. slot.streamed_json = arguments
  198. self._refused[slot.id] = arguments
  199. deltas.append(
  200. DeltaToolCall(
  201. index=idx,
  202. id=slot.id,
  203. type="function",
  204. function=DeltaFunctionCall(name=name, arguments=arguments),
  205. )
  206. )
  207. _REASONING_ADAPTER, _TOOL_ADAPTER = make_adapters(Glm47FailClosedParser)
  208. @ToolParserManager.register_module("glm47_failclosed")
  209. class Glm47FailClosedToolParser(_TOOL_ADAPTER): # type: ignore[valid-type, misc]
  210. supports_required_and_named = False
  211. structural_tag_model = "glm_4_7"