fix: tolerate missing tool icons in workflow history API (#33635)

Root cause: node-executions and agent logs called get_tool_icon without the same try/except used in workflow streaming.
This commit is contained in:
themavik 2026-03-23 04:22:32 -04:00
parent 9336935295
commit e01fc9924d
4 changed files with 97 additions and 18 deletions

View File

@ -1012,11 +1012,15 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo
if execution_metadata:
if self.node_type == BuiltinNodeTypes.TOOL and "tool_info" in execution_metadata:
tool_info: dict[str, Any] = execution_metadata["tool_info"]
extras["icon"] = ToolManager.get_tool_icon(
tenant_id=self.tenant_id,
provider_type=tool_info["provider_type"],
provider_id=tool_info["provider_id"],
)
try:
extras["icon"] = ToolManager.get_tool_icon(
tenant_id=self.tenant_id,
provider_type=tool_info["provider_type"],
provider_id=tool_info["provider_id"],
)
except Exception:
# metadata fetch may fail, for example, the plugin daemon is down or plugin is uninstalled.
logger.warning("failed to fetch icon for %s", tool_info.get("provider_id"))
elif self.node_type == BuiltinNodeTypes.DATASOURCE and "datasource_info" in execution_metadata:
datasource_info = execution_metadata["datasource_info"]
extras["icon"] = datasource_info.get("icon")

View File

@ -1,3 +1,4 @@
import logging
import threading
from typing import Any
@ -13,6 +14,8 @@ from libs.login import current_user
from models import Account
from models.model import App, Conversation, EndUser, Message
logger = logging.getLogger(__name__)
class AgentService:
@classmethod
@ -109,19 +112,23 @@ class AgentService:
tool_meta_data = tool_meta.get(tool_name, {})
tool_config = tool_meta_data.get("tool_config", {})
if tool_config.get("tool_provider_type", "") != "dataset-retrieval":
tool_icon = ToolManager.get_tool_icon(
tenant_id=app_model.tenant_id,
provider_type=tool_config.get("tool_provider_type", ""),
provider_id=tool_config.get("tool_provider", ""),
)
if not tool_icon:
tool_entity = find_agent_tool(tool_name)
if tool_entity:
tool_icon = ToolManager.get_tool_icon(
tenant_id=app_model.tenant_id,
provider_type=tool_entity.provider_type,
provider_id=tool_entity.provider_id,
)
tool_icon = ""
try:
tool_icon = ToolManager.get_tool_icon(
tenant_id=app_model.tenant_id,
provider_type=tool_config.get("tool_provider_type", ""),
provider_id=tool_config.get("tool_provider", ""),
)
if not tool_icon:
tool_entity = find_agent_tool(tool_name)
if tool_entity:
tool_icon = ToolManager.get_tool_icon(
tenant_id=app_model.tenant_id,
provider_type=tool_entity.provider_type,
provider_id=tool_entity.provider_id,
)
except Exception:
logger.warning("failed to fetch icon for %s", tool_config.get("tool_provider", tool_name))
else:
tool_icon = ""

View File

@ -5,6 +5,7 @@ from uuid import uuid4
from constants import HIDDEN_VALUE
from core.helper import encrypter
from dify_graph.enums import BuiltinNodeTypes
from dify_graph.file.enums import FileTransferMethod, FileType
from dify_graph.file.models import File
from dify_graph.variables import FloatVariable, IntegerVariable, SecretVariable, StringVariable
@ -190,6 +191,42 @@ class TestWorkflowNodeExecution:
node_exec.execution_metadata = json.dumps(original)
assert node_exec.execution_metadata_dict == original
def test_extras_tool_node_skips_icon_when_fetch_fails(self):
node_exec = WorkflowNodeExecutionModel()
node_exec.tenant_id = "tenant-1"
node_exec.node_type = BuiltinNodeTypes.TOOL
node_exec.execution_metadata = json.dumps(
{
"tool_info": {
"provider_type": "plugin",
"provider_id": "missing-provider",
}
}
)
with mock.patch(
"core.tools.tool_manager.ToolManager.get_tool_icon",
side_effect=ValueError("provider not found"),
):
assert node_exec.extras == {}
def test_extras_tool_node_includes_icon_when_fetch_succeeds(self):
node_exec = WorkflowNodeExecutionModel()
node_exec.tenant_id = "tenant-1"
node_exec.node_type = BuiltinNodeTypes.TOOL
node_exec.execution_metadata = json.dumps(
{
"tool_info": {
"provider_type": "plugin",
"provider_id": "p1",
}
}
)
with mock.patch(
"core.tools.tool_manager.ToolManager.get_tool_icon",
return_value="https://example.com/icon.png",
):
assert node_exec.extras == {"icon": "https://example.com/icon.png"}
class TestIsSystemVariableEditable:
def test_is_system_variable(self):

View File

@ -222,6 +222,37 @@ class TestAgentServiceGetAgentLogs:
assert tool_calls[1]["tool_icon"] == ""
mock_convert.assert_called_once()
def test_get_agent_logs_should_omit_tool_icon_when_fetch_raises(self) -> None:
"""Uninstalled plugin or daemon down must not break agent log API."""
agent_thought = _make_agent_thought()
message = _make_message([agent_thought])
conversation = _make_conversation(from_end_user_id="end-user-1", from_account_id=None)
executor = MagicMock(spec=EndUser)
executor.name = "End User"
app_model_config = MagicMock()
app_model_config.agent_mode_dict = {"strategy": "react"}
app_model_config.to_dict.return_value = {"tools": []}
app_model = _make_app_model(app_model_config)
current_user = _make_current_user_account()
agent_config = MagicMock()
agent_config.tools = []
with (
patch("services.agent_service.db") as mock_db,
patch("services.agent_service.AgentConfigManager.convert", return_value=agent_config),
patch(
"services.agent_service.ToolManager.get_tool_icon",
side_effect=RuntimeError("plugin daemon unavailable"),
),
patch("services.agent_service.current_user", current_user),
):
mock_db.session.query.side_effect = _build_query_side_effect(conversation, message, executor)
result = AgentService.get_agent_logs(app_model, conversation.id, message.id)
tool_calls = result["iterations"][0]["tool_calls"]
assert tool_calls[0]["tool_icon"] == ""
assert tool_calls[1]["tool_icon"] == ""
def test_get_agent_logs_should_return_account_executor_when_no_end_user(self) -> None:
"""Test agent logs fall back to account executor when end user is missing."""
# Arrange