"""进程内 Mock LLM:脚本化回复 + 录制模式(CI 无 key 无网络跑通 agent loop 的底座)。 脚本文件格式(JSON 列表,每项一个 AssistantTurn):: [ {"text": "", "tool_calls": [{"id": "call_1", "name": "get_frame", "arguments": {"time": {"num": 1, "den": 1}}}]}, {"text": "完成", "tool_calls": []} ] """ from __future__ import annotations import json from pathlib import Path from typing import Any from quercus_core.providers.base import AssistantTurn, LLMProvider, Message, ToolCall, ToolSchema def _turn_to_dict(turn: AssistantTurn) -> dict[str, Any]: return { "text": turn.text, "tool_calls": [ {"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in turn.tool_calls ], } def _turn_from_dict(data: dict[str, Any]) -> AssistantTurn: calls = tuple( ToolCall(id=item["id"], name=item["name"], arguments=item.get("arguments") or {}) for item in data.get("tool_calls") or [] ) return AssistantTurn(text=data.get("text", ""), tool_calls=calls) class MockProvider(LLMProvider): """按预置脚本依次返回 AssistantTurn;记录每次收到的消息供断言。""" name = "mock" def __init__( self, script: list[AssistantTurn] | None = None, script_file: Path | str | None = None, ) -> None: self._script = list(script) if script is not None else [] if script_file is not None: self._script = self.load_script(script_file) self._index = 0 self.observed: list[list[Message]] = [] # 每次 generate 收到的消息历史 def generate( self, messages: list[Message], tools: list[ToolSchema] | None = None, ) -> AssistantTurn: self.observed.append(list(messages)) if self._index >= len(self._script): return AssistantTurn(text="(mock: 脚本已耗尽,结束本轮)") turn = self._script[self._index] self._index += 1 return turn @property def exhausted(self) -> bool: return self._index >= len(self._script) # ---- 脚本文件 ---- @staticmethod def dump_script(turns: list[AssistantTurn], path: Path | str) -> Path: path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps([_turn_to_dict(t) for t in turns], ensure_ascii=False, indent=2), encoding="utf-8", ) return path @staticmethod def load_script(path: Path | str) -> list[AssistantTurn]: data = json.loads(Path(path).read_text(encoding="utf-8")) return [_turn_from_dict(item) for item in data] class RecordingProvider(LLMProvider): """录制模式:包装真实(或 mock)provider,记录其响应序列。""" name = "recording" def __init__(self, inner: LLMProvider) -> None: self._inner = inner self.recorded: list[AssistantTurn] = [] def generate( self, messages: list[Message], tools: list[ToolSchema] | None = None, ) -> AssistantTurn: turn = self._inner.generate(messages, tools=tools) self.recorded.append(turn) return turn