fix: harden L3 runtime continuity and tool execution
Align the L3 graph, agent service, and sync tool shims on one canonical continuity contract so clarification resumes and persisted snapshots behave consistently. Add targeted regressions and hardening notes covering system-message coalescing, async bridge usage, and continuity rehydration.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
317
backend/tests/backend/app/agents/test_graph_system_messages.py
Normal file
317
backend/tests/backend/app/agents/test_graph_system_messages.py
Normal file
@@ -0,0 +1,317 @@
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
sys.modules.setdefault("trafilatura", Mock())
|
||||
|
||||
from app.agents.graph import _build_system_messages, _run_sub_commander
|
||||
from app.agents.state import AgentRole
|
||||
|
||||
|
||||
def _base_state(message: str, user_llm_config: dict | None = None) -> dict:
|
||||
return {
|
||||
"messages": [HumanMessage(content=message)],
|
||||
"user_id": "u1",
|
||||
"conversation_id": "c1",
|
||||
"current_agent": AgentRole.MASTER,
|
||||
"active_agents": [AgentRole.MASTER],
|
||||
"current_sub_commander": None,
|
||||
"active_sub_commanders": [],
|
||||
"sub_commander_trace": [],
|
||||
"pending_tasks": [],
|
||||
"completed_tasks": [],
|
||||
"tool_calls": [],
|
||||
"last_tool_result": None,
|
||||
"action_results": [],
|
||||
"created_entities": [],
|
||||
"tool_strategy_used": None,
|
||||
"provider_capabilities": None,
|
||||
"fallback_parse_error": None,
|
||||
"knowledge_context": None,
|
||||
"graph_context": None,
|
||||
"schedule_context_summary": None,
|
||||
"plan": None,
|
||||
"plan_steps": [],
|
||||
"analysis_report": None,
|
||||
"final_response": None,
|
||||
"should_respond": True,
|
||||
"memory_context": "memory context",
|
||||
"current_datetime_context": "CURRENT_TIME: 2026-03-28T12:00:00+08:00",
|
||||
"current_datetime_reference": {
|
||||
"current_time_iso": "2026-03-28T12:00:00+08:00",
|
||||
"current_date_iso": "2026-03-28",
|
||||
"timezone": "UTC",
|
||||
},
|
||||
"user_llm_config": user_llm_config,
|
||||
}
|
||||
|
||||
|
||||
class FakeTool:
|
||||
def __init__(self, name: str, result: str):
|
||||
self.name = name
|
||||
self.result = result
|
||||
self.invocations: list[dict] = []
|
||||
|
||||
def invoke(self, args: dict):
|
||||
self.invocations.append(args)
|
||||
return self.result
|
||||
|
||||
|
||||
class SingleSystemMessageLLM:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
self.system_message_counts: list[int] = []
|
||||
self._jarvis_provider_capabilities = SimpleNamespace(
|
||||
provider="minimax",
|
||||
supports_native_tools=False,
|
||||
preferred_tool_strategy="json_fallback",
|
||||
)
|
||||
|
||||
async def ainvoke(self, messages):
|
||||
self.calls += 1
|
||||
self.system_message_counts.append(
|
||||
sum(1 for message in messages if getattr(message, "type", None) == "system")
|
||||
)
|
||||
if self.system_message_counts[-1] != 1:
|
||||
raise AssertionError(
|
||||
f"expected exactly one system message, got {self.system_message_counts[-1]}"
|
||||
)
|
||||
if self.calls == 1:
|
||||
return AIMessage(
|
||||
content=(
|
||||
'{"mode":"tool_call","tool_calls":[{"name":"create_reminder",'
|
||||
'"arguments":{"title":"blanket","reminder_at":"\\u660e\\u5929 09:00"}}]}'
|
||||
)
|
||||
)
|
||||
return AIMessage(content="created reminder for blanket")
|
||||
|
||||
|
||||
def test_build_system_messages_includes_structured_continuity_summary():
|
||||
state = _base_state("创建")
|
||||
state["pending_action"] = {
|
||||
"type": "schedule_creation",
|
||||
"summary": "为周报安排明天下午提醒",
|
||||
"status": "pending",
|
||||
}
|
||||
state["routing_decision"] = {
|
||||
"target_agent": AgentRole.SCHEDULE_PLANNER.value,
|
||||
"reason": "continue_pending_action",
|
||||
}
|
||||
state["continuity_state"] = {"status": "fresh"}
|
||||
|
||||
messages = _build_system_messages(
|
||||
state,
|
||||
"manager prompt",
|
||||
AgentRole.SCHEDULE_PLANNER,
|
||||
"schedule_planning",
|
||||
)
|
||||
|
||||
system_text = "\n\n".join(str(getattr(message, "content", "")) for message in messages)
|
||||
assert "pending_action" in system_text
|
||||
assert "schedule_creation" in system_text
|
||||
assert "continue_pending_action" in system_text
|
||||
assert "为周报安排明天下午提醒" in system_text
|
||||
|
||||
|
||||
def test_build_system_messages_skips_structured_continuity_when_pending_action_is_not_pending():
|
||||
state = _base_state("创建")
|
||||
state["pending_action"] = {
|
||||
"type": "schedule_creation",
|
||||
"summary": "为周报安排明天下午提醒",
|
||||
"status": "completed",
|
||||
}
|
||||
state["routing_decision"] = {
|
||||
"target_agent": AgentRole.SCHEDULE_PLANNER.value,
|
||||
"reason": "continue_pending_action",
|
||||
}
|
||||
state["continuity_state"] = {"status": "fresh"}
|
||||
|
||||
messages = _build_system_messages(
|
||||
state,
|
||||
"manager prompt",
|
||||
AgentRole.SCHEDULE_PLANNER,
|
||||
"schedule_planning",
|
||||
)
|
||||
|
||||
system_text = "\n\n".join(str(getattr(message, "content", "")) for message in messages)
|
||||
assert "structured_continuity" not in system_text
|
||||
assert "continue_pending_action" not in system_text
|
||||
|
||||
|
||||
def test_build_system_messages_skips_structured_continuity_when_routing_reason_is_not_continuation():
|
||||
state = _base_state("创建")
|
||||
state["pending_action"] = {
|
||||
"type": "schedule_creation",
|
||||
"summary": "为周报安排明天下午提醒",
|
||||
"status": "pending",
|
||||
}
|
||||
state["routing_decision"] = {
|
||||
"target_agent": AgentRole.SCHEDULE_PLANNER.value,
|
||||
"reason": "initial_schedule_detection",
|
||||
}
|
||||
state["continuity_state"] = {"status": "fresh"}
|
||||
|
||||
messages = _build_system_messages(
|
||||
state,
|
||||
"manager prompt",
|
||||
AgentRole.SCHEDULE_PLANNER,
|
||||
"schedule_planning",
|
||||
)
|
||||
|
||||
system_text = "\n\n".join(str(getattr(message, "content", "")) for message in messages)
|
||||
assert "structured_continuity" not in system_text
|
||||
assert "continue_pending_action" not in system_text
|
||||
|
||||
|
||||
def test_build_system_messages_skips_structured_continuity_when_routing_decision_missing():
|
||||
state = _base_state("创建")
|
||||
state["pending_action"] = {
|
||||
"type": "schedule_creation",
|
||||
"summary": "为周报安排明天下午提醒",
|
||||
}
|
||||
state["routing_decision"] = None
|
||||
|
||||
messages = _build_system_messages(
|
||||
state,
|
||||
"manager prompt",
|
||||
AgentRole.SCHEDULE_PLANNER,
|
||||
"schedule_planning",
|
||||
)
|
||||
|
||||
system_text = "\n\n".join(str(getattr(message, "content", "")) for message in messages)
|
||||
assert "pending_action" not in system_text
|
||||
assert "schedule_creation" not in system_text
|
||||
assert "为周报安排明天下午提醒" not in system_text
|
||||
|
||||
|
||||
def test_build_system_messages_skips_stale_structured_continuity_for_unrelated_new_request():
|
||||
state = _base_state(
|
||||
"帮我搜索 Rust 异步 trait 最佳实践",
|
||||
{
|
||||
"provider": "openai",
|
||||
"model": "MiniMax-M2.7-highspeed",
|
||||
"base_url": "https://api.minimaxi.com/v1",
|
||||
},
|
||||
)
|
||||
state["current_agent"] = AgentRole.SCHEDULE_PLANNER
|
||||
state["pending_action"] = {
|
||||
"type": "schedule_creation",
|
||||
"summary": "为周报安排明天下午提醒",
|
||||
"status": "pending",
|
||||
}
|
||||
state["routing_decision"] = {
|
||||
"target_agent": AgentRole.SCHEDULE_PLANNER.value,
|
||||
"reason": "continue_pending_action",
|
||||
}
|
||||
state["continuity_state"] = {
|
||||
"status": "stale",
|
||||
"override_reason": "new_explicit_request",
|
||||
}
|
||||
|
||||
messages = _build_system_messages(
|
||||
state,
|
||||
"manager prompt",
|
||||
AgentRole.SCHEDULE_PLANNER,
|
||||
"schedule_planning",
|
||||
)
|
||||
|
||||
system_text = "\n\n".join(str(getattr(message, "content", "")) for message in messages)
|
||||
assert "structured_continuity" not in system_text
|
||||
assert "pending_action" not in system_text
|
||||
assert "continue_pending_action" not in system_text
|
||||
|
||||
|
||||
def test_build_system_messages_uses_role_scoped_context_instead_of_raw_memory_blob():
|
||||
state = _base_state("帮我搜索 Rust 异步 trait 最佳实践")
|
||||
state["memory_context"] = "【用户记忆】\n- 用户喜欢燕麦拿铁。\n\n【之前对话摘要】\n[对话摘要1] 之前聊过提醒。\n\n【知识大脑】\n- Rust Async: trait object 需要 pin。"
|
||||
state["schedule_context_summary"] = "【用户记忆】\n- 用户喜欢燕麦拿铁。\n\n【之前对话摘要】\n[对话摘要1] 之前聊过提醒。"
|
||||
state["knowledge_context"] = "【知识大脑】\n- Rust Async: trait object 需要 pin。"
|
||||
state["analysis_report"] = "【之前对话摘要】\n[对话摘要1] 之前聊过提醒。\n\n【知识大脑】\n- Rust Async: trait object 需要 pin。"
|
||||
|
||||
messages = _build_system_messages(
|
||||
state,
|
||||
"manager prompt",
|
||||
AgentRole.LIBRARIAN,
|
||||
"librarian_retrieval",
|
||||
)
|
||||
|
||||
system_text = "\n\n".join(str(getattr(message, "content", "")) for message in messages)
|
||||
assert "角色上下文" in system_text
|
||||
assert "【知识大脑】" in system_text
|
||||
assert "Rust Async" in system_text
|
||||
assert "用户喜欢燕麦拿铁" not in system_text
|
||||
assert "记忆上下文" not in system_text
|
||||
|
||||
|
||||
def test_build_system_messages_keeps_fresh_structured_continuity_for_matching_followup():
|
||||
state = _base_state(
|
||||
"创建",
|
||||
{
|
||||
"provider": "openai",
|
||||
"model": "MiniMax-M2.7-highspeed",
|
||||
"base_url": "https://api.minimaxi.com/v1",
|
||||
},
|
||||
)
|
||||
state["current_agent"] = AgentRole.SCHEDULE_PLANNER
|
||||
state["pending_action"] = {
|
||||
"type": "schedule_creation",
|
||||
"summary": "为周报安排明天下午提醒",
|
||||
"status": "pending",
|
||||
}
|
||||
state["routing_decision"] = {
|
||||
"target_agent": AgentRole.SCHEDULE_PLANNER.value,
|
||||
"reason": "continue_pending_action",
|
||||
}
|
||||
state["continuity_state"] = {
|
||||
"status": "fresh",
|
||||
}
|
||||
|
||||
messages = _build_system_messages(
|
||||
state,
|
||||
"manager prompt",
|
||||
AgentRole.SCHEDULE_PLANNER,
|
||||
"schedule_planning",
|
||||
)
|
||||
|
||||
system_text = "\n\n".join(str(getattr(message, "content", "")) for message in messages)
|
||||
assert "pending_action" in system_text
|
||||
assert "continue_pending_action" in system_text
|
||||
|
||||
|
||||
async def test_run_sub_commander_coalesces_system_messages_for_openai_compatible_provider(
|
||||
monkeypatch,
|
||||
):
|
||||
fake_llm = SingleSystemMessageLLM()
|
||||
fake_tool = FakeTool("create_reminder", "created reminder: blanket @ tomorrow 09:00")
|
||||
|
||||
monkeypatch.setattr("app.agents.graph._get_llm_for_state", lambda state: fake_llm)
|
||||
monkeypatch.setitem(
|
||||
__import__("app.agents.graph", fromlist=["SUB_COMMANDER_TOOLSETS"]).SUB_COMMANDER_TOOLSETS,
|
||||
"schedule_planning",
|
||||
[fake_tool],
|
||||
)
|
||||
|
||||
state = _base_state(
|
||||
"给我设置明天的提醒,提醒我收被子",
|
||||
{
|
||||
"provider": "openai",
|
||||
"model": "MiniMax-M2.7-highspeed",
|
||||
"base_url": "https://api.minimaxi.com/v1",
|
||||
},
|
||||
)
|
||||
state["current_agent"] = AgentRole.SCHEDULE_PLANNER
|
||||
|
||||
result = await _run_sub_commander(
|
||||
state,
|
||||
AgentRole.SCHEDULE_PLANNER,
|
||||
"manager prompt",
|
||||
"给我设置明天的提醒,提醒我收被子",
|
||||
use_tools=True,
|
||||
)
|
||||
|
||||
assert fake_llm.system_message_counts == [1, 1]
|
||||
assert result["tool_strategy_used"] == "json_fallback"
|
||||
assert fake_tool.invocations == [{"title": "blanket", "reminder_at": "2026-03-29T09:00:00"}]
|
||||
assert result["final_response"] == "created reminder for blanket"
|
||||
@@ -47,3 +47,27 @@ def test_web_search_tool_returns_stable_message_when_unavailable(monkeypatch):
|
||||
result = web_search.func('Jarvis')
|
||||
|
||||
assert result == '网页搜索不可用: 网页搜索未启用或未配置'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_search_tool_runs_from_active_event_loop(monkeypatch):
|
||||
class FakeService:
|
||||
async def search(self, query: str, limit: int | None = None):
|
||||
assert query == 'Jarvis 最新更新'
|
||||
assert limit == 1
|
||||
return [
|
||||
FakeResult(
|
||||
title='Jarvis release notes',
|
||||
url='https://example.com/jarvis-release',
|
||||
snippet='Latest Jarvis changes.',
|
||||
source='duckduckgo',
|
||||
published_at='2026-03-29',
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr('app.services.web_search_service.WebSearchService', FakeService)
|
||||
|
||||
result = web_search.func('Jarvis 最新更新', top_k=1)
|
||||
|
||||
assert '[1] Jarvis release notes' in result
|
||||
assert '链接: https://example.com/jarvis-release' in result
|
||||
|
||||
@@ -2,6 +2,7 @@ import pytest
|
||||
|
||||
from app.agents.tools import forum as forum_tools
|
||||
from app.agents.tools import schedule as schedule_tools
|
||||
from app.agents.tools import search as search_tools
|
||||
from app.agents.tools import task as task_tools
|
||||
|
||||
|
||||
@@ -12,6 +13,7 @@ from app.agents.tools import task as task_tools
|
||||
(task_tools, "task"),
|
||||
(schedule_tools, "schedule"),
|
||||
(forum_tools, "forum"),
|
||||
(search_tools, "search"),
|
||||
],
|
||||
)
|
||||
async def test_run_async_bridge_works_inside_running_event_loop(module, label):
|
||||
|
||||
@@ -127,15 +127,14 @@ class FakeStreamingFallbackWithContinuityGraph:
|
||||
return {
|
||||
'final_response': '这是回退后的同步回答。',
|
||||
'continuity_state': {
|
||||
'active_agent': 'executor',
|
||||
'active_sub_flow': 'create_task',
|
||||
'status': 'awaiting_clarification',
|
||||
'status': 'fresh',
|
||||
'mode': 'resume_after_clarification',
|
||||
},
|
||||
'pending_action': {
|
||||
'agent': 'executor',
|
||||
'sub_flow': 'create_task',
|
||||
'action_type': 'create_task',
|
||||
'status': 'awaiting_confirmation',
|
||||
'type': 'create_task',
|
||||
'owner_agent': 'executor',
|
||||
'owner_sub_commander': 'create_task',
|
||||
'status': 'pending',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -690,25 +689,25 @@ async def test_streaming_chat_fallback_reuses_rehydrated_continuity_snapshot(bra
|
||||
'user_turn_type': 'continuation',
|
||||
'user_turn_signal': 'clarification_answer',
|
||||
'active_agent': 'executor',
|
||||
'active_sub_flow': 'create_reminder',
|
||||
'active_sub_commander': 'create_reminder',
|
||||
},
|
||||
'current_agent': 'executor',
|
||||
'clarification_context': {
|
||||
'awaiting_user_input': True,
|
||||
'active_agent': 'executor',
|
||||
'sub_flow': 'create_reminder',
|
||||
'owning_agent': 'executor',
|
||||
'owning_sub_commander': 'create_reminder',
|
||||
'target_action': 'create_reminder',
|
||||
'question': '你想提醒几点?',
|
||||
'status': 'pending',
|
||||
},
|
||||
'pending_action': {
|
||||
'agent': 'executor',
|
||||
'sub_flow': 'create_reminder',
|
||||
'action_type': 'clarification',
|
||||
'status': 'awaiting_clarification',
|
||||
'type': 'clarification',
|
||||
'owner_agent': 'executor',
|
||||
'owner_sub_commander': 'create_reminder',
|
||||
'status': 'blocked_on_clarification',
|
||||
},
|
||||
'continuity_state': {
|
||||
'active_agent': 'executor',
|
||||
'active_sub_flow': 'create_reminder',
|
||||
'status': 'awaiting_clarification',
|
||||
'status': 'fresh',
|
||||
'mode': 'resume_after_clarification',
|
||||
},
|
||||
}
|
||||
conversation.agent_state = {
|
||||
@@ -927,21 +926,21 @@ async def test_chat_simple_persists_continuity_snapshot_on_assistant_message(bra
|
||||
return {
|
||||
'final_response': '需要你确认下一步。',
|
||||
'pending_action': {
|
||||
'agent': 'executor',
|
||||
'sub_flow': 'create_task',
|
||||
'action_type': 'create_task',
|
||||
'status': 'awaiting_confirmation',
|
||||
'type': 'create_task',
|
||||
'owner_agent': 'executor',
|
||||
'owner_sub_commander': 'create_task',
|
||||
'status': 'pending',
|
||||
},
|
||||
'clarification_context': {
|
||||
'awaiting_user_input': True,
|
||||
'active_agent': 'executor',
|
||||
'sub_flow': 'create_task',
|
||||
'owning_agent': 'executor',
|
||||
'owning_sub_commander': 'create_task',
|
||||
'target_action': 'create_task',
|
||||
'question': '要现在创建吗?',
|
||||
'status': 'pending',
|
||||
},
|
||||
'continuity_state': {
|
||||
'active_agent': 'executor',
|
||||
'active_sub_flow': 'create_task',
|
||||
'status': 'awaiting_clarification',
|
||||
'status': 'fresh',
|
||||
'mode': 'resume_after_clarification',
|
||||
},
|
||||
'last_completed_action': {
|
||||
'tool_name': 'create_task',
|
||||
@@ -972,15 +971,14 @@ async def test_chat_simple_persists_continuity_snapshot_on_assistant_message(bra
|
||||
'version': 1,
|
||||
'state': {
|
||||
'continuity_state': {
|
||||
'active_agent': 'executor',
|
||||
'active_sub_flow': 'create_task',
|
||||
'status': 'awaiting_clarification',
|
||||
'status': 'fresh',
|
||||
'mode': 'resume_after_clarification',
|
||||
},
|
||||
'pending_action': {
|
||||
'agent': 'executor',
|
||||
'sub_flow': 'create_task',
|
||||
'action_type': 'create_task',
|
||||
'status': 'awaiting_confirmation',
|
||||
'type': 'create_task',
|
||||
'owner_agent': 'executor',
|
||||
'owner_sub_commander': 'create_task',
|
||||
'status': 'pending',
|
||||
},
|
||||
'last_completed_action': {
|
||||
'tool_name': 'create_task',
|
||||
@@ -989,10 +987,11 @@ async def test_chat_simple_persists_continuity_snapshot_on_assistant_message(bra
|
||||
'entity_type': 'task',
|
||||
},
|
||||
'clarification_context': {
|
||||
'awaiting_user_input': True,
|
||||
'active_agent': 'executor',
|
||||
'sub_flow': 'create_task',
|
||||
'owning_agent': 'executor',
|
||||
'owning_sub_commander': 'create_task',
|
||||
'target_action': 'create_task',
|
||||
'question': '要现在创建吗?',
|
||||
'status': 'pending',
|
||||
},
|
||||
},
|
||||
}]
|
||||
@@ -1005,21 +1004,21 @@ async def test_streaming_chat_persists_continuity_snapshot_in_assistant_message_
|
||||
final_response='继续处理。',
|
||||
output_state={
|
||||
'continuity_state': {
|
||||
'active_agent': 'executor',
|
||||
'active_sub_flow': 'create_task',
|
||||
'status': 'awaiting_clarification',
|
||||
'status': 'fresh',
|
||||
'mode': 'resume_after_clarification',
|
||||
},
|
||||
'pending_action': {
|
||||
'agent': 'executor',
|
||||
'sub_flow': 'create_task',
|
||||
'action_type': 'create_task',
|
||||
'status': 'awaiting_confirmation',
|
||||
'type': 'create_task',
|
||||
'owner_agent': 'executor',
|
||||
'owner_sub_commander': 'create_task',
|
||||
'status': 'pending',
|
||||
},
|
||||
'clarification_context': {
|
||||
'awaiting_user_input': True,
|
||||
'active_agent': 'executor',
|
||||
'sub_flow': 'create_task',
|
||||
'owning_agent': 'executor',
|
||||
'owning_sub_commander': 'create_task',
|
||||
'target_action': 'create_task',
|
||||
'question': '要现在创建吗?',
|
||||
'status': 'pending',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1044,21 +1043,21 @@ async def test_streaming_chat_persists_continuity_snapshot_in_assistant_message_
|
||||
|
||||
expected_state_fields = {
|
||||
'continuity_state': {
|
||||
'active_agent': 'executor',
|
||||
'active_sub_flow': 'create_task',
|
||||
'status': 'awaiting_clarification',
|
||||
'status': 'fresh',
|
||||
'mode': 'resume_after_clarification',
|
||||
},
|
||||
'pending_action': {
|
||||
'agent': 'executor',
|
||||
'sub_flow': 'create_task',
|
||||
'action_type': 'create_task',
|
||||
'status': 'awaiting_confirmation',
|
||||
'type': 'create_task',
|
||||
'owner_agent': 'executor',
|
||||
'owner_sub_commander': 'create_task',
|
||||
'status': 'pending',
|
||||
},
|
||||
'clarification_context': {
|
||||
'awaiting_user_input': True,
|
||||
'active_agent': 'executor',
|
||||
'sub_flow': 'create_task',
|
||||
'owning_agent': 'executor',
|
||||
'owning_sub_commander': 'create_task',
|
||||
'target_action': 'create_task',
|
||||
'question': '要现在创建吗?',
|
||||
'status': 'pending',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1071,6 +1070,7 @@ async def test_streaming_chat_persists_continuity_snapshot_in_assistant_message_
|
||||
assert persisted_snapshot['state'][key] == value
|
||||
assert conversation is not None
|
||||
assert conversation.agent_state == {
|
||||
'kind': 'agent_continuity_state',
|
||||
'version': persisted_snapshot['version'],
|
||||
'state': persisted_snapshot['state'],
|
||||
}
|
||||
@@ -1099,21 +1099,21 @@ async def test_streaming_chat_rehydrates_previous_continuity_snapshot(brain_inge
|
||||
'version': 1,
|
||||
'state': {
|
||||
'pending_action': {
|
||||
'agent': 'executor',
|
||||
'sub_flow': 'create_task',
|
||||
'action_type': 'create_task',
|
||||
'status': 'awaiting_confirmation',
|
||||
'type': 'create_task',
|
||||
'owner_agent': 'executor',
|
||||
'owner_sub_commander': 'create_task',
|
||||
'status': 'pending',
|
||||
},
|
||||
'clarification_context': {
|
||||
'awaiting_user_input': True,
|
||||
'active_agent': 'executor',
|
||||
'sub_flow': 'create_task',
|
||||
'owning_agent': 'executor',
|
||||
'owning_sub_commander': 'create_task',
|
||||
'target_action': 'create_task',
|
||||
'question': '要现在创建吗?',
|
||||
'status': 'pending',
|
||||
},
|
||||
'continuity_state': {
|
||||
'active_agent': 'executor',
|
||||
'active_sub_flow': 'create_task',
|
||||
'status': 'awaiting_clarification',
|
||||
'status': 'fresh',
|
||||
'mode': 'resume_after_clarification',
|
||||
},
|
||||
'last_completed_action': {
|
||||
'tool_name': 'create_task',
|
||||
@@ -1139,21 +1139,21 @@ async def test_streaming_chat_rehydrates_previous_continuity_snapshot(brain_inge
|
||||
|
||||
assert streaming_graph.captured_state is not None
|
||||
assert streaming_graph.captured_state['pending_action'] == {
|
||||
'agent': 'executor',
|
||||
'sub_flow': 'create_task',
|
||||
'action_type': 'create_task',
|
||||
'status': 'awaiting_confirmation',
|
||||
'type': 'create_task',
|
||||
'owner_agent': 'executor',
|
||||
'owner_sub_commander': 'create_task',
|
||||
'status': 'pending',
|
||||
}
|
||||
assert streaming_graph.captured_state['clarification_context'] == {
|
||||
'awaiting_user_input': True,
|
||||
'active_agent': 'executor',
|
||||
'sub_flow': 'create_task',
|
||||
'owning_agent': 'executor',
|
||||
'owning_sub_commander': 'create_task',
|
||||
'target_action': 'create_task',
|
||||
'question': '要现在创建吗?',
|
||||
'status': 'pending',
|
||||
}
|
||||
assert streaming_graph.captured_state['continuity_state'] == {
|
||||
'active_agent': 'executor',
|
||||
'active_sub_flow': 'create_task',
|
||||
'status': 'awaiting_clarification',
|
||||
'status': 'fresh',
|
||||
'mode': 'resume_after_clarification',
|
||||
}
|
||||
assert streaming_graph.captured_state['last_completed_action'] == {
|
||||
'tool_name': 'create_task',
|
||||
@@ -1374,11 +1374,11 @@ async def test_build_memory_context_includes_brain_memory_section(brain_ingestio
|
||||
'Jarvis 接下来应该优先做什么?',
|
||||
)
|
||||
|
||||
assert '【用户记忆】' in context
|
||||
assert '【之前对话摘要】' in context
|
||||
assert '【知识大脑】' in context
|
||||
assert 'Knowledge brain phase 1' in context
|
||||
assert 'Jarvis should learn from conversation and document events first.' in context
|
||||
assert '【用户记忆】' not in context
|
||||
assert 'Forum moderation policy' not in context
|
||||
|
||||
|
||||
@@ -1397,25 +1397,25 @@ async def test_chat_simple_rehydrates_clarification_follow_up_state_into_langgra
|
||||
'user_turn_type': 'continuation',
|
||||
'user_turn_signal': 'clarification_answer',
|
||||
'active_agent': 'executor',
|
||||
'active_sub_flow': 'create_reminder',
|
||||
'active_sub_commander': 'create_reminder',
|
||||
},
|
||||
'current_agent': 'executor',
|
||||
'clarification_context': {
|
||||
'awaiting_user_input': True,
|
||||
'active_agent': 'executor',
|
||||
'sub_flow': 'create_reminder',
|
||||
'owning_agent': 'executor',
|
||||
'owning_sub_commander': 'create_reminder',
|
||||
'target_action': 'create_reminder',
|
||||
'question': '你想提醒几点?',
|
||||
'status': 'pending',
|
||||
},
|
||||
'pending_action': {
|
||||
'agent': 'executor',
|
||||
'sub_flow': 'create_reminder',
|
||||
'action_type': 'clarification',
|
||||
'status': 'awaiting_clarification',
|
||||
'type': 'clarification',
|
||||
'owner_agent': 'executor',
|
||||
'owner_sub_commander': 'create_reminder',
|
||||
'status': 'blocked_on_clarification',
|
||||
},
|
||||
'continuity_state': {
|
||||
'active_agent': 'executor',
|
||||
'active_sub_flow': 'create_reminder',
|
||||
'status': 'awaiting_clarification',
|
||||
'status': 'fresh',
|
||||
'mode': 'resume_after_clarification',
|
||||
},
|
||||
}
|
||||
session.add(Message(
|
||||
@@ -1465,25 +1465,25 @@ async def test_chat_simple_preserves_stale_continuity_state_for_fresh_request_ov
|
||||
'user_turn_type': 'continuation',
|
||||
'user_turn_signal': 'clarification_answer',
|
||||
'active_agent': 'executor',
|
||||
'active_sub_flow': 'create_reminder',
|
||||
'active_sub_commander': 'create_reminder',
|
||||
},
|
||||
'current_agent': 'executor',
|
||||
'clarification_context': {
|
||||
'awaiting_user_input': True,
|
||||
'active_agent': 'executor',
|
||||
'sub_flow': 'create_reminder',
|
||||
'owning_agent': 'executor',
|
||||
'owning_sub_commander': 'create_reminder',
|
||||
'target_action': 'create_reminder',
|
||||
'question': '你想提醒几点?',
|
||||
'status': 'pending',
|
||||
},
|
||||
'pending_action': {
|
||||
'agent': 'executor',
|
||||
'sub_flow': 'create_reminder',
|
||||
'action_type': 'clarification',
|
||||
'status': 'awaiting_clarification',
|
||||
'type': 'clarification',
|
||||
'owner_agent': 'executor',
|
||||
'owner_sub_commander': 'create_reminder',
|
||||
'status': 'blocked_on_clarification',
|
||||
},
|
||||
'continuity_state': {
|
||||
'active_agent': 'executor',
|
||||
'active_sub_flow': 'create_reminder',
|
||||
'status': 'awaiting_clarification',
|
||||
'status': 'fresh',
|
||||
'mode': 'resume_after_clarification',
|
||||
},
|
||||
'last_completed_action': {
|
||||
'tool_name': 'create_reminder',
|
||||
@@ -1546,25 +1546,24 @@ async def test_streaming_chat_rehydrates_continuation_state_and_memory_context_i
|
||||
'user_turn_type': 'continuation',
|
||||
'user_turn_signal': 'clarification_answer',
|
||||
'active_agent': 'schedule_planner',
|
||||
'active_sub_flow': 'plan_revision',
|
||||
'active_sub_commander': 'plan_revision',
|
||||
},
|
||||
'current_agent': 'schedule_planner',
|
||||
'clarification_context': {
|
||||
'awaiting_user_input': True,
|
||||
'active_agent': 'schedule_planner',
|
||||
'sub_flow': 'plan_revision',
|
||||
'owning_agent': 'schedule_planner',
|
||||
'owning_sub_commander': 'plan_revision',
|
||||
'question': '你想优先看总结版还是完整计划?',
|
||||
'status': 'pending',
|
||||
},
|
||||
'pending_action': {
|
||||
'agent': 'schedule_planner',
|
||||
'sub_flow': 'plan_revision',
|
||||
'action_type': 'clarification',
|
||||
'status': 'awaiting_clarification',
|
||||
'type': 'clarification',
|
||||
'owner_agent': 'schedule_planner',
|
||||
'owner_sub_commander': 'plan_revision',
|
||||
'status': 'blocked_on_clarification',
|
||||
},
|
||||
'continuity_state': {
|
||||
'active_agent': 'schedule_planner',
|
||||
'active_sub_flow': 'plan_revision',
|
||||
'status': 'awaiting_clarification',
|
||||
'status': 'fresh',
|
||||
'mode': 'resume_after_clarification',
|
||||
},
|
||||
}
|
||||
session.add(Message(
|
||||
@@ -1585,7 +1584,7 @@ async def test_streaming_chat_rehydrates_continuation_state_and_memory_context_i
|
||||
'【延续处理】\n'
|
||||
'- continuation context: this user turn continues an existing workflow.\n'
|
||||
'- active_agent: schedule_planner\n'
|
||||
'- active_sub_flow: plan_revision\n'
|
||||
'- active_sub_commander: plan_revision\n'
|
||||
'- user_turn_signal: clarification_answer'
|
||||
)
|
||||
|
||||
@@ -1617,3 +1616,380 @@ async def test_streaming_chat_rehydrates_continuation_state_and_memory_context_i
|
||||
assert graph.captured_state['pending_action'] == previous_snapshot['pending_action']
|
||||
assert graph.captured_state['continuity_state'] == previous_snapshot['continuity_state']
|
||||
assert graph.captured_state['current_agent'] == 'schedule_planner'
|
||||
async def test_build_memory_context_suppresses_summary_for_memory_query(brain_ingestion_env):
|
||||
session, user = brain_ingestion_env
|
||||
conversation = Conversation(user_id=user.id, title='Memory-only query test')
|
||||
session.add(conversation)
|
||||
await session.flush()
|
||||
|
||||
session.add(UserMemory(
|
||||
user_id=user.id,
|
||||
memory_type='preference',
|
||||
content='用户喜欢燕麦拿铁。',
|
||||
importance=8,
|
||||
source_conversation_id=conversation.id,
|
||||
))
|
||||
session.add(MemorySummary(
|
||||
user_id=user.id,
|
||||
conversation_id=conversation.id,
|
||||
summary_text='之前讨论了知识大脑迁移和文档入库流程。',
|
||||
turn_count=10,
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
context = await memory_service.build_memory_context(
|
||||
session,
|
||||
user.id,
|
||||
conversation.id,
|
||||
'记住我喜欢燕麦拿铁,以后推荐咖啡时参考这个偏好。',
|
||||
)
|
||||
|
||||
assert '【用户记忆】' in context
|
||||
assert '用户喜欢燕麦拿铁。' in context
|
||||
assert '【之前对话摘要】' not in context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_memory_context_keeps_summary_for_ambiguous_like_word_query(brain_ingestion_env):
|
||||
session, user = brain_ingestion_env
|
||||
conversation = Conversation(user_id=user.id, title='Ambiguous preference word test')
|
||||
session.add(conversation)
|
||||
await session.flush()
|
||||
|
||||
session.add(UserMemory(
|
||||
user_id=user.id,
|
||||
memory_type='preference',
|
||||
content='用户喜欢结构化输出。',
|
||||
importance=7,
|
||||
source_conversation_id=conversation.id,
|
||||
))
|
||||
session.add(MemorySummary(
|
||||
user_id=user.id,
|
||||
conversation_id=conversation.id,
|
||||
summary_text='之前已经总结过知识大脑迁移计划。',
|
||||
turn_count=6,
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
context = await memory_service.build_memory_context(
|
||||
session,
|
||||
user.id,
|
||||
conversation.id,
|
||||
'你觉得用户会喜欢这个知识大脑迁移方案吗?顺便总结一下之前聊过的重点。',
|
||||
)
|
||||
|
||||
assert '【用户记忆】' not in context
|
||||
assert '【之前对话摘要】' in context
|
||||
assert '之前已经总结过知识大脑迁移计划。' in context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_memory_context_keeps_summary_for_document_reference_query(brain_ingestion_env):
|
||||
session, user = brain_ingestion_env
|
||||
conversation = Conversation(user_id=user.id, title='Document reference query test')
|
||||
session.add(conversation)
|
||||
await session.flush()
|
||||
|
||||
session.add(UserMemory(
|
||||
user_id=user.id,
|
||||
memory_type='preference',
|
||||
content='用户偏好带示例的说明。',
|
||||
importance=7,
|
||||
source_conversation_id=conversation.id,
|
||||
))
|
||||
session.add(MemorySummary(
|
||||
user_id=user.id,
|
||||
conversation_id=conversation.id,
|
||||
summary_text='之前总结了文档入库和知识大脑联动流程。',
|
||||
turn_count=7,
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
context = await memory_service.build_memory_context(
|
||||
session,
|
||||
user.id,
|
||||
conversation.id,
|
||||
'这个 document ingestion 方案会有什么影响?也请总结一下之前聊过的重点。',
|
||||
)
|
||||
|
||||
assert '【用户记忆】' not in context
|
||||
assert '【之前对话摘要】' in context
|
||||
assert '之前总结了文档入库和知识大脑联动流程。' in context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_memory_context_suppresses_user_memory_for_grounded_query(brain_ingestion_env):
|
||||
session, user = brain_ingestion_env
|
||||
conversation = Conversation(user_id=user.id, title='Grounded query test')
|
||||
session.add(conversation)
|
||||
await session.flush()
|
||||
|
||||
session.add(UserMemory(
|
||||
user_id=user.id,
|
||||
memory_type='preference',
|
||||
content='用户偏好轻松随意的语气。',
|
||||
importance=9,
|
||||
source_conversation_id=conversation.id,
|
||||
))
|
||||
session.add(MemorySummary(
|
||||
user_id=user.id,
|
||||
conversation_id=conversation.id,
|
||||
summary_text='之前聊过论坛审核策略。',
|
||||
turn_count=8,
|
||||
))
|
||||
session.add(BrainMemory(
|
||||
user_id=user.id,
|
||||
memory_type='project_fact',
|
||||
title='Document ingestion flow',
|
||||
content='Document uploads are chunked before vector indexing.',
|
||||
importance=7,
|
||||
confidence=0.9,
|
||||
status='active',
|
||||
origin_source_types=['document'],
|
||||
metadata_={'source_count': 1},
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
context = await memory_service.build_memory_context(
|
||||
session,
|
||||
user.id,
|
||||
conversation.id,
|
||||
'请严格根据文档内容说明 document ingestion flow,不要结合我的个人偏好。',
|
||||
)
|
||||
|
||||
assert '【知识大脑】' in context
|
||||
assert 'Document ingestion flow' in context
|
||||
assert '【用户记忆】' not in context
|
||||
assert '用户偏好轻松随意的语气。' not in context
|
||||
assert '【之前对话摘要】' not in context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_memory_context_keeps_partial_context_when_user_memory_recall_fails(
|
||||
brain_ingestion_env,
|
||||
monkeypatch,
|
||||
caplog,
|
||||
):
|
||||
session, user = brain_ingestion_env
|
||||
conversation = Conversation(user_id=user.id, title='Partial context test')
|
||||
session.add(conversation)
|
||||
await session.flush()
|
||||
|
||||
session.add(MemorySummary(
|
||||
user_id=user.id,
|
||||
conversation_id=conversation.id,
|
||||
summary_text='之前总结了知识大脑的激活记忆策略。',
|
||||
turn_count=9,
|
||||
))
|
||||
session.add(BrainMemory(
|
||||
user_id=user.id,
|
||||
memory_type='project_fact',
|
||||
title='Active memory filter',
|
||||
content='Only active Brain memories should enter default prompt context.',
|
||||
importance=8,
|
||||
confidence=0.96,
|
||||
status='active',
|
||||
origin_source_types=['conversation'],
|
||||
metadata_={'source_count': 1},
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
original_execute = session.execute
|
||||
recall_selects = 0
|
||||
|
||||
async def fail_recall_user_memories(*args, **kwargs):
|
||||
nonlocal recall_selects
|
||||
recall_selects += 1
|
||||
await original_execute(select(UserMemory).where(UserMemory.user_id == user.id))
|
||||
raise RuntimeError('mem0 unavailable')
|
||||
|
||||
monkeypatch.setattr(memory_service, 'recall_user_memories', fail_recall_user_memories)
|
||||
caplog.set_level('WARNING')
|
||||
|
||||
context = await memory_service.build_memory_context(
|
||||
session,
|
||||
user.id,
|
||||
conversation.id,
|
||||
'active memory filter',
|
||||
)
|
||||
|
||||
assert recall_selects == 1
|
||||
assert '【之前对话摘要】' in context
|
||||
assert '之前总结了知识大脑的激活记忆策略。' in context
|
||||
assert '【知识大脑】' in context
|
||||
assert 'Active memory filter' in context
|
||||
assert '【用户记忆】' not in context
|
||||
assert any('用户记忆召回失败' in record.message for record in caplog.records)
|
||||
assert any(record.exc_info for record in caplog.records if '用户记忆召回失败' in record.message)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_memory_context_does_not_rollback_caller_pending_message_on_tolerated_failure(
|
||||
brain_ingestion_env,
|
||||
monkeypatch,
|
||||
):
|
||||
session, user = brain_ingestion_env
|
||||
conversation = Conversation(user_id=user.id, title='Pending message preservation test')
|
||||
session.add(conversation)
|
||||
await session.flush()
|
||||
|
||||
pending_message = Message(
|
||||
conversation_id=conversation.id,
|
||||
role='user',
|
||||
content='这条消息不应因记忆召回失败而丢失。',
|
||||
)
|
||||
session.add(pending_message)
|
||||
|
||||
async def fail_recall_user_memories(*args, **kwargs):
|
||||
raise RuntimeError('mem0 unavailable')
|
||||
|
||||
monkeypatch.setattr(memory_service, 'recall_user_memories', fail_recall_user_memories)
|
||||
|
||||
context = await memory_service.build_memory_context(
|
||||
session,
|
||||
user.id,
|
||||
conversation.id,
|
||||
'active memory filter',
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
persisted_message = await session.get(Message, pending_message.id)
|
||||
|
||||
assert context == ''
|
||||
assert persisted_message is not None
|
||||
assert persisted_message.content == '这条消息不应因记忆召回失败而丢失。'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_memory_context_skips_unrelated_user_memory_when_fallback_has_no_query_match(brain_ingestion_env):
|
||||
session, user = brain_ingestion_env
|
||||
conversation = Conversation(user_id=user.id, title='Irrelevant fallback memory test')
|
||||
session.add(conversation)
|
||||
await session.flush()
|
||||
|
||||
session.add(UserMemory(
|
||||
user_id=user.id,
|
||||
memory_type='preference',
|
||||
content='用户喜欢燕麦拿铁。',
|
||||
importance=8,
|
||||
source_conversation_id=conversation.id,
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
context = await memory_service.build_memory_context(
|
||||
session,
|
||||
user.id,
|
||||
conversation.id,
|
||||
'讨论数据库迁移回滚策略。',
|
||||
)
|
||||
|
||||
assert '【用户记忆】' not in context
|
||||
assert '用户喜欢燕麦拿铁。' not in context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_memory_context_marks_recalled_memories_in_single_commit(
|
||||
brain_ingestion_env,
|
||||
monkeypatch,
|
||||
):
|
||||
session, user = brain_ingestion_env
|
||||
conversation = Conversation(user_id=user.id, title='Recall batching test')
|
||||
session.add(conversation)
|
||||
await session.flush()
|
||||
|
||||
memories = [
|
||||
UserMemory(
|
||||
user_id=user.id,
|
||||
memory_type='preference',
|
||||
content='用户偏好简洁回答。',
|
||||
importance=7,
|
||||
source_conversation_id=conversation.id,
|
||||
),
|
||||
UserMemory(
|
||||
user_id=user.id,
|
||||
memory_type='goal',
|
||||
content='用户想推进知识大脑上线。',
|
||||
importance=6,
|
||||
source_conversation_id=conversation.id,
|
||||
),
|
||||
]
|
||||
session.add_all(memories)
|
||||
await session.commit()
|
||||
|
||||
original_commit = session.commit
|
||||
commit_calls = 0
|
||||
|
||||
async def counting_commit():
|
||||
nonlocal commit_calls
|
||||
commit_calls += 1
|
||||
await original_commit()
|
||||
|
||||
monkeypatch.setattr(session, 'commit', counting_commit)
|
||||
|
||||
context = await memory_service.build_memory_context(
|
||||
session,
|
||||
user.id,
|
||||
conversation.id,
|
||||
'请结合我的历史偏好给我建议。',
|
||||
)
|
||||
|
||||
assert '【用户记忆】' in context
|
||||
assert '用户偏好简洁回答。' in context
|
||||
assert '用户想推进知识大脑上线。' in context
|
||||
assert commit_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_memory_context_excludes_non_active_brain_memories(brain_ingestion_env):
|
||||
session, user = brain_ingestion_env
|
||||
conversation = Conversation(user_id=user.id, title='Brain status filter test')
|
||||
session.add(conversation)
|
||||
await session.flush()
|
||||
|
||||
session.add(BrainMemory(
|
||||
user_id=user.id,
|
||||
memory_type='project_fact',
|
||||
title='Active rollout note',
|
||||
content='Use only active Brain memories in the default prompt.',
|
||||
importance=9,
|
||||
confidence=0.97,
|
||||
status='active',
|
||||
origin_source_types=['conversation'],
|
||||
metadata_={'source_count': 1},
|
||||
))
|
||||
session.add(BrainMemory(
|
||||
user_id=user.id,
|
||||
memory_type='project_fact',
|
||||
title='Archived rollout note',
|
||||
content='This archived memory should stay out of the prompt.',
|
||||
importance=10,
|
||||
confidence=0.99,
|
||||
status='archived',
|
||||
origin_source_types=['conversation'],
|
||||
metadata_={'source_count': 1},
|
||||
))
|
||||
session.add(BrainMemory(
|
||||
user_id=user.id,
|
||||
memory_type='project_fact',
|
||||
title='Superseded rollout note',
|
||||
content='This superseded memory should stay out of the prompt.',
|
||||
importance=10,
|
||||
confidence=0.99,
|
||||
status='superseded',
|
||||
origin_source_types=['conversation'],
|
||||
metadata_={'source_count': 1},
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
context = await memory_service.build_memory_context(
|
||||
session,
|
||||
user.id,
|
||||
conversation.id,
|
||||
'rollout note',
|
||||
)
|
||||
|
||||
assert '【知识大脑】' in context
|
||||
assert 'Active rollout note' in context
|
||||
assert 'Archived rollout note' not in context
|
||||
assert 'Superseded rollout note' not in context
|
||||
|
||||
Reference in New Issue
Block a user