Introduce a manifest-backed agent registry surface and align graph tests with the new runtime prompt and tool indexing behavior.
292 lines
11 KiB
Python
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)
|