Files
JARVIS/backend/tests/backend/app/agents/test_graph.py
WIN-JHFT4D3SIVT\caoxiaozhu 4251a79062 feat: add agent registry manifests and coverage
Introduce a manifest-backed agent registry surface and align graph tests with the new runtime prompt and tool indexing behavior.
2026-04-02 14:34:26 +08:00

292 lines
11 KiB
Python

from pathlib import Path
from types import SimpleNamespace
import sys
WORKTREE_ROOT = Path(__file__).resolve().parents[4]
if str(WORKTREE_ROOT) not in sys.path:
sys.path.insert(0, str(WORKTREE_ROOT))
for module_name in list(sys.modules):
if module_name == "app" or module_name.startswith("app."):
del sys.modules[module_name]
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from langgraph.graph import END
from app.agents.graph import (
JSON_ACTION_FALLBACK_PROMPT,
_get_role_tools,
call_agent_llm,
execute_tools_node,
master_node,
route_after_agent,
route_master,
)
from app.agents.state import AgentRole
from app.agents.tools import SUB_COMMANDER_TOOLSETS
from app.agents.prompts import MASTER_SYSTEM_PROMPT
def _base_state(message: str = "帮我安排今天的重点") -> dict:
return {
"messages": [HumanMessage(content=message)],
"user_id": "u1",
"conversation_id": "c1",
"current_agent": AgentRole.MASTER.value,
"next_step": None,
"agent_trace": [AgentRole.MASTER.value],
"pending_tasks": [],
"completed_tasks": [],
"created_entities": [],
"knowledge_context": None,
"schedule_context_summary": None,
"analysis_report": None,
"final_response": None,
"memory_context": None,
"current_datetime_context": None,
"user_llm_config": None,
"provider_capabilities": None,
}
class FailIfCalledLLM:
async def ainvoke(self, messages):
raise AssertionError("LLM should not be called for greeting fast-path")
class StaticResponseLLM:
def __init__(self, response: AIMessage):
self.response = response
self.messages = None
async def ainvoke(self, messages):
self.messages = messages
return self.response
class CaptureFallbackLLM:
def __init__(self, response: AIMessage):
self.response = response
self.messages = None
self.bind_tools_called = False
async def ainvoke(self, messages):
self.messages = messages
return self.response
def bind_tools(self, tools):
self.bind_tools_called = True
raise AssertionError("bind_tools should not be used when native tools are unsupported")
class AsyncFakeTool:
def __init__(self, name: str, result: str):
self.name = name
self.result = result
self.calls: list[dict] = []
async def ainvoke(self, args: dict):
self.calls.append(args)
return self.result
class SyncFakeTool:
def __init__(self, name: str, result: str):
self.name = name
self.result = result
self.calls: list[dict] = []
def invoke(self, args: dict):
self.calls.append(args)
return self.result
async def test_master_node_greeting_fast_path_returns_stable_reply_without_llm(monkeypatch):
monkeypatch.setattr("app.agents.graph._get_llm_for_state", lambda state: (FailIfCalledLLM(), SimpleNamespace()))
result = await master_node(_base_state("你好"))
assert result["final_response"] == "您好。我在。\n\n您把问题给我,我先帮您收束重点,再往下推。"
assert result["messages"][0].content == "您好。我在。"
async def test_master_node_routes_to_agent_when_llm_returns_role_name(monkeypatch):
llm = StaticResponseLLM(AIMessage(content="schedule_planner"))
monkeypatch.setattr(
"app.agents.graph._get_llm_for_state",
lambda state: (llm, SimpleNamespace(provider="test", supports_native_tools=True)),
)
state = _base_state("帮我安排这周重点")
result = await master_node(state)
assert result["current_agent"] == AgentRole.SCHEDULE_PLANNER.value
assert result["agent_trace"] == [AgentRole.MASTER.value, AgentRole.SCHEDULE_PLANNER.value]
assert result["messages"][0].content == f"已分发至 {AgentRole.SCHEDULE_PLANNER.value} 处理。"
assert isinstance(llm.messages[0], SystemMessage)
assert MASTER_SYSTEM_PROMPT in llm.messages[0].content
async def test_master_node_returns_final_response_when_llm_answers_directly(monkeypatch):
response = AIMessage(content="我建议先收束需求,再拆执行步骤。")
llm = StaticResponseLLM(response)
monkeypatch.setattr(
"app.agents.graph._get_llm_for_state",
lambda state: (llm, SimpleNamespace(provider="test", supports_native_tools=True)),
)
result = await master_node(_base_state("现在应该怎么推进这个项目?"))
assert result["final_response"] == response.content
assert result["messages"] == [response]
def test_route_after_agent_sends_tool_calls_to_tools_node():
state = _base_state()
state["messages"] = [AIMessage(content="", tool_calls=[{"id": "1", "name": "create_task", "args": {}}])]
assert route_after_agent(state) == "tools"
def test_route_after_agent_ends_when_no_tool_calls_exist():
state = _base_state()
state["messages"] = [AIMessage(content="done")]
assert route_after_agent(state) == END
def test_route_master_ends_when_final_response_exists():
state = _base_state()
state["final_response"] = "done"
state["current_agent"] = AgentRole.EXECUTOR.value
assert route_master(state) == END
def test_route_master_returns_current_agent_when_more_work_remains():
state = _base_state()
state["current_agent"] = AgentRole.LIBRARIAN.value
assert route_master(state) == AgentRole.LIBRARIAN.value
def test_get_role_tools_returns_expected_semantic_tool_sets():
expected_by_role = {
AgentRole.SCHEDULE_PLANNER: [
"get_schedule_day",
"get_tasks",
"resolve_time_expression",
"create_todo",
"create_schedule_task",
"create_reminder",
"create_goal",
],
AgentRole.EXECUTOR: [
"get_tasks",
"create_task",
"update_task_status",
"resolve_time_expression",
"create_todo",
"create_schedule_task",
"create_reminder",
"create_goal",
"get_forum_posts",
"create_forum_post",
"scan_forum_for_instructions",
],
AgentRole.LIBRARIAN: [
"search_knowledge",
"hybrid_search",
"web_search",
"get_knowledge_graph_context",
"build_knowledge_graph",
],
AgentRole.ANALYST: [
"get_tasks",
"get_forum_posts",
"scan_forum_for_instructions",
"search_knowledge",
"hybrid_search",
"web_search",
],
}
for role, expected_tool_names in expected_by_role.items():
actual_tools = _get_role_tools(role)
actual_tool_names = [tool.name for tool in actual_tools]
assert actual_tool_names == expected_tool_names
assert len(actual_tool_names) == len(set(actual_tool_names))
async def test_execute_tools_node_executes_tool_calls_and_tracks_created_entities(monkeypatch):
create_tool = AsyncFakeTool("create_task", "created task 123")
read_tool = SyncFakeTool("get_tasks", "[]")
monkeypatch.setattr("app.agents.graph.ALL_TOOLS", [create_tool, read_tool])
monkeypatch.setattr(
"app.agents.graph.normalize_tool_time_arguments",
lambda tool_name, tool_args, current_datetime_context: {**tool_args, "normalized": True},
)
state = _base_state()
state["created_entities"] = [{"tool": "existing", "result": "already there"}]
state["current_datetime_context"] = "2026-04-02T09:00:00+08:00"
state["messages"] = [
AIMessage(
content="",
tool_calls=[
{"id": "tool-1", "name": "create_task", "args": {"title": "Write tests"}},
{"id": "tool-2", "name": "get_tasks", "args": {"status": "open"}},
],
)
]
result = await execute_tools_node(state)
assert create_tool.calls == [{"title": "Write tests", "normalized": True}]
assert read_tool.calls == [{"status": "open", "normalized": True}]
assert [type(message) for message in result["messages"]] == [ToolMessage, ToolMessage]
assert result["messages"][0].tool_call_id == "tool-1"
assert result["messages"][0].name == "create_task"
assert result["messages"][0].content == "created task 123"
assert result["messages"][1].tool_call_id == "tool-2"
assert result["messages"][1].name == "get_tasks"
assert result["messages"][1].content == "[]"
assert result["created_entities"] == [
{"tool": "existing", "result": "already there"},
{"tool": "create_task", "result": "created task 123"},
]
async def test_call_agent_llm_includes_context_messages_and_uses_json_fallback(monkeypatch):
llm = CaptureFallbackLLM(AIMessage(content='{"mode":"final","final_response":"好的。"}'))
capabilities = SimpleNamespace(
provider="ollama",
supports_native_tools=False,
preferred_tool_strategy="json_fallback",
)
fake_tools = [SimpleNamespace(name="create_reminder"), SimpleNamespace(name="get_tasks")]
monkeypatch.setattr("app.agents.graph._get_llm_for_state", lambda state: (llm, capabilities))
monkeypatch.setattr("app.agents.graph._get_role_tools", lambda role: fake_tools)
monkeypatch.setattr("app.agents.graph.build_skill_context", lambda role_key: "技能上下文: 先判断,再执行")
state = _base_state("明天提醒我开会")
state["messages"] = [HumanMessage(content="明天提醒我开会")]
state["current_datetime_context"] = "CURRENT_TIME: 2026-04-02T09:00:00+08:00"
state["memory_context"] = "用户偏好早上处理深度工作。"
result = await call_agent_llm(state, AgentRole.EXECUTOR, "executor system prompt")
assert result["messages"][0].content == '{"mode":"final","final_response":"好的。"}'
assert llm.bind_tools_called is False
assert llm.messages is not None
system_contents = [message.content for message in llm.messages if isinstance(message, SystemMessage)]
assert "executor system prompt" in system_contents[0]
assert any("当前时间上下文: CURRENT_TIME: 2026-04-02T09:00:00+08:00" == content for content in system_contents)
assert any("长期记忆上下文: 用户偏好早上处理深度工作。" == content for content in system_contents)
assert any("技能上下文: 先判断,再执行" == content for content in system_contents)
assert any(content == JSON_ACTION_FALLBACK_PROMPT for content in system_contents)
assert any(content == "本次可用工具列表: create_reminder, get_tasks" for content in system_contents)
assert any(isinstance(message, HumanMessage) and message.content == "明天提醒我开会" for message in llm.messages)