zcbot/tests/test_skill_model_pinning.py

170 lines
5.7 KiB
Python

"""skill 定向模型(frontmatter `model:`)测试。
覆盖两层:
1. Skill.from_dir 解析 frontmatter 可选 `model` 字段(无 → "";有 → strip 后原样)。
2. AgentLoop._execute_tool_call 的热切挂点:load_skill 成功且 switcher 返回新
(profile, caps, llm) → self.caps/self.llm 替换 + 结果尾追加说明 + emit model_switch;
load_skill 报错 / switcher 抛异常 → 不切换、原模型继续。
switcher 本体(内置 skill 判定 + 档位豁免 + tasks 持久化)在 agent_builder 闭包里,
依赖 DB,不在此测;loop 侧只关心"给了切换指令就正确执行、失败不破坏 run"
"""
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from core.loop import AgentLoop # noqa: E402
from core.skills import Skill # noqa: E402
def _write_skill(root: Path, name: str, frontmatter_extra: str = "") -> Path:
d = root / name
d.mkdir()
(d / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: 测试用\n{frontmatter_extra}---\n\n# body\n",
encoding="utf-8",
)
return d
class TestSkillModelField(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.TemporaryDirectory()
self.root = Path(self.tmpdir.name)
def tearDown(self):
self.tmpdir.cleanup()
def test_no_model_field_defaults_empty(self):
sk = Skill.from_dir(_write_skill(self.root, "plain"))
self.assertEqual(sk.model, "")
def test_model_field_parsed_and_stripped(self):
sk = Skill.from_dir(_write_skill(self.root, "pinned", "model: glm.pro52 \n"))
self.assertEqual(sk.model, "glm.pro52")
class _FakeSession:
task_id = "00000000-0000-0000-0000-000000000000"
messages: list = []
def append(self, msg):
return 1
class _FakeExecutor:
"""load_skill 返回可配置文本;其余不需要。"""
def __init__(self, result_text: str):
self._result = result_text
def schemas(self):
return []
def call_tool(self, name, args, ctx):
return SimpleNamespace(content=self._result)
def _fake_caps(family="deepseek_v4", variant="flash"):
return SimpleNamespace(
family=family, variant=variant, max_iterations=10,
default_reasoning_effort="", reliable_context=64000,
)
def _make_loop(result_text: str, switcher) -> AgentLoop:
loop = AgentLoop(
llm=SimpleNamespace(tag="old_llm"),
executor=_FakeExecutor(result_text),
session=_FakeSession(),
capabilities=_fake_caps(),
user_id="00000000-0000-0000-0000-000000000000",
working_dir=Path("."),
skill_model_switch=switcher,
)
loop.events = []
loop.sink = SimpleNamespace(emit=lambda ev: loop.events.append(ev))
return loop
def _load_skill_tc(skill_name="ppt"):
return SimpleNamespace(
id="tc1",
function=SimpleNamespace(name="load_skill", arguments=f'{{"name": "{skill_name}"}}'),
)
class TestLoopHotSwap(unittest.TestCase):
def test_switch_applies_new_caps_and_llm(self):
new_caps = _fake_caps("glm", "pro52")
new_llm = SimpleNamespace(tag="new_llm")
calls = []
def switcher(skill_name, cur_profile):
calls.append((skill_name, cur_profile))
return "glm.pro52", new_caps, new_llm
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", switcher)
result, _ = loop._execute_tool_call(_load_skill_tc())
self.assertEqual(calls, [("ppt", "deepseek_v4.flash")])
self.assertIs(loop.caps, new_caps)
self.assertIs(loop.llm, new_llm)
self.assertIn("[模型切换]", result)
self.assertIn("glm.pro52", result)
types = [e["type"] for e in loop.events]
self.assertIn("model_switch", types)
ms = next(e for e in loop.events if e["type"] == "model_switch")
self.assertEqual(ms["model_profile"], "glm.pro52")
self.assertEqual(ms["from"], "deepseek_v4.flash")
def test_no_switch_when_switcher_returns_none(self):
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", lambda n, c: None)
old_caps, old_llm = loop.caps, loop.llm
result, _ = loop._execute_tool_call(_load_skill_tc())
self.assertIs(loop.caps, old_caps)
self.assertIs(loop.llm, old_llm)
self.assertNotIn("[模型切换]", result)
self.assertNotIn("model_switch", [e["type"] for e in loop.events])
def test_error_result_skips_switcher(self):
calls = []
loop = _make_loop(
"[Error] skill 'nope' not found.",
lambda n, c: calls.append(n),
)
loop._execute_tool_call(_load_skill_tc("nope"))
self.assertEqual(calls, [])
def test_switcher_exception_warns_and_keeps_model(self):
def switcher(n, c):
raise FileNotFoundError("glm.yaml missing")
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", switcher)
old_caps, old_llm = loop.caps, loop.llm
result, _ = loop._execute_tool_call(_load_skill_tc())
self.assertIs(loop.caps, old_caps)
self.assertIs(loop.llm, old_llm)
self.assertNotIn("[模型切换]", result)
warns = [e for e in loop.events if e["type"] == "warn"]
self.assertTrue(any("切换失败" in e["msg"] for e in warns))
def test_other_tools_never_trigger_switcher(self):
calls = []
loop = _make_loop("file content", lambda n, c: calls.append(n))
tc = SimpleNamespace(
id="tc2", function=SimpleNamespace(name="read", arguments='{"path": "a.txt"}')
)
loop._execute_tool_call(tc)
self.assertEqual(calls, [])
if __name__ == "__main__":
unittest.main()