- 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)
105 lines
3.2 KiB
Python
105 lines
3.2 KiB
Python
"""进程内 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
|