- 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)
196 lines
6.5 KiB
Python
196 lines
6.5 KiB
Python
"""Agent loop:两轮对话、确认门、只读直通、视觉闭环回喂、会话日志。"""
|
|
from quercus_core.agent.loop import AgentLoop
|
|
from quercus_core.providers.base import AssistantTurn, ToolCall
|
|
from quercus_core.session.log import SessionLog, replay
|
|
from quercus_core.types import PngBytes, ToolResult
|
|
|
|
from mock_llm import MockProvider
|
|
|
|
PNG = PngBytes(b"\x89PNG\r\n\x1a\n" + bytes(range(64)))
|
|
|
|
|
|
def _sha256(data: bytes) -> str:
|
|
import hashlib
|
|
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
class StubExecutor:
|
|
"""内存 stub 工具执行器(P1 无真实宿主实现)。"""
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def execute(self, action):
|
|
self.calls.append(action)
|
|
if action.tool == "get_frame":
|
|
return ToolResult(tool="get_frame", ok=True, summary="帧:00:00:02", images=(PNG,))
|
|
if action.tool == "place_clip":
|
|
return ToolResult(tool="place_clip", ok=True, summary="已放置片段 c1 到轨道 1")
|
|
if action.tool == "ripple_delete":
|
|
return ToolResult(tool="ripple_delete", ok=True, summary="已波纹删除 1 个片段")
|
|
return ToolResult(tool=action.tool, ok=True, summary=f"{action.tool} ok")
|
|
|
|
|
|
def _frame_call(i: int) -> ToolCall:
|
|
return ToolCall(
|
|
id=f"call_frame_{i}",
|
|
name="get_frame",
|
|
arguments={"time": {"num": i, "den": 1}, "max_size": {"width": 32, "height": 18}},
|
|
)
|
|
|
|
|
|
def test_two_round_loop_confirm_auto_approve():
|
|
"""第一轮含变更工具(触发确认门) + get_frame(PNG 回喂);第二轮纯文本结束。"""
|
|
mock = MockProvider(
|
|
script=[
|
|
AssistantTurn(
|
|
text="",
|
|
tool_calls=(
|
|
ToolCall(
|
|
id="call_place",
|
|
name="place_clip",
|
|
arguments={"clip_id": "c1", "track_index": 1, "time": {"num": 0, "den": 1}},
|
|
),
|
|
_frame_call(2),
|
|
),
|
|
),
|
|
AssistantTurn(text="剪辑完成", tool_calls=()),
|
|
]
|
|
)
|
|
stub = StubExecutor()
|
|
confirmed = []
|
|
|
|
def confirm(batch):
|
|
confirmed.append(batch)
|
|
return True
|
|
|
|
loop = AgentLoop(provider=mock, executor=stub, confirm=confirm)
|
|
result = loop.run("铺一个片段并看一眼画面")
|
|
|
|
assert result.final_text == "剪辑完成"
|
|
assert result.turns == 2
|
|
assert result.tool_calls_executed == 2
|
|
assert [a.tool for a in stub.calls] == ["place_clip", "get_frame"]
|
|
assert len(confirmed) == 1
|
|
assert len(confirmed[0].actions) == 2
|
|
|
|
# 视觉闭环:get_frame 的 PNG 作为 tool 消息回喂 provider
|
|
last_messages = mock.observed[-1]
|
|
tool_msgs = [m for m in last_messages if m.role == "tool"]
|
|
assert len(tool_msgs) == 2
|
|
frame_msg = next(m for m in tool_msgs if m.name == "get_frame")
|
|
assert PNG in frame_msg.images
|
|
|
|
|
|
def test_rejected_batch_mutation_not_executed():
|
|
mock = MockProvider(
|
|
script=[
|
|
AssistantTurn(
|
|
text="",
|
|
tool_calls=(
|
|
ToolCall(id="c1", name="ripple_delete", arguments={"clip_ids": ["x1"]}),
|
|
),
|
|
),
|
|
AssistantTurn(text="已取消删除", tool_calls=()),
|
|
]
|
|
)
|
|
stub = StubExecutor()
|
|
loop = AgentLoop(provider=mock, executor=stub, confirm=lambda batch: False)
|
|
result = loop.run("删掉 x1")
|
|
|
|
assert stub.calls == [] # 变更工具未执行
|
|
assert result.final_text == "已取消删除"
|
|
assert loop.batch_decisions[0][1] is False
|
|
# 拒绝结果回喂 LLM
|
|
last_messages = mock.observed[-1]
|
|
tool_msgs = [m for m in last_messages if m.role == "tool"]
|
|
assert len(tool_msgs) == 1
|
|
assert "未执行" in tool_msgs[0].text
|
|
|
|
|
|
def test_readonly_batch_bypasses_confirm():
|
|
mock = MockProvider(
|
|
script=[
|
|
AssistantTurn(text="", tool_calls=(_frame_call(1),)),
|
|
AssistantTurn(text="帧已取到", tool_calls=()),
|
|
]
|
|
)
|
|
stub = StubExecutor()
|
|
confirm_calls = []
|
|
|
|
def confirm(batch):
|
|
confirm_calls.append(batch)
|
|
return True
|
|
|
|
loop = AgentLoop(provider=mock, executor=stub, confirm=confirm)
|
|
result = loop.run("取一帧")
|
|
|
|
assert confirm_calls == [] # 纯只读批次不触发确认门
|
|
assert [a.tool for a in stub.calls] == ["get_frame"]
|
|
assert result.final_text == "帧已取到"
|
|
|
|
|
|
def test_default_confirm_is_auto_approve():
|
|
"""未注入 confirm 回调时默认全开(计划文档 P1.3)。"""
|
|
mock = MockProvider(
|
|
script=[
|
|
AssistantTurn(
|
|
text="",
|
|
tool_calls=(ToolCall(id="c1", name="add_marker", arguments={"time": {"num": 1, "den": 1}}),),
|
|
),
|
|
AssistantTurn(text="已打点", tool_calls=()),
|
|
]
|
|
)
|
|
stub = StubExecutor()
|
|
loop = AgentLoop(provider=mock, executor=stub)
|
|
result = loop.run("打点")
|
|
assert [a.tool for a in stub.calls] == ["add_marker"]
|
|
assert result.final_text == "已打点"
|
|
|
|
|
|
def test_max_turns_bound():
|
|
mock = MockProvider(
|
|
script=[AssistantTurn(text="", tool_calls=(_frame_call(i),)) for i in range(3)]
|
|
)
|
|
stub = StubExecutor()
|
|
loop = AgentLoop(provider=mock, executor=stub, max_turns=2)
|
|
result = loop.run("一直取帧")
|
|
assert result.turns == 2
|
|
assert len(stub.calls) == 2
|
|
|
|
|
|
def test_unknown_tool_reported_back():
|
|
mock = MockProvider(
|
|
script=[
|
|
AssistantTurn(text="", tool_calls=(ToolCall(id="c1", name="no_such_tool", arguments={}),)),
|
|
AssistantTurn(text="done", tool_calls=()),
|
|
]
|
|
)
|
|
stub = StubExecutor()
|
|
loop = AgentLoop(provider=mock, executor=stub)
|
|
loop.run("调用未知工具")
|
|
assert stub.calls == []
|
|
last = mock.observed[-1][-1]
|
|
assert "未知工具" in last.text
|
|
|
|
|
|
def test_loop_writes_session_log(tmp_path):
|
|
mock = MockProvider(
|
|
script=[
|
|
AssistantTurn(text="", tool_calls=(_frame_call(3),)),
|
|
AssistantTurn(text="结束", tool_calls=()),
|
|
]
|
|
)
|
|
stub = StubExecutor()
|
|
log = SessionLog(tmp_path / "sessions" / "s1.jsonl", session_id="sess-1")
|
|
loop = AgentLoop(provider=mock, executor=stub, session=log)
|
|
loop.run("取帧并结束")
|
|
|
|
events = replay(log.path)
|
|
kinds = [e.kind for e in events]
|
|
assert kinds == ["user_message", "assistant_turn", "batch_decision", "tool_result", "assistant_turn"]
|
|
# 帧哈希落盘(不落图片本体)
|
|
result_event = next(e for e in events if e.kind == "tool_result")
|
|
assert result_event.payload["image_hashes"] == [_sha256(PNG)]
|