640 lines
28 KiB
Python
640 lines
28 KiB
Python
"""Hermes provider lifecycle tests.
|
|
|
|
These tests exercise the Python provider the way the Hermes host calls it,
|
|
but with a fake JSON-RPC bridge so they stay deterministic and do not spawn
|
|
Node, Hermes, or the HTTP viewer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
|
|
_ADAPTER_ROOT = Path(__file__).resolve().parent.parent.parent / "adapters" / "hermes"
|
|
_PLUGIN_DIR = _ADAPTER_ROOT / "memos_provider"
|
|
for _p in (_ADAPTER_ROOT, _PLUGIN_DIR):
|
|
if str(_p) not in sys.path:
|
|
sys.path.insert(0, str(_p))
|
|
|
|
import memos_provider # noqa: E402
|
|
|
|
|
|
class FakeBridge:
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple[str, dict]] = []
|
|
self.closed = False
|
|
self.host_handlers: dict[str, object] = {}
|
|
|
|
def register_host_handler(self, method: str, handler: object) -> None:
|
|
self.host_handlers[method] = handler
|
|
|
|
def request(self, method: str, params: dict | None = None, **_kwargs: object) -> dict:
|
|
payload = params or {}
|
|
self.calls.append((method, payload))
|
|
if method == "session.open":
|
|
return {"sessionId": payload.get("sessionId") or "hermes:test-session"}
|
|
if method == "turn.start":
|
|
return {
|
|
"query": {
|
|
"sessionId": payload.get("sessionId") or "hermes:test-session",
|
|
"episodeId": "episode-from-turn-start",
|
|
},
|
|
"injectedContext": "remembered HERMES_MEMOS_E2E_0428",
|
|
}
|
|
if method == "turn.end":
|
|
return {"traceId": "trace-1", "episodeId": payload.get("episodeId")}
|
|
if method == "core.health":
|
|
return {"ok": True}
|
|
if method in {"episode.close", "session.close", "subagent.record"}:
|
|
return {"ok": True}
|
|
raise AssertionError(f"unexpected bridge method: {method}")
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class FailingSessionOpenBridge(FakeBridge):
|
|
def request(self, method: str, params: dict | None = None, **_kwargs: object) -> dict:
|
|
if method == "session.open":
|
|
self.closed = True
|
|
raise RuntimeError("session.open did not respond")
|
|
return super().request(method, params, **_kwargs)
|
|
|
|
|
|
class HermesProviderPipelineTests(unittest.TestCase):
|
|
def test_lifecycle_persists_turn_and_closes_real_episode(self) -> None:
|
|
bridge = FakeBridge()
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", return_value=bridge),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize(
|
|
"host-session",
|
|
hermes_home="/tmp/hermes-test-home",
|
|
platform="cli",
|
|
agent_identity="hermes-test",
|
|
)
|
|
|
|
provider.on_turn_start(1, "Remember project HERMES_MEMOS_E2E_0428")
|
|
prefetch = provider.prefetch("HERMES_MEMOS_E2E_0428")
|
|
self.assertIn("remembered HERMES_MEMOS_E2E_0428", prefetch)
|
|
self.assertEqual(provider._episode_id, "episode-from-turn-start")
|
|
|
|
provider._on_post_tool_call(
|
|
tool_name="terminal",
|
|
args={"cmd": "npm test"},
|
|
result="all green",
|
|
tool_call_id="tool-1",
|
|
)
|
|
provider.sync_turn(
|
|
"Remember project HERMES_MEMOS_E2E_0428",
|
|
"Recorded the Hermes MemOS test fact.",
|
|
)
|
|
provider.on_session_end([])
|
|
provider.shutdown()
|
|
|
|
methods = [method for method, _params in bridge.calls]
|
|
self.assertEqual(
|
|
methods,
|
|
[
|
|
"session.open",
|
|
"turn.start",
|
|
"turn.end",
|
|
"session.close",
|
|
],
|
|
)
|
|
|
|
turn_end = next(params for method, params in bridge.calls if method == "turn.end")
|
|
self.assertEqual(turn_end["agent"], "hermes")
|
|
self.assertEqual(turn_end["sessionId"], "host-session")
|
|
self.assertEqual(turn_end["episodeId"], "episode-from-turn-start")
|
|
self.assertIn("HERMES_MEMOS_E2E_0428", turn_end["userText"])
|
|
self.assertIn("Recorded", turn_end["agentText"])
|
|
self.assertEqual(turn_end["toolCalls"][0]["name"], "terminal")
|
|
self.assertIn("npm test", turn_end["toolCalls"][0]["input"])
|
|
|
|
self.assertTrue(bridge.closed)
|
|
|
|
def test_sync_turn_recovers_when_initial_bridge_open_timed_out(self) -> None:
|
|
failed_bridge = FailingSessionOpenBridge()
|
|
recovered_bridge = FakeBridge()
|
|
bridge_attempts = [failed_bridge, recovered_bridge]
|
|
|
|
def bridge_factory() -> FakeBridge:
|
|
return bridge_attempts.pop(0)
|
|
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", side_effect=bridge_factory),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize("slow-start-session")
|
|
self.assertIsNone(provider._bridge)
|
|
self.assertTrue(failed_bridge.closed)
|
|
|
|
provider.on_turn_start(1, "检查 package.json")
|
|
provider._on_post_tool_call(
|
|
tool_name="read_file",
|
|
args={"path": "package.json"},
|
|
result='{"content":"{}"}',
|
|
tool_call_id="tool-1",
|
|
)
|
|
provider.sync_turn("检查 package.json", "检查完成")
|
|
|
|
methods = [method for method, _params in recovered_bridge.calls]
|
|
self.assertEqual(methods, ["session.open", "turn.start", "turn.end"])
|
|
turn_end = next(params for method, params in recovered_bridge.calls if method == "turn.end")
|
|
self.assertEqual(turn_end["sessionId"], "slow-start-session")
|
|
self.assertEqual(turn_end["episodeId"], "episode-from-turn-start")
|
|
self.assertEqual(turn_end["toolCalls"][0]["name"], "read_file")
|
|
|
|
def test_delegation_recovers_when_initial_bridge_open_timed_out(self) -> None:
|
|
recovered_bridge = FakeBridge()
|
|
bridge_attempts = [FailingSessionOpenBridge(), recovered_bridge]
|
|
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", side_effect=lambda: bridge_attempts.pop(0)),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize("slow-parent-session")
|
|
provider.on_turn_start(1, "请派一个子代理检查 package.json")
|
|
provider.on_delegation(
|
|
"检查 package.json scripts",
|
|
"当前目录没有 package.json",
|
|
child_session_id="child-session",
|
|
)
|
|
|
|
methods = [method for method, _params in recovered_bridge.calls]
|
|
self.assertEqual(methods, ["session.open", "turn.start", "subagent.record"])
|
|
record = next(
|
|
params for method, params in recovered_bridge.calls if method == "subagent.record"
|
|
)
|
|
self.assertEqual(record["sessionId"], "slow-parent-session")
|
|
self.assertEqual(record["episodeId"], "episode-from-turn-start")
|
|
self.assertEqual(record["childSessionId"], "child-session")
|
|
|
|
def test_sync_turn_lazily_starts_turn_when_prefetch_was_skipped(self) -> None:
|
|
bridge = FakeBridge()
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", return_value=bridge),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize("host-session")
|
|
|
|
provider.on_turn_start(1, "继续处理 Hermes viewer 端口")
|
|
provider.sync_turn(
|
|
"继续处理 Hermes viewer 端口",
|
|
"已继续检查 viewer 端口配置。",
|
|
)
|
|
|
|
methods = [method for method, _params in bridge.calls]
|
|
self.assertEqual(methods, ["session.open", "turn.start", "turn.end"])
|
|
turn_end = next(params for method, params in bridge.calls if method == "turn.end")
|
|
self.assertEqual(turn_end["episodeId"], "episode-from-turn-start")
|
|
|
|
def test_internal_hermes_review_prompt_is_not_persisted_as_user_turn(self) -> None:
|
|
bridge = FakeBridge()
|
|
review_prompt = (
|
|
"Review the conversation above and consider whether a skill should be "
|
|
"saved or updated. Work in this order -- do not skip."
|
|
)
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", return_value=bridge),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize("host-session")
|
|
|
|
provider.on_turn_start(10, review_prompt)
|
|
self.assertEqual(provider.prefetch(review_prompt), "")
|
|
provider._on_post_tool_call(
|
|
tool_name="memos_search",
|
|
args={"query": "conversation"},
|
|
result="[]",
|
|
tool_call_id="tool-1",
|
|
)
|
|
provider.sync_turn(review_prompt, "Nothing to save.")
|
|
provider.on_session_end([])
|
|
|
|
methods = [method for method, _params in bridge.calls]
|
|
self.assertEqual(methods, ["session.open", "session.close"])
|
|
self.assertFalse(any(method == "turn.start" for method, _ in bridge.calls))
|
|
self.assertFalse(any(method == "turn.end" for method, _ in bridge.calls))
|
|
|
|
def test_on_pre_compress_reuses_last_user_text_for_snapshot(self) -> None:
|
|
bridge = FakeBridge()
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", return_value=bridge),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize("compress-session")
|
|
provider.on_turn_start(2, "compress HERMES_MEMOS_E2E_0428 context")
|
|
|
|
snapshot = provider.on_pre_compress([{"role": "user", "content": "x"}])
|
|
|
|
self.assertIn("MemOS memory snapshot", snapshot)
|
|
self.assertIn("remembered HERMES_MEMOS_E2E_0428", snapshot)
|
|
self.assertEqual(bridge.calls[-1][0], "turn.start")
|
|
self.assertIn("HERMES_MEMOS_E2E_0428", bridge.calls[-1][1]["userText"])
|
|
|
|
def test_prefetch_suppresses_memory_injection_for_explicit_delegation(self) -> None:
|
|
bridge = FakeBridge()
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", return_value=bridge),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize("parent-session")
|
|
provider.on_turn_start(1, "请派一个子代理检查 package.json")
|
|
|
|
prefetch = provider.prefetch("请派一个子代理检查 package.json")
|
|
|
|
self.assertEqual(prefetch, "")
|
|
self.assertEqual(provider._episode_id, "episode-from-turn-start")
|
|
self.assertEqual(bridge.calls[-1][0], "turn.start")
|
|
self.assertIn("子代理", bridge.calls[-1][1]["userText"])
|
|
|
|
def test_tool_hook_ignores_other_sessions(self) -> None:
|
|
bridge = FakeBridge()
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", return_value=bridge),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize("parent-session")
|
|
provider.on_turn_start(1, "parent task")
|
|
provider.prefetch("parent task")
|
|
|
|
provider._on_post_tool_call(
|
|
tool_name="read_file",
|
|
args={"path": "child-only.txt"},
|
|
result="child output",
|
|
tool_call_id="child-tool",
|
|
session_id="child-session",
|
|
)
|
|
provider._on_post_tool_call(
|
|
tool_name="terminal",
|
|
args={"cmd": "npm test"},
|
|
result="parent output",
|
|
tool_call_id="parent-tool",
|
|
session_id="parent-session",
|
|
)
|
|
provider.sync_turn("parent task", "parent done")
|
|
|
|
turn_end = next(params for method, params in bridge.calls if method == "turn.end")
|
|
self.assertEqual([tc["name"] for tc in turn_end["toolCalls"]], ["terminal"])
|
|
|
|
def test_on_delegation_targets_parent_episode(self) -> None:
|
|
bridge = FakeBridge()
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", return_value=bridge),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize("parent-session")
|
|
provider.on_turn_start(1, "delegate task")
|
|
provider.prefetch("delegate task")
|
|
provider.on_delegation(
|
|
"check package", "no package.json", child_session_id="child-session"
|
|
)
|
|
|
|
method, params = bridge.calls[-1]
|
|
self.assertEqual(method, "subagent.record")
|
|
self.assertEqual(params["sessionId"], "parent-session")
|
|
self.assertEqual(params["episodeId"], "episode-from-turn-start")
|
|
self.assertEqual(params["childSessionId"], "child-session")
|
|
|
|
def test_on_delegation_backfills_child_session_tool_calls(self) -> None:
|
|
bridge = FakeBridge()
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
sessions_dir = Path(tmp) / "sessions"
|
|
sessions_dir.mkdir()
|
|
(sessions_dir / "session_child-session.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"tool_calls": [
|
|
{
|
|
"id": "tool-1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "read_file",
|
|
"arguments": json.dumps(
|
|
{"path": "package.json", "limit": 20}
|
|
),
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "tool-1",
|
|
"content": json.dumps({"content": "1|{}", "total_lines": 1}),
|
|
},
|
|
]
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", return_value=bridge),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize("parent-session", hermes_home=tmp)
|
|
provider.on_turn_start(1, "delegate task")
|
|
provider.prefetch("delegate task")
|
|
provider.on_delegation(
|
|
"check package",
|
|
"package exists",
|
|
child_session_id="child-session",
|
|
)
|
|
|
|
method, params = bridge.calls[-1]
|
|
self.assertEqual(method, "subagent.record")
|
|
self.assertEqual(params["toolCalls"][0]["name"], "read_file")
|
|
self.assertEqual(params["toolCalls"][0]["input"]["path"], "package.json")
|
|
self.assertIn("total_lines", params["toolCalls"][0]["output"])
|
|
|
|
def test_post_llm_call_backfills_tool_calls_without_post_tool_hook(self) -> None:
|
|
bridge = FakeBridge()
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", return_value=bridge),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize("host-session")
|
|
provider.on_turn_start(1, "东京房产投资分析")
|
|
provider.prefetch("东京房产投资分析")
|
|
|
|
provider._on_post_llm_call(
|
|
conversation_history=[
|
|
{"role": "user", "content": "东京房产投资分析"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "好的,我来逐步完成这个分析。",
|
|
"reasoning": "先列计划,再查汇率和房源。",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_todo_1",
|
|
"call_id": "call_todo_1",
|
|
"response_item_id": "fc_todo_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "todo",
|
|
"arguments": '{"todos": [{"id": "1"}]}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
]
|
|
)
|
|
provider.sync_turn("东京房产投资分析", "好的,我来逐步完成这个分析。")
|
|
|
|
turn_end = next(params for method, params in bridge.calls if method == "turn.end")
|
|
self.assertEqual(turn_end["toolCalls"][0]["name"], "todo")
|
|
self.assertIn('"todos"', turn_end["toolCalls"][0]["input"])
|
|
self.assertEqual(turn_end["toolCalls"][0]["thinkingBefore"], "先列计划,再查汇率和房源。")
|
|
self.assertEqual(
|
|
turn_end["toolCalls"][0]["assistantTextBefore"],
|
|
"好的,我来逐步完成这个分析。",
|
|
)
|
|
|
|
def test_post_tool_call_merges_with_llm_tool_aliases(self) -> None:
|
|
bridge = FakeBridge()
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", return_value=bridge),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize("host-session")
|
|
provider.on_turn_start(1, "查汇率")
|
|
provider.prefetch("查汇率")
|
|
provider._on_post_llm_call(
|
|
conversation_history=[
|
|
{"role": "user", "content": "查汇率"},
|
|
{
|
|
"role": "assistant",
|
|
"reasoning": "用 terminal 调 API。",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_terminal_1",
|
|
"call_id": "call_terminal_1",
|
|
"response_item_id": "fc_terminal_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "terminal",
|
|
"arguments": '{"command": "curl example"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
]
|
|
)
|
|
provider._on_post_tool_call(
|
|
tool_name="terminal",
|
|
args={"command": "curl example"},
|
|
result="1 JPY = 0.006 USD",
|
|
tool_call_id="call_terminal_1",
|
|
)
|
|
provider.sync_turn("查汇率", "查到了。")
|
|
|
|
turn_end = next(params for method, params in bridge.calls if method == "turn.end")
|
|
self.assertEqual(len(turn_end["toolCalls"]), 1)
|
|
self.assertEqual(turn_end["toolCalls"][0]["name"], "terminal")
|
|
self.assertIn("0.006 USD", turn_end["toolCalls"][0]["output"])
|
|
self.assertEqual(turn_end["toolCalls"][0]["thinkingBefore"], "用 terminal 调 API。")
|
|
|
|
def test_post_llm_call_preserves_visible_text_before_tool_call(self) -> None:
|
|
bridge = FakeBridge()
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", return_value=bridge),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize("host-session")
|
|
provider.on_turn_start(1, "分析房价数据集")
|
|
provider.prefetch("分析房价数据集")
|
|
provider._on_post_llm_call(
|
|
conversation_history=[
|
|
{"role": "user", "content": "分析房价数据集"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "好的,这是经典的 Kaggle 房价预测数据集。先创建计划。",
|
|
"reasoning": "用户要元数据清单,先列 todo。",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_todo_1",
|
|
"call_id": "call_todo_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "todo",
|
|
"arguments": '{"todos": [{"id": "step0"}]}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
]
|
|
)
|
|
provider.sync_turn("分析房价数据集", "计划已创建。")
|
|
|
|
turn_end = next(params for method, params in bridge.calls if method == "turn.end")
|
|
tool = turn_end["toolCalls"][0]
|
|
self.assertEqual(tool["name"], "todo")
|
|
self.assertEqual(tool["thinkingBefore"], "用户要元数据清单,先列 todo。")
|
|
self.assertEqual(
|
|
tool["assistantTextBefore"],
|
|
"好的,这是经典的 Kaggle 房价预测数据集。先创建计划。",
|
|
)
|
|
|
|
def test_transform_tool_result_appends_memos_search_hint_after_three_failures(self) -> None:
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.on_turn_start(1, "run failing command")
|
|
|
|
self.assertIsNone(
|
|
provider._on_transform_tool_result(
|
|
tool_name="terminal",
|
|
result="boom",
|
|
is_error=True,
|
|
)
|
|
)
|
|
self.assertIsNone(
|
|
provider._on_transform_tool_result(
|
|
tool_name="terminal",
|
|
result="boom again",
|
|
is_error=True,
|
|
)
|
|
)
|
|
third = provider._on_transform_tool_result(
|
|
tool_name="terminal",
|
|
result="boom third",
|
|
is_error=True,
|
|
)
|
|
self.assertIsNotNone(third)
|
|
self.assertIn("failed multiple times in a row", third or "")
|
|
self.assertIn("memos_search", third or "")
|
|
|
|
provider._on_transform_tool_result(
|
|
tool_name="terminal",
|
|
result="ok",
|
|
is_error=False,
|
|
)
|
|
self.assertIsNone(
|
|
provider._on_transform_tool_result(
|
|
tool_name="terminal",
|
|
result="boom after reset",
|
|
is_error=True,
|
|
)
|
|
)
|
|
|
|
def test_transform_tool_result_detects_plain_error_text(self) -> None:
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.on_turn_start(1, "run failing command")
|
|
|
|
self.assertIsNone(
|
|
provider._on_transform_tool_result(
|
|
tool_name="terminal",
|
|
result="Error: command failed",
|
|
)
|
|
)
|
|
self.assertIsNone(
|
|
provider._on_transform_tool_result(
|
|
tool_name="terminal",
|
|
result="Error: command failed again",
|
|
)
|
|
)
|
|
third = provider._on_transform_tool_result(
|
|
tool_name="terminal",
|
|
result="Error: command failed third time",
|
|
)
|
|
self.assertIsNotNone(third)
|
|
self.assertIn("memos_search", third or "")
|
|
|
|
def test_post_llm_call_orders_backfilled_tools_before_later_tool_results(self) -> None:
|
|
bridge = FakeBridge()
|
|
with (
|
|
patch("memos_provider.ensure_bridge_running", return_value=True),
|
|
patch("memos_provider.ensure_viewer_daemon", return_value=True),
|
|
patch("memos_provider.MemosBridgeClient", return_value=bridge),
|
|
):
|
|
provider = memos_provider.MemTensorProvider()
|
|
provider.initialize("host-session")
|
|
provider.on_turn_start(1, "规划北欧旅行")
|
|
provider.prefetch("规划北欧旅行")
|
|
|
|
# A later executed tool may be reported before post_llm_call
|
|
# backfills planner/todo calls from conversation_history.
|
|
provider._on_post_tool_call(
|
|
tool_name="terminal",
|
|
args={"command": "search flights"},
|
|
result="PVG-CPH 4200 RMB",
|
|
tool_call_id="call_terminal_1",
|
|
)
|
|
provider._on_post_llm_call(
|
|
conversation_history=[
|
|
{"role": "user", "content": "规划北欧旅行"},
|
|
{
|
|
"role": "assistant",
|
|
"reasoning": "先列计划,再查机票。",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_todo_1",
|
|
"call_id": "call_todo_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "todo",
|
|
"arguments": '{"todos": [{"id": "1"}]}',
|
|
},
|
|
},
|
|
{
|
|
"id": "call_terminal_1",
|
|
"call_id": "call_terminal_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "terminal",
|
|
"arguments": '{"command": "search flights"}',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
]
|
|
)
|
|
provider.sync_turn("规划北欧旅行", "路线和预算整理好了。")
|
|
|
|
turn_end = next(params for method, params in bridge.calls if method == "turn.end")
|
|
self.assertEqual([tc["name"] for tc in turn_end["toolCalls"]], ["todo", "terminal"])
|
|
self.assertIn('"todos"', turn_end["toolCalls"][0]["input"])
|
|
self.assertEqual(turn_end["toolCalls"][0]["thinkingBefore"], "先列计划,再查机票。")
|
|
self.assertIn("PVG-CPH", turn_end["toolCalls"][1]["output"])
|
|
self.assertEqual(turn_end["toolCalls"][1]["thinkingBefore"], "先列计划,再查机票。")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|