From c87a07706ec79724706f6c4e1e253918d35e2c58 Mon Sep 17 00:00:00 2001 From: shuiyu67 Date: Thu, 6 Aug 2026 20:43:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BA=9F=E5=BC=83=20FC=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E7=BA=AF=20JSON=20action=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E8=B0=83=E7=94=A8=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine.generate_response 不再下发 tools schema(TUI/CLI 分支均 tools=None) - 系统提示改为纯 JSON 调用格式说明,保留 tool_search 自查用法 - 新增 _loads_json 容错解析,容忍模型写出的 Windows 反斜杠路径(如 C:/Users) - 新增 tests/test_pure_json.py 覆盖解析容错/引擎无 schema 下发/端到端按名分发 Co-Authored-By: WorkBuddy --- tests/test_pure_json.py | 145 ++++++++++++++++++++++++++++++++++++++++ xcli_core/cli_core.py | 67 ++++++++++--------- 2 files changed, 179 insertions(+), 33 deletions(-) create mode 100644 tests/test_pure_json.py diff --git a/tests/test_pure_json.py b/tests/test_pure_json.py new file mode 100644 index 0000000..5635646 --- /dev/null +++ b/tests/test_pure_json.py @@ -0,0 +1,145 @@ +""" +纯 JSON 工具调用模式 测试 + +取代 FC:引擎不再收到 tools schema,模型按系统提示里的 +{"action":"use_tool","tool":名,"args":参数} 文本输出,由 _parse_mixed_response +解析、按名分发。重点验证: +1. _loads_json / _parse_mixed_response 对 Windows 反斜杠路径健壮 +2. 引擎在 tools=None 时返回纯文本(不产 _fc / tool_calls) +3. 端到端:真实 AICLI + FakeEngine,use_tool 被按名分发执行,且无 FC schema 下发 +""" +import os +import sys +import json +import tempfile + +import pytest + +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + + +# ════════════════════════════════════════════════ +# 1. 解析器:Windows 反斜杠路径容错 +# ════════════════════════════════════════════════ + +@pytest.fixture +def cli(): + from xcli_core.cli_core import AICLI + return AICLI() + + +class TestParseRobustness: + def test_loads_json_tolerates_backslash_path(self, cli): + # 模型常把 Windows 路径写成单反斜杠 → 非法 JSON 转义 + raw = r'{"action": "use_tool", "tool": "code_editor", "args": "create C:\Users\me\demo.py print(1)"}' + data = cli._loads_json(raw) + assert data["action"] == "use_tool" + assert data["tool"] == "code_editor" + # 修复后应是合法的单反斜杠路径 + assert data["args"] == r"create C:\Users\me\demo.py print(1)" + + def test_parse_mixed_extracts_use_tool_with_backslash(self, cli): + resp = ( + '好的,我来创建文件。\n' + r'{"action": "use_tool", "tool": "code_editor", "args": "create C:\Users\me\demo.py print(1)"}' + ) + text, data = cli._parse_mixed_response(resp) + assert isinstance(data, dict) + assert data["action"] == "use_tool" + assert data["tool"] == "code_editor" + assert r"C:\Users\me\demo.py" in data["args"] + + def test_parse_mixed_plain_text_no_action(self, cli): + text, data = cli._parse_mixed_response("今天天气不错") + assert data is None + assert "今天天气不错" in text + + +# ════════════════════════════════════════════════ +# 2. 引擎:tools=None 返回纯文本,不产 tool_calls +# ════════════════════════════════════════════════ + +class TestEngineToolsNone: + def test_openai_engine_no_tools_sent(self, monkeypatch): + import types + import ai_engines.openai_engine as oe + + calls = [] + + def fake_post(url=None, json=None, headers=None, timeout=None, **kw): + calls.append(dict(json)) + return types.SimpleNamespace( + status_code=200, + json=lambda: {"choices": [{"message": {"content": "好"}}], + "usage": {"prompt_tokens": 5}}, + text="{}", + ) + + monkeypatch.setattr(oe, "requests", + types.SimpleNamespace(post=fake_post, exceptions=oe.requests.exceptions)) + + eng = oe.OpenaiAI.__new__(oe.OpenaiAI) + eng.name = "openai" + eng.api_key = "k" + eng.base_url = "https://example.com/v1" + eng.model = "gpt-4o" + eng.max_history = 10 + eng.conversation_history = [] + eng.shared_conversation_history = None + eng.last_prompt_tokens = None + eng.thinking_start_marker = "" + eng.thinking_end_marker = "" + + reply = eng.generate_response("hi", tools=None) + assert reply == "好" + assert "tools" not in calls[0], "tools=None 时不应下发 FC schema" + assert "tool_choice" not in calls[0] + + +# ════════════════════════════════════════════════ +# 3. 端到端:真实 AICLI + FakeEngine,use_tool 按名分发 +# ════════════════════════════════════════════════ + +class FakeEngine: + def __init__(self): + self.calls = 0 + self.tools_seen = [] + + @property + def name(self): + return "fake" + + def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None): + self.calls += 1 + self.tools_seen.append(tools) + assert tools is None, f"纯 JSON 模式不应下发 FC tools,实际 {tools!r}" + if self.calls == 1: + return ('好的,我来创建文件。\n' + '{"action": "use_tool", "tool": "code_editor", ' + '"args": "create %s print(1)"}' % self._fp) + return "已完成。" + + +class TestEndToEndDispatch: + def test_use_tool_dispatched_without_fc(self): + from xcli_core.cli_core import AICLI + from xcli_core.safety import get_safety, MODE_UNRESTRICTED + + cli = AICLI() + engine = FakeEngine() + cli.current_engine = engine + cli.tui_output_callback = lambda *a, **k: None # 同步 TUI 分支,免动画线程 + get_safety().set_mode(MODE_UNRESTRICTED) # 跳过交互确认,验证分发本身 + + d = tempfile.mkdtemp(prefix="pj_") + FakeEngine._fp = os.path.join(d, "demo.py") # 原始反斜杠路径 + fp = FakeEngine._fp + + cli.process_conversation("帮我创建一个 python 文件") + + assert all(t is None for t in engine.tools_seen), "引擎不应收到任何 FC tools" + assert engine.calls >= 2, "至少应有:首轮JSON + 工具结果续轮" + assert os.path.exists(fp), "code_editor 应被按名分发执行并创建文件" + assert "print(1)" in open(fp, encoding="utf-8").read() diff --git a/xcli_core/cli_core.py b/xcli_core/cli_core.py index 120c1dc..7200b9c 100644 --- a/xcli_core/cli_core.py +++ b/xcli_core/cli_core.py @@ -42,7 +42,7 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist def process_thinking_response(self, response): """处理深度思考AI返回的内容, 分离思考和回复内容""" try: - response_data = json.loads(response) + response_data = self._loads_json(response) if isinstance(response_data, dict) and response_data.get('action') == 'use_tool': return response if isinstance(response_data, dict): @@ -310,13 +310,14 @@ multi 操作支持一次修改多处: plugins.append(tool) if plugins: - # 工具名清单(与 FC tools 字段互补;不逐条重复 description,避免双重曝光)。 - # 无 function-calling 能力的模型只能靠此清单知道有哪些工具,故保留名称。 + # 工具名清单(纯 JSON 模式:不发送 FC schema,模型按 action JSON 调用)。 + # 只列名字 + 由 tool_search 提供用法,避免重复曝光、省 token。 names = ", ".join(t.get('name', '') for t in plugins) prompts.append( - "## 内置工具(已通过 function calling 的 tools 字段提供,调用前请先用 " - "tool_search 查询其详细用法与示例)\n" - f"工具名: {names}" + "## 内置工具(纯 JSON 模式:不发送 function calling 的 tools schema," + "模型按下方 JSON 格式直接调用,调用前请先用 tool_search 查询其详细用法与示例)\n" + f"工具名: {names}\n" + '调用格式: {"action": "use_tool", "tool": "工具名", "args": "参数"}' ) if skills: @@ -356,6 +357,18 @@ multi 操作支持一次修改多处: return "\n".join(prompts) + def _loads_json(self, s): + """json.loads,但容忍未成对出现的反斜杠(Windows 路径 C:\\Users 常见)。 + + 纯 JSON 模式下模型常把路径写成单反斜杠,直接 json.loads 会报非法转义。 + 这里先按标准解析;失败时把「未成对的反斜杠」转义后再试一次。 + """ + try: + return json.loads(s) + except json.JSONDecodeError: + fixed = re.sub(r'(?