From f8976ccddf2909d0d7b91f679f6445d8b3ac57ec Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Sat, 22 Aug 2026 07:29:22 +0000 Subject: [PATCH] fix: guard add_tool_field against an orphaned tool_id ToolInstance.tool_id carries no foreign key, so a ToolInstance can outlive the ToolInfo row it points to. add_tool_field's query.first() then returns None, and the next line dereferences tool.params, crashing agent execution with AttributeError: 'NoneType' object has no attribute 'params'. Return None for a missing tool and skip it in search_tools_for_sub_agent's caller loop. Also default tool.params to an empty list for a tool that legitimately has none configured. --- backend/database/tool_db.py | 9 +++- test/backend/database/test_tool_db.py | 71 +++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/backend/database/tool_db.py b/backend/database/tool_db.py index f7d1d0aef..f570f25cf 100644 --- a/backend/database/tool_db.py +++ b/backend/database/tool_db.py @@ -365,8 +365,13 @@ def add_tool_field(tool_info): query = session.query(ToolInfo).filter( ToolInfo.tool_id == tool_info["tool_id"]) tool = query.first() + if tool is None: + # tool_id has no FK constraint, so a stale ToolInstance can outlive its ToolInfo row + logger.warning("add_tool_field: no ToolInfo found for tool_id=%s, skipping", + tool_info.get("tool_id")) + return None # add tool params - tool_params = tool.params + tool_params = tool.params or [] for ele in tool_params: param_name = ele["name"] instance_value = tool_info["params"].get(param_name) @@ -440,6 +445,8 @@ def search_tools_for_sub_agent(agent_id, tenant_id, version_no: int = 0): for tool_instance in tool_instances: tool_instance_dict = as_dict(tool_instance) new_tool_instance_dict = add_tool_field(tool_instance_dict) + if new_tool_instance_dict is None: + continue tools_list.append(new_tool_instance_dict) return tools_list diff --git a/test/backend/database/test_tool_db.py b/test/backend/database/test_tool_db.py index ce2a1425a..d3a6da7bf 100644 --- a/test/backend/database/test_tool_db.py +++ b/test/backend/database/test_tool_db.py @@ -1182,6 +1182,77 @@ def test_add_tool_field(monkeypatch, mock_session): assert result["source"] == "test_source" +def test_add_tool_field_missing_tool_info_returns_none(monkeypatch, mock_session): + """A ToolInstance whose tool_id has no matching ToolInfo row must not crash.""" + session, query = mock_session + + mock_first = MagicMock(return_value=None) + mock_filter = MagicMock() + mock_filter.first = mock_first + query.filter.return_value = mock_filter + + mock_ctx = MagicMock() + mock_ctx.__enter__.return_value = session + mock_ctx.__exit__.return_value = None + monkeypatch.setattr( + "backend.database.tool_db.get_db_session", lambda: mock_ctx) + + tool_info = {"tool_id": 999, "params": {}} + result = add_tool_field(tool_info) + + assert result is None + + +def test_add_tool_field_null_params_defaults_to_empty_list(monkeypatch, mock_session): + """A ToolInfo row with params=NULL must not crash the default-value merge loop.""" + session, query = mock_session + mock_tool_info = MockToolInfo() + mock_tool_info.params = None + + mock_first = MagicMock(return_value=mock_tool_info) + mock_filter = MagicMock() + mock_filter.first = mock_first + query.filter.return_value = mock_filter + + mock_ctx = MagicMock() + mock_ctx.__enter__.return_value = session + mock_ctx.__exit__.return_value = None + monkeypatch.setattr( + "backend.database.tool_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.tool_db.as_dict", + lambda obj: obj.__dict__) + + tool_info = {"tool_id": 1, "params": {"param1": "value1"}} + result = add_tool_field(tool_info) + + assert result["params"] == [] + + +def test_search_tools_for_sub_agent_skips_orphaned_tool(monkeypatch, mock_session): + """An orphaned ToolInstance (add_tool_field returns None) must be skipped, not appended.""" + session, query = mock_session + mock_tool_instance = MockToolInstance() + + mock_all = MagicMock(return_value=[mock_tool_instance]) + mock_filter = MagicMock() + mock_filter.all = mock_all + query.filter.return_value = mock_filter + + mock_ctx = MagicMock() + mock_ctx.__enter__.return_value = session + mock_ctx.__exit__.return_value = None + monkeypatch.setattr( + "backend.database.tool_db.get_db_session", lambda: mock_ctx) + monkeypatch.setattr("backend.database.tool_db.as_dict", + lambda obj: obj.__dict__) + monkeypatch.setattr( + "backend.database.tool_db.add_tool_field", lambda data: None) + + result = search_tools_for_sub_agent(1, "tenant1") + + assert result == [] + + def test_search_tools_for_sub_agent(monkeypatch, mock_session): """Test searching tools for sub-agent""" session, query = mock_session