zcbot/core/salvage.py

67 lines
3.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""从损坏的 tool_call arguments 里抢救完好 JSON 的纯逻辑。
单独成模块(不 import litellm)是刻意的:core/loop.py 顶层 import litellm 在部分
开发机导入期会拉 GitHub cost map 卡死(见 memory),使 import core.loop 无法跑;而
本机制的正确性盲区(线上真垃圾前缀本机复现不出)恰恰只能靠单测兜底。把纯函数放这里,
tests 可无 litellm 依赖直接 import 校验守卫逻辑。
定层结论(见 project_deepseek_malformed_toolcall_salvage / DESIGN):畸形 arguments 是
provider 在 wire 上把别处正文字节错标成本 tool_call 的 arguments delta,粘在**开头**
(char-0 垃圾前缀),尾部真 JSON 完好并跑到串尾。
"""
from __future__ import annotations
import json
from typing import Optional
def salvage_tool_arguments(raw: str, allowed_keys: set) -> Optional[dict]:
"""从损坏的 arguments 里抢救出完好的 JSON 对象;抢不出返回 None。
左→右扫每个 `{`,试 `json.loads(raw[i:])`——第一个满足三条的即采纳:
(a) parse-to-end 成功(json.loads 要求整个后缀是合法 JSON,尾部有残渣就失败);
(b) 结果是非空 dict;
(c) 顶层 key ⊆ 该工具 schema 的参数名。
双护栏缺一不可:(a) 挡尾部被截断的半个 JSON唯一例外是 wire 已给出至少两个
完全一致的完整参数对象、随后又粘了截断副本,此时一致副本可作为相互校验;
(c) 挡垃圾前缀里恰好自洽的旁支 JSON(如模型正文里引用的 `{...}` 片段)。
都不满足返回 None。纯函数、无副作用,便于单测(线上真前缀样本当夹具)。
"""
# 只可能出现在 '{' 处;非 '{' 直接跳过,省下大部分 json.loads 尝试。
start = 0
decoder = json.JSONDecoder()
repeated_candidates: list[dict] = []
while True:
i = raw.find("{", start)
if i < 0:
break
try:
obj = json.loads(raw[i:])
except (json.JSONDecodeError, ValueError):
# 某些 wire 抖动会形成 ``{完整参数}{完整参数}{截断副本``。
# parse-to-end 无法处理,但 raw_decode 仍能取出前面的完整对象。
# 这里只收集候选,最终必须至少两个且完全一致才可执行。
try:
partial_obj, _end = decoder.raw_decode(raw, i)
except (json.JSONDecodeError, ValueError):
start = i + 1
continue
if (
isinstance(partial_obj, dict)
and partial_obj
and set(partial_obj.keys()) <= allowed_keys
):
repeated_candidates.append(partial_obj)
start = i + 1
continue
if isinstance(obj, dict) and obj and set(obj.keys()) <= allowed_keys:
return obj
# 解析成功但不是想要的 dict(如前缀里一段自洽 JSON):继续往后找,真 JSON 可能在更后面。
start = i + 1
if (
len(repeated_candidates) >= 2
and all(obj == repeated_candidates[0] for obj in repeated_candidates[1:])
):
return repeated_candidates[0]
return None