Files
quercus/tests/mock_llm/provider.py
T
Mike-Solar e0db5ab2cf P1: host-agnostic quercus-core with agent loop, providers, and tool schemas
- Core types: Rational time, EntityId, ActionBatch, PngBytes (no Pillow dep)
- Config: ~/.quercus/config.toml with 0600 perms, env var override for CI
- LLMProvider: Claude (anthropic SDK) and OpenAI-compatible endpoint
  (covers custom gateways and llama.cpp server mode); graceful degradation
  without API keys
- Tool schemas: 26 curated tools with JSON Schema and read_only flags
- AgentLoop: sync dialogue loop with confirmation gate (read-only batches
  bypass, rejected mutations are reported back to the LLM), PNG frame
  feedback for multimodal providers
- Session log: JSONL recording with frame sha256 hashes, replayable
- HostAdapter ABC (signatures only, aligned with OPP/1 method families)
- Mock LLM provider (scripted/replay/record) for network-free CI

Tests: 69 passed via uv run pytest (no network access)
2026-08-25 02:06:10 +08:00

105 lines
3.2 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.
"""进程内 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