Skip to content

Commit 8eae4ed

Browse files
authored
🐛 Bugfix: Guard memory_user_config against None in store/search memory tools (#3359)
* 🐛 Bugfix: Guard memory_user_config against None in store/search memory tools Add None checks before accessing memory_user_config attributes in StoreMemoryTool and SearchMemoryTool. When config is unavailable, apply conservative default (agent_share_option='never') to prevent unintended cross-agent memory sharing. * fix(memory): restore hidden tabs and hide Tenant Shared in speed mode - Fix nested array syntax bug that hid Tenant Shared and Agent Shared tabs - Conditionally hide Tenant Shared tab when isSpeedMode=true (no tenant concept) - All 5 tabs now properly visible in non-speed mode - 4 tabs visible in speed mode (Tenant Shared hidden) * test: add coverage for memory None guard, duplicate cleanup, and system tool filtering - test_store_memory_tool: add None config, _run_coroutine tests - test_search_memory_tool: add None config, _run_coroutine tests - test_memory_config_service: add duplicate record cleanup tests - test_tool_configuration_service: add SYSTEM_MANAGED_TOOL_NAMES filter test
1 parent e8666ec commit 8eae4ed

11 files changed

Lines changed: 247 additions & 46 deletions

File tree

backend/agents/create_agent_info.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,9 @@ async def create_agent_config(
762762
"agent_id": memory_context.agent_id,
763763
}
764764

765+
memory_tool_names = {"store_memory", "search_memory"}
766+
tool_list = [t for t in tool_list if t.name not in memory_tool_names]
767+
765768
store_tool_config = ToolConfig(
766769
class_name="StoreMemoryTool",
767770
name="store_memory",

backend/consts/tool_labels.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,5 @@
5252
BUILTIN_LABEL_MAP.update(_category_email)
5353
BUILTIN_LABEL_MAP.update(_category_memory)
5454
BUILTIN_LABEL_MAP.update(_category_terminal)
55+
56+
SYSTEM_MANAGED_TOOL_NAMES = frozenset({"store_memory", "search_memory"})

backend/services/memory_config_service.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,17 @@ def _update_single_config(user_id: str, config_key: str, config_value: str) -> b
9494
f"update_config_by_id failed, user_id={user_id}, key={config_key}, value={config_value}"
9595
)
9696
return False
97+
98+
# Handle duplicate records: delete all except the first one
99+
if len(record_list) > 1:
100+
logger.warning(
101+
f"Found {len(record_list)} duplicate records for user_id={user_id}, key={config_key}. Cleaning up..."
102+
)
103+
for duplicate in record_list[1:]:
104+
dup_id = duplicate["config_id"]
105+
delete_result = delete_config_by_config_id(dup_id, updated_by=user_id)
106+
if not delete_result:
107+
logger.error(f"Failed to delete duplicate config_id={dup_id}")
97108
return True
98109

99110

backend/services/tool_configuration_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from consts.const import DATA_PROCESS_SERVICE, LOCAL_MCP_SERVER, MCP_MANAGEMENT_API
1616
from consts.exceptions import MCPConnectionError, NotFoundException, ToolExecutionException
1717
from consts.model import ToolInstanceInfoRequest, ToolInfo, ToolSourceEnum, ToolValidateRequest
18+
from consts.tool_labels import SYSTEM_MANAGED_TOOL_NAMES
1819
from database.outer_api_tool_db import (
1920
upsert_openapi_service,
2021
query_openapi_services_by_tenant,
@@ -506,6 +507,9 @@ async def list_all_tools(tenant_id: str, labels: Optional[List[str]] = None):
506507
for tool in tools_info:
507508
tool_name = tool.get("name")
508509

510+
if tool_name in SYSTEM_MANAGED_TOOL_NAMES:
511+
continue
512+
509513
# Always use SDK inputs for local tools to stay in sync with current tool code
510514
is_local = tool.get("source") == "local"
511515
sdk_info = local_tool_descriptions.get(tool_name) if is_local else None

frontend/app/[locale]/memory/page.tsx

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -297,32 +297,30 @@ export default function MemoryContent() {
297297
),
298298
children: renderBaseSettings(),
299299
},
300-
[
301-
{
302-
key: MEMORY_TAB_KEYS.TENANT,
303-
label: (
304-
<span className="inline-flex items-center gap-2">
305-
<UsersRound className="size-4" />
306-
{t("memoryManageModal.tenantShareTab")}
307-
</span>
308-
),
309-
children: renderSingleList(memory.tenantSharedGroup),
310-
disabled: !memory.memoryEnabled,
311-
},
312-
{
313-
key: MEMORY_TAB_KEYS.AGENT_SHARED,
314-
label: (
315-
<span className="inline-flex items-center gap-2">
316-
<Share2 className="size-4" />
317-
{t("memoryManageModal.agentShareTab")}
318-
</span>
319-
),
320-
children: renderMemoryWithMenu(memory.agentSharedGroups, true),
321-
disabled:
322-
!memory.memoryEnabled ||
323-
memory.shareOption === MEMORY_SHARE_STRATEGY.NEVER,
324-
},
325-
],
300+
...(!isSpeedMode ? [{
301+
key: MEMORY_TAB_KEYS.TENANT,
302+
label: (
303+
<span className="inline-flex items-center gap-2">
304+
<UsersRound className="size-4" />
305+
{t("memoryManageModal.tenantShareTab")}
306+
</span>
307+
),
308+
children: renderSingleList(memory.tenantSharedGroup),
309+
disabled: !memory.memoryEnabled,
310+
}] : []),
311+
{
312+
key: MEMORY_TAB_KEYS.AGENT_SHARED,
313+
label: (
314+
<span className="inline-flex items-center gap-2">
315+
<Share2 className="size-4" />
316+
{t("memoryManageModal.agentShareTab")}
317+
</span>
318+
),
319+
children: renderMemoryWithMenu(memory.agentSharedGroups, true),
320+
disabled:
321+
!memory.memoryEnabled ||
322+
memory.shareOption === MEMORY_SHARE_STRATEGY.NEVER,
323+
},
326324
{
327325
key: MEMORY_TAB_KEYS.USER_PERSONAL,
328326
label: (

sdk/nexent/core/tools/search_memory_tool.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import concurrent.futures
23
import logging
34
from typing import Any
45

@@ -11,6 +12,15 @@
1112
logger = logging.getLogger("search_memory_tool")
1213

1314

15+
def _run_coroutine(coro):
16+
try:
17+
asyncio.get_running_loop()
18+
except RuntimeError:
19+
return asyncio.run(coro)
20+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
21+
return pool.submit(asyncio.run, coro).result()
22+
23+
1424
class SearchMemoryTool(Tool):
1525
name = "search_memory"
1626
description = (
@@ -63,25 +73,34 @@ def __init__(
6373
self.running_prompt_en = "Searching memory..."
6474
self.running_prompt_zh = "搜索记忆中..."
6575

76+
def _resolve_memory_levels(self) -> list[str]:
77+
"""Determine which memory levels to search based on user preferences.
78+
79+
Conservative default: when config is unavailable, assume "never"
80+
(no agent-level sharing) to protect user privacy.
81+
"""
82+
levels = ["tenant", "user", "agent", "user_agent"]
83+
if not self.memory_user_config or self.memory_user_config.agent_share_option == "never":
84+
levels.remove("agent")
85+
if self.memory_user_config and self.agent_id in getattr(self.memory_user_config, "disable_agent_ids", []):
86+
if "agent" in levels:
87+
levels.remove("agent")
88+
if self.memory_user_config and self.agent_id in getattr(self.memory_user_config, "disable_user_agent_ids", []):
89+
if "user_agent" in levels:
90+
levels.remove("user_agent")
91+
return levels
92+
6693
def forward(self, query: str, top_k: int = 5) -> str:
6794
logger.info(f"[ACTIVE MEMORY] SearchMemoryTool invoked: query={query[:200]}, top_k={top_k}, user_id={self.user_id}, agent_id={self.agent_id}")
6895
if self.observer:
6996
running_prompt = self.running_prompt_zh if self.observer.lang == "zh" else self.running_prompt_en
7097
self.observer.add_message("", ProcessType.TOOL, running_prompt)
7198

72-
memory_levels = ["tenant", "user", "agent", "user_agent"]
73-
if self.memory_user_config.agent_share_option == "never":
74-
memory_levels.remove("agent")
75-
if self.agent_id in getattr(self.memory_user_config, "disable_agent_ids", []):
76-
if "agent" in memory_levels:
77-
memory_levels.remove("agent")
78-
if self.agent_id in getattr(self.memory_user_config, "disable_user_agent_ids", []):
79-
if "user_agent" in memory_levels:
80-
memory_levels.remove("user_agent")
99+
memory_levels = self._resolve_memory_levels()
81100

82101
try:
83102
from ...memory.memory_service import search_memory_in_levels
84-
result = asyncio.run(search_memory_in_levels(
103+
result = _run_coroutine(search_memory_in_levels(
85104
query_text=query,
86105
memory_config=self.memory_config,
87106
tenant_id=self.tenant_id,

sdk/nexent/core/tools/store_memory_tool.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import concurrent.futures
23
import logging
34
from typing import Any
45

@@ -11,6 +12,15 @@
1112
logger = logging.getLogger("store_memory_tool")
1213

1314

15+
def _run_coroutine(coro):
16+
try:
17+
asyncio.get_running_loop()
18+
except RuntimeError:
19+
return asyncio.run(coro)
20+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
21+
return pool.submit(asyncio.run, coro).result()
22+
23+
1424
class StoreMemoryTool(Tool):
1525
name = "store_memory"
1626
description = (
@@ -58,6 +68,21 @@ def __init__(
5868
self.running_prompt_en = "Saving to memory..."
5969
self.running_prompt_zh = "保存到记忆中..."
6070

71+
def _resolve_memory_levels(self) -> list[str]:
72+
"""Determine which memory levels to write to based on user preferences.
73+
74+
Conservative default: when config is unavailable, assume "never"
75+
(no agent-level sharing) to protect user privacy.
76+
"""
77+
levels = ["user_agent", "agent"]
78+
if not self.memory_user_config or self.memory_user_config.agent_share_option == "never":
79+
levels.remove("agent")
80+
if self.memory_user_config and self.agent_id in getattr(self.memory_user_config, "disable_user_agent_ids", []):
81+
levels = [l for l in levels if l != "user_agent"]
82+
if self.memory_user_config and self.agent_id in getattr(self.memory_user_config, "disable_agent_ids", []):
83+
levels = [l for l in levels if l != "agent"]
84+
return levels
85+
6186
def forward(self, content: str) -> str:
6287
logger.info(f"[ACTIVE MEMORY] StoreMemoryTool invoked: content={content[:200]}, user_id={self.user_id}, agent_id={self.agent_id}, store_count={self.store_count}/{self.max_stores_per_run}")
6388
if self.observer:
@@ -67,19 +92,13 @@ def forward(self, content: str) -> str:
6792
if self.store_count >= self.max_stores_per_run:
6893
return "Memory storage limit reached for this conversation. Information will be saved automatically at the end."
6994

70-
levels = ["user_agent", "agent"]
71-
if self.memory_user_config.agent_share_option == "never":
72-
levels.remove("agent")
73-
if self.agent_id in getattr(self.memory_user_config, "disable_user_agent_ids", []):
74-
levels = [l for l in levels if l != "user_agent"]
75-
if self.agent_id in getattr(self.memory_user_config, "disable_agent_ids", []):
76-
levels = [l for l in levels if l != "agent"]
95+
levels = self._resolve_memory_levels()
7796
if not levels:
7897
return "No memory levels available (all disabled by user preferences)."
7998

8099
try:
81100
from ...memory.memory_service import add_memory_in_levels
82-
result = asyncio.run(add_memory_in_levels(
101+
result = _run_coroutine(add_memory_in_levels(
83102
messages=[{"role": "user", "content": content}],
84103
memory_config=self.memory_config,
85104
tenant_id=self.tenant_id,

test/backend/services/test_memory_config_service.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,38 @@ def test_update_single_config_insert_branch_fail(self, m_get_info, m_insert):
188188
ok = _update_single_config(self.user_id, "MEMORY_SWITCH", "Y")
189189
self.assertFalse(ok)
190190

191+
@patch("backend.services.memory_config_service.delete_config_by_config_id", return_value=True)
192+
@patch("backend.services.memory_config_service.update_config_by_id", return_value=True)
193+
@patch("backend.services.memory_config_service.get_memory_config_info")
194+
def test_update_single_config_cleans_up_duplicates(self, m_get_info, m_update, m_del):
195+
m_get_info.return_value = [
196+
{"config_id": 10, "config_value": "N"},
197+
{"config_id": 20, "config_value": "Y"},
198+
{"config_id": 30, "config_value": "N"},
199+
]
200+
from backend.services.memory_config_service import _update_single_config
201+
202+
ok = _update_single_config(self.user_id, "MEMORY_SWITCH", "Y")
203+
self.assertTrue(ok)
204+
m_update.assert_called_once()
205+
self.assertEqual(m_del.call_count, 2)
206+
m_del.assert_any_call(20, updated_by=self.user_id)
207+
m_del.assert_any_call(30, updated_by=self.user_id)
208+
209+
@patch("backend.services.memory_config_service.delete_config_by_config_id", return_value=False)
210+
@patch("backend.services.memory_config_service.update_config_by_id", return_value=True)
211+
@patch("backend.services.memory_config_service.get_memory_config_info")
212+
def test_update_single_config_duplicate_cleanup_failure_logged(self, m_get_info, m_update, m_del):
213+
m_get_info.return_value = [
214+
{"config_id": 10, "config_value": "N"},
215+
{"config_id": 20, "config_value": "Y"},
216+
]
217+
from backend.services.memory_config_service import _update_single_config
218+
219+
ok = _update_single_config(self.user_id, "MEMORY_SWITCH", "Y")
220+
self.assertTrue(ok)
221+
m_del.assert_called_once_with(20, updated_by=self.user_id)
222+
191223
# ------------------------------ _add_multi_value ------------------------------
192224
@patch("backend.services.memory_config_service.insert_config", return_value=True)
193225
@patch("backend.services.memory_config_service.get_memory_config_info", return_value=[])

test/backend/services/test_tool_configuration_service.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,6 +1105,32 @@ async def test_list_all_tools_with_labels(self, mock_query_by_labels, mock_descr
11051105
assert result[0]["tool_id"] == 2
11061106
mock_query_by_labels.assert_called_once_with("tenant1", ["database", "file"])
11071107

1108+
@patch('backend.services.tool_configuration_service.get_local_tools_description_zh')
1109+
@patch('backend.services.tool_configuration_service.query_all_tools')
1110+
async def test_list_all_tools_filters_system_managed_tools(self, mock_query, mock_descriptions):
1111+
"""list_all_tools filters out tools in SYSTEM_MANAGED_TOOL_NAMES."""
1112+
mock_query.return_value = [
1113+
{"tool_id": 1, "name": "tavily_search", "description": "d1", "source": "local",
1114+
"params": [], "inputs": "{}", "is_available": True, "create_time": "", "usage": ""},
1115+
{"tool_id": 2, "name": "store_memory", "description": "d2", "source": "local",
1116+
"params": [], "inputs": "{}", "is_available": True, "create_time": "", "usage": ""},
1117+
{"tool_id": 3, "name": "search_memory", "description": "d3", "source": "local",
1118+
"params": [], "inputs": "{}", "is_available": True, "create_time": "", "usage": ""},
1119+
{"tool_id": 4, "name": "postgres_database", "description": "d4", "source": "local",
1120+
"params": [], "inputs": "{}", "is_available": True, "create_time": "", "usage": ""},
1121+
]
1122+
mock_descriptions.return_value = {}
1123+
1124+
from backend.services.tool_configuration_service import list_all_tools
1125+
result = await list_all_tools("tenant1")
1126+
1127+
result_names = [t["name"] for t in result]
1128+
assert "store_memory" not in result_names
1129+
assert "search_memory" not in result_names
1130+
assert "tavily_search" in result_names
1131+
assert "postgres_database" in result_names
1132+
assert len(result) == 2
1133+
11081134

11091135
# test the fixture and helper function
11101136
@pytest.fixture

test/sdk/core/tools/test_search_memory_tool.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from unittest.mock import MagicMock, patch, AsyncMock
33

44
from sdk.nexent.core.utils.observer import MessageObserver, ProcessType
5-
from sdk.nexent.core.tools.search_memory_tool import SearchMemoryTool
5+
from sdk.nexent.core.tools.search_memory_tool import SearchMemoryTool, _run_coroutine
66

77

88
@pytest.fixture
@@ -207,3 +207,47 @@ def test_forward_exception_returns_friendly_error(search_memory_tool):
207207
assert "Memory search failed" in result
208208
assert "Elasticsearch timeout" in result
209209
assert "Continuing without memory results" in result
210+
211+
212+
def test_levels_none_config_conservative_default(mock_observer):
213+
tool = SearchMemoryTool(
214+
memory_config={"test": "config"},
215+
tenant_id="tenant_1",
216+
user_id="user_1",
217+
agent_id="agent_1",
218+
memory_user_config=None,
219+
observer=mock_observer,
220+
)
221+
222+
with patch(
223+
"sdk.nexent.memory.memory_service.search_memory_in_levels",
224+
new_callable=AsyncMock,
225+
return_value={"results": []},
226+
) as mock_search:
227+
tool.forward("query")
228+
229+
call_kwargs = mock_search.call_args[1]
230+
assert "agent" not in call_kwargs["memory_levels"]
231+
assert "tenant" in call_kwargs["memory_levels"]
232+
assert "user" in call_kwargs["memory_levels"]
233+
assert "user_agent" in call_kwargs["memory_levels"]
234+
235+
236+
def test_run_coroutine_no_running_loop():
237+
async def sample_coro():
238+
return "result"
239+
240+
result = _run_coroutine(sample_coro())
241+
assert result == "result"
242+
243+
244+
def test_run_coroutine_with_running_loop():
245+
async def sample_coro():
246+
return "result"
247+
248+
async def run_with_loop():
249+
return _run_coroutine(sample_coro())
250+
251+
import asyncio
252+
result = asyncio.run(run_with_loop())
253+
assert result == "result"

0 commit comments

Comments
 (0)