feat: 重新打开 FC 开关,AI 可在纯 JSON 与 FC 之间自选(混合模式)

This commit is contained in:
shuiyu67 committed 2026-08-06 23:30:53 +08:00
1 parent a70e4b1819
commit 46376aa23e
3 files changed
+189 -11

No files matched your search

+29 -4
View File
@@ -203,12 +203,21 @@ Ollama AI引擎插件帮助信息
"options": {"num_ctx": self.num_ctx}
}
# ── Function Calling: 注入工具定义 ──
if tools:
# ── Function Calling: 注入工具定义(仅引擎支持 FC 时下发)──
fc_enabled = bool(tools) and self._fc_supported()
if fc_enabled:
kwargs["tools"] = tools
# 使用Ollama Python库调用API
response = ollama_client.chat(**kwargs)
# 使用Ollama Python库调用API(FC 不支持时降级为无工具重试一次)
try:
response = ollama_client.chat(**kwargs)
except Exception as _fc_err:
if fc_enabled and "tool" in str(_fc_err).lower():
self._mark_fc_unsupported()
kwargs.pop("tools", None)
response = ollama_client.chat(**kwargs)
else:
raise
# ── Function Calling: 检查 tool_calls ──
message = response.get("message", {})
@@ -266,6 +275,22 @@ Ollama AI引擎插件帮助信息
except Exception as e:
return f"Ollama API调用失败: {str(e)}"
def _fc_supported(self):
"""该 (base_url, model) 是否支持 FC。未探测过时默认 True(乐观,先试一次)"""
try:
from xcli_core.fc_tools import supports_fc
return supports_fc(self.base_url, self.model)
except Exception:
return False
def _mark_fc_unsupported(self):
"""标记该 (base_url, model) 不支持 FC 并持久化"""
try:
from xcli_core.fc_tools import mark_fc_unsupported
mark_fc_unsupported(self.base_url, self.model)
except Exception:
pass
def _build_messages_with_history(self, system_prompt, user_input):
"""构建包含历史记录的消息列表(支持 FC tool 消息)"""
messages = []
+121
View File
@@ -0,0 +1,121 @@
"""
FC 混合模式(AI 自选)集成测试
验证「让 AI 自己选纯 JSON 或 FC」的接线:
1. 支持 FC 的引擎会收到下发的 tools schema(tool_choice=auto,模型自选)
2. 引擎返回 _fc JSON 时,code_editor 被按名分发执行(走 _handle_fc_response 路径)
3. 不支持 FC 的引擎(无 _fc_supported)则不收 tools,纯 JSON 照常工作
"""
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)
class FakeFcEngine:
"""模拟一个支持 FC 的引擎:首轮返回 _fc 调用 code_editor,次轮收尾。"""
def __init__(self):
self.calls = 0
self.tools_seen = []
@property
def name(self):
return "fakefc"
def _fc_supported(self):
return True
def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None):
self.calls += 1
self.tools_seen.append(tools)
if self.calls == 1:
return json.dumps({
"_fc": True,
"tool_calls": [{
"id": "c1",
"name": "code_editor",
"tool": "code_editor",
"args": "create %s print(1)" % self._fp,
}],
}, ensure_ascii=False)
return "已完成。"
class FakeNoFcEngine:
"""模拟一个不支持 FC 的引擎(无 _fc_supported):应降级为纯 JSON。"""
def __init__(self):
self.calls = 0
self.tools_seen = []
@property
def name(self):
return "fakenofc"
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"不支持 FC 的引擎不应收到 tools,实际 {tools!r}"
if self.calls == 1:
return ('好的,我来创建文件。\n'
'{"action": "use_tool", "tool": "code_editor", '
'"args": "create %s print(1)"}' % self._fp)
return "已完成。"
def _run(engine):
from xcli_core.cli_core import AICLI
from xcli_core.safety import get_safety, MODE_UNRESTRICTED
cli = AICLI()
cli.current_engine = engine
cli.tui_output_callback = lambda *a, **k: None # 同步 TUI 分支,免动画线程
get_safety().set_mode(MODE_UNRESTRICTED)
d = tempfile.mkdtemp(prefix="pj_")
fp = os.path.join(d, "demo.py")
engine._fp = fp
cli.process_conversation("帮我创建一个 python 文件")
return engine, fp
def test_fc_engine_receives_tools_and_dispatches():
engine, fp = _run(FakeFcEngine())
# 首轮应向支持 FC 的引擎下发 tools schema
assert engine.tools_seen[0] is not None, "FC 引擎应收到 tools schema"
assert isinstance(engine.tools_seen[0], list) and len(engine.tools_seen[0]) > 0
assert any(
t.get("function", {}).get("name") == "code_editor" for t in engine.tools_seen[0]
), "code_editor 应在下发的 FC 工具集中"
# _fc 调用应被按名分发执行并真实创建文件
assert os.path.exists(fp), "FC 路径应执行 code_editor 并创建文件"
assert "print(1)" in open(fp, encoding="utf-8").read()
assert engine.calls >= 2
def test_no_fc_engine_stays_pure_json():
engine, fp = _run(FakeNoFcEngine())
# 不支持 FC 的引擎不应收到 tools,纯 JSON 照常分发
assert all(t is None for t in engine.tools_seen), "不支持 FC 的引擎不应收到 tools"
assert os.path.exists(fp), "纯 JSON 路径仍应创建文件"
def test_build_fc_tools_returns_none_for_unsupported_engine():
from xcli_core.cli_core import AICLI
cli = AICLI()
cli.current_engine = FakeNoFcEngine()
assert cli._build_fc_tools("帮我创建文件") is None
def test_build_fc_tools_returns_list_for_supported_engine():
from xcli_core.cli_core import AICLI
cli = AICLI()
cli.current_engine = FakeFcEngine()
tools = cli._build_fc_tools("帮我创建 python 文件")
assert isinstance(tools, list) and len(tools) > 0
assert any(t.get("function", {}).get("name") == "code_editor" for t in tools)
+39 -7
View File
@@ -907,6 +907,38 @@ multi 操作支持一次修改多处:
return f"未找到工具: {tool_name}"
def _build_fc_tools(self, current_input):
"""构建本轮下发给引擎的 FC 工具子集,让引擎自行决定用 FC 结构化调用还是回退纯 JSON。
引擎不支持 FC(探测缓存未通过)时返回 None —— 引擎自动降级为纯 JSON 模式。
仅对带 `_fc_supported()` 的引擎下发 tools(Manual 等不支持的引擎不传 tools,
避免关键字参数错误)。
"""
engine = self.current_engine
if not engine or not hasattr(engine, '_fc_supported'):
return None
try:
if not engine._fc_supported():
return None
except Exception:
return None
try:
from .fc_tools import mcp_to_openai_tools, prune_tools
all_tools = mcp_to_openai_tools(self.liugin_manager)
if not all_tools:
return None
return prune_tools(all_tools, current_input)
except Exception:
return None
def _call_engine_with_tools(self, current_input, system_prompt, fc_tools):
"""调用当前引擎;fc_tools 非空时下发 FC 工具定义(tool_choice=auto,模型自选)。"""
if fc_tools:
return self.current_engine.generate_response(
current_input, system_prompt=system_prompt, tools=fc_tools)
return self.current_engine.generate_response(
current_input, system_prompt=system_prompt)
def _generate_response_with_animation(self, current_input, liugin_prompts=None):
"""生成AI响应并显示等待动画 — 支持 ESC 跨平台取消"""
# TUI 模式:不做动画
@@ -914,9 +946,10 @@ multi 操作支持一次修改多处:
system_prompt = self._build_system_prompt(liugin_prompts)
if not self.current_engine:
return "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件"
# 纯 JSON 模式:不发送 FC tools schema,模型按 action JSON 调用,
# 由 _parse_mixed_response 解析、按名分发(任意工具,无 schema 门槛)。
response = self.current_engine.generate_response(current_input, system_prompt=system_prompt, tools=None)
# 混合模式:下发 FC 工具定义,tool_choice=auto —— 模型可自行选择
# 用 FC 结构化调用,或回退到纯 JSON action(两种都会被正确处理)。
fc_tools = self._build_fc_tools(current_input)
response = self._call_engine_with_tools(current_input, system_prompt, fc_tools)
return response
# CLI 模式:带动画和跨平台 ESC 取消
@@ -998,10 +1031,9 @@ multi 操作支持一次修改多处:
if not self.current_engine:
response_result[0] = "错误: 当前没有可用的AI引擎,请检查ai_engines目录中的引擎插件"
else:
# 纯 JSON 模式:不发送 FC tools schema,模型按 action JSON 调用。
response_result[0] = self.current_engine.generate_response(
current_input, system_prompt=system_prompt, tools=None
)
# 混合模式:下发 FC 工具(tool_choice=auto),模型自选 FC 或纯 JSON
fc_tools = self._build_fc_tools(current_input)
response_result[0] = self._call_engine_with_tools(current_input, system_prompt, fc_tools)
except Exception as e:
response_result[0] = f"AI 响应生成失败: {e}"
finally: