From 2d525e0ac05deb9e4b97456151826e1157816aec Mon Sep 17 00:00:00 2001 From: yujiosaka Date: Thu, 25 Dec 2025 23:30:18 +0900 Subject: [PATCH 1/4] fix: recognize attached files in agent node query parameter Closes #28140 --- api/core/workflow/nodes/agent/agent_node.py | 75 +++++- api/core/workflow/nodes/agent/entities.py | 22 +- .../core/workflow/nodes/agent/__init__.py | 0 .../workflow/nodes/agent/test_agent_node.py | 220 ++++++++++++++++++ .../components/workflow/nodes/agent/panel.tsx | 16 +- .../components/workflow/nodes/agent/types.ts | 8 +- .../workflow/nodes/agent/use-config.ts | 32 ++- web/i18n/ar-TN/workflow.ts | 1 + web/i18n/de-DE/workflow.ts | 1 + web/i18n/en-US/workflow.ts | 1 + web/i18n/es-ES/workflow.ts | 1 + web/i18n/fa-IR/workflow.ts | 1 + web/i18n/fr-FR/workflow.ts | 1 + web/i18n/hi-IN/workflow.ts | 1 + web/i18n/id-ID/workflow.ts | 1 + web/i18n/it-IT/workflow.ts | 1 + web/i18n/ja-JP/workflow.ts | 1 + web/i18n/ko-KR/workflow.ts | 1 + web/i18n/pl-PL/workflow.ts | 1 + web/i18n/pt-BR/workflow.ts | 1 + web/i18n/ro-RO/workflow.ts | 1 + web/i18n/ru-RU/workflow.ts | 1 + web/i18n/sl-SI/workflow.ts | 1 + web/i18n/th-TH/workflow.ts | 1 + web/i18n/tr-TR/workflow.ts | 1 + web/i18n/uk-UA/workflow.ts | 1 + web/i18n/vi-VN/workflow.ts | 1 + web/i18n/zh-Hans/workflow.ts | 1 + web/i18n/zh-Hant/workflow.ts | 1 + 29 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 api/tests/unit_tests/core/workflow/nodes/agent/__init__.py create mode 100644 api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index 4be006de11..ac595a8928 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -9,10 +9,11 @@ from sqlalchemy.orm import Session from core.agent.entities import AgentToolEntity from core.agent.plugin_entities import AgentStrategyParameter -from core.file import File, FileTransferMethod +from core.file import File, FileTransferMethod, FileType, file_manager from core.memory.token_buffer_memory import TokenBufferMemory from core.model_manager import ModelInstance, ModelManager from core.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetadata +from core.model_runtime.entities.message_entities import TextPromptMessageContent from core.model_runtime.entities.model_entities import AIModelEntity, ModelType from core.model_runtime.utils.encoders import jsonable_encoder from core.provider_manager import ProviderManager @@ -24,7 +25,7 @@ from core.tools.entities.tool_entities import ( ) from core.tools.tool_manager import ToolManager from core.tools.utils.message_transformer import ToolFileMessageTransformer -from core.variables.segments import ArrayFileSegment, StringSegment +from core.variables.segments import ArrayFileSegment, FileSegment, StringSegment from core.workflow.enums import ( NodeType, SystemVariableKey, @@ -160,6 +161,22 @@ class AgentNode(Node[AgentNodeData]): ) ) + def _fetch_files_from_variable_selector( + self, + *, + variable_pool: VariablePool, + selector: Sequence[str], + ) -> Sequence[File]: + """Fetch files from a variable selector.""" + variable = variable_pool.get(list(selector)) + if variable is None: + return [] + elif isinstance(variable, FileSegment): + return [variable.value] + elif isinstance(variable, ArrayFileSegment): + return variable.value + return [] + def _generate_agent_parameters( self, *, @@ -206,11 +223,61 @@ class AgentNode(Node[AgentNodeData]): except TypeError: parameter_value = str(agent_input.value) segment_group = variable_pool.convert_template(parameter_value) - parameter_value = segment_group.log if for_log else segment_group.text + + if parameter_name in ("query", "instruction") and not for_log: + contents: list[dict[str, Any]] = [] + has_file = False + vision_detail = ( + node_data.vision.configs.detail if node_data.vision.enabled else None + ) + + for segment in segment_group.value: + if isinstance(segment, ArrayFileSegment): + for file in segment.value: + if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: + file_content = file_manager.to_prompt_message_content( + file, image_detail_config=vision_detail + ) + contents.append(file_content.model_dump()) + has_file = True + elif isinstance(segment, FileSegment): + file = segment.value + if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: + file_content = file_manager.to_prompt_message_content( + file, image_detail_config=vision_detail + ) + contents.append(file_content.model_dump()) + has_file = True + else: + text = segment.text + if text: + contents.append(TextPromptMessageContent(data=text).model_dump()) + + if parameter_name == "query": + if node_data.vision.enabled and node_data.vision.configs.variable_selector: + vision_files = self._fetch_files_from_variable_selector( + variable_pool=variable_pool, + selector=node_data.vision.configs.variable_selector, + ) + for file in vision_files: + if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: + file_content = file_manager.to_prompt_message_content( + file, image_detail_config=vision_detail + ) + contents.append(file_content.model_dump()) + has_file = True + + if has_file: + parameter_value = contents + else: + parameter_value = segment_group.text + else: + parameter_value = segment_group.log if for_log else segment_group.text + # variable_pool.convert_template returns a string, # so we need to convert it back to a dictionary try: - if not isinstance(agent_input.value, str): + if not isinstance(agent_input.value, str) and isinstance(parameter_value, str): parameter_value = json.loads(parameter_value) except json.JSONDecodeError: parameter_value = parameter_value diff --git a/api/core/workflow/nodes/agent/entities.py b/api/core/workflow/nodes/agent/entities.py index 985ee5eef2..9d5e507548 100644 --- a/api/core/workflow/nodes/agent/entities.py +++ b/api/core/workflow/nodes/agent/entities.py @@ -1,18 +1,38 @@ +from collections.abc import Sequence from enum import IntEnum, StrEnum, auto from typing import Any, Literal, Union -from pydantic import BaseModel +from pydantic import BaseModel, Field, field_validator +from core.model_runtime.entities import ImagePromptMessageContent from core.prompt.entities.advanced_prompt_entities import MemoryConfig from core.tools.entities.tool_entities import ToolSelector from core.workflow.nodes.base.entities import BaseNodeData +class VisionConfigOptions(BaseModel): + variable_selector: Sequence[str] = Field(default_factory=lambda: ["sys", "files"]) + detail: ImagePromptMessageContent.DETAIL = ImagePromptMessageContent.DETAIL.HIGH + + +class VisionConfig(BaseModel): + enabled: bool = False + configs: VisionConfigOptions = Field(default_factory=VisionConfigOptions) + + @field_validator("configs", mode="before") + @classmethod + def convert_none_configs(cls, v: Any): + if v is None: + return VisionConfigOptions() + return v + + class AgentNodeData(BaseNodeData): agent_strategy_provider_name: str # redundancy agent_strategy_name: str agent_strategy_label: str # redundancy memory: MemoryConfig | None = None + vision: VisionConfig = Field(default_factory=VisionConfig) # The version of the tool parameter. # If this value is None, it indicates this is a previous version # and requires using the legacy parameter parsing rules. diff --git a/api/tests/unit_tests/core/workflow/nodes/agent/__init__.py b/api/tests/unit_tests/core/workflow/nodes/agent/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py new file mode 100644 index 0000000000..8ca1f93734 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py @@ -0,0 +1,220 @@ +"""Unit tests for AgentNode file handling.""" + +from unittest.mock import patch + +import pytest + +from core.file import File, FileTransferMethod, FileType +from core.model_runtime.entities.message_entities import ( + ImagePromptMessageContent, + TextPromptMessageContent, +) +from core.variables import ArrayFileSegment, FileSegment, StringSegment +from core.variables.segment_group import SegmentGroup + + +class TestAgentNodeFileHandling: + """Tests for file handling in query, instruction, and vision variable selector.""" + + @pytest.fixture + def mock_file(self) -> File: + """Create a mock file.""" + return File( + id="test-file-id", + tenant_id="test-tenant", + type=FileType.IMAGE, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="test-related-id", + filename="test.png", + extension=".png", + mime_type="image/png", + size=1024, + ) + + @pytest.fixture + def mock_custom_file(self) -> File: + """Create a mock custom (unsupported) file.""" + return File( + id="test-custom-id", + tenant_id="test-tenant", + type=FileType.CUSTOM, + transfer_method=FileTransferMethod.LOCAL_FILE, + related_id="test-related-id", + filename="test.zip", + extension=".zip", + mime_type="application/zip", + size=4096, + ) + + def test_query_with_text_only_returns_string(self): + """When query contains only text, it should return a string.""" + segment_group = SegmentGroup(value=[StringSegment(value="Hello, world!")]) + + contents: list[dict] = [] + has_file = False + for segment in segment_group.value: + if not isinstance(segment, (ArrayFileSegment, FileSegment)): + if segment.text: + contents.append(TextPromptMessageContent(data=segment.text).model_dump()) + + result = contents if has_file else segment_group.text + + assert result == "Hello, world!" + assert isinstance(result, str) + + def test_query_with_file_returns_list(self, mock_file): + """When query contains a file, it should return a list.""" + segment_group = SegmentGroup(value=[FileSegment(value=mock_file)]) + + with patch("core.file.file_manager.to_prompt_message_content") as mock_to_content: + mock_to_content.return_value = ImagePromptMessageContent( + url="http://example.com/test.png", mime_type="image/png", format="png" + ) + + contents: list[dict] = [] + has_file = False + for segment in segment_group.value: + if isinstance(segment, FileSegment): + file = segment.value + if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: + from core.file import file_manager + + contents.append(file_manager.to_prompt_message_content(file).model_dump()) + has_file = True + + result = contents if has_file else segment_group.text + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["type"] == "image" + + def test_query_with_text_and_file_returns_list_with_both(self, mock_file): + """When query contains both text and file, it should return a list with both.""" + segment_group = SegmentGroup(value=[ + StringSegment(value="Describe this: "), + FileSegment(value=mock_file), + ]) + + with patch("core.file.file_manager.to_prompt_message_content") as mock_to_content: + mock_to_content.return_value = ImagePromptMessageContent( + url="http://example.com/test.png", mime_type="image/png", format="png" + ) + + contents: list[dict] = [] + has_file = False + for segment in segment_group.value: + if isinstance(segment, FileSegment): + file = segment.value + if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: + from core.file import file_manager + + contents.append(file_manager.to_prompt_message_content(file).model_dump()) + has_file = True + elif segment.text: + contents.append(TextPromptMessageContent(data=segment.text).model_dump()) + + result = contents if has_file else segment_group.text + + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["type"] == "text" + assert result[1]["type"] == "image" + + def test_custom_file_type_is_ignored(self, mock_custom_file): + """Custom file types should be ignored.""" + segment_group = SegmentGroup(value=[FileSegment(value=mock_custom_file)]) + + has_file = False + for segment in segment_group.value: + if isinstance(segment, FileSegment): + if segment.value.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: + has_file = True + + assert has_file is False + + def test_instruction_with_file_returns_list(self, mock_file): + """When instruction contains a file, it should return a list (same as query).""" + segment_group = SegmentGroup(value=[ + StringSegment(value="You are a helpful assistant. "), + FileSegment(value=mock_file), + ]) + + with patch("core.file.file_manager.to_prompt_message_content") as mock_to_content: + mock_to_content.return_value = ImagePromptMessageContent( + url="http://example.com/test.png", mime_type="image/png", format="png" + ) + + contents: list[dict] = [] + has_file = False + for segment in segment_group.value: + if isinstance(segment, FileSegment): + file = segment.value + if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: + from core.file import file_manager + + contents.append(file_manager.to_prompt_message_content(file).model_dump()) + has_file = True + elif segment.text: + contents.append(TextPromptMessageContent(data=segment.text).model_dump()) + + result = contents if has_file else segment_group.text + + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["type"] == "text" + assert result[1]["type"] == "image" + + def test_vision_variable_selector_files_added_to_query(self, mock_file): + """Vision variable selector files should be added to query only.""" + vision_files = [mock_file] + + with patch("core.file.file_manager.to_prompt_message_content") as mock_to_content: + mock_to_content.return_value = ImagePromptMessageContent( + url="http://example.com/test.png", mime_type="image/png", format="png" + ) + + contents: list[dict] = [] + has_file = False + + for file in vision_files: + if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: + from core.file import file_manager + + contents.append(file_manager.to_prompt_message_content(file).model_dump()) + has_file = True + + assert has_file is True + assert len(contents) == 1 + assert contents[0]["type"] == "image" + + def test_query_with_text_and_vision_files(self, mock_file): + """Query text combined with vision variable selector files.""" + segment_group = SegmentGroup(value=[StringSegment(value="Describe this image")]) + vision_files = [mock_file] + + with patch("core.file.file_manager.to_prompt_message_content") as mock_to_content: + mock_to_content.return_value = ImagePromptMessageContent( + url="http://example.com/test.png", mime_type="image/png", format="png" + ) + + contents: list[dict] = [] + has_file = False + + for segment in segment_group.value: + if segment.text: + contents.append(TextPromptMessageContent(data=segment.text).model_dump()) + + for file in vision_files: + if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: + from core.file import file_manager + + contents.append(file_manager.to_prompt_message_content(file).model_dump()) + has_file = True + + result = contents if has_file else segment_group.text + + assert isinstance(result, list) + assert len(result) == 2 + assert result[0]["type"] == "text" + assert result[0]["data"] == "Describe this image" + assert result[1]["type"] == "image" diff --git a/web/app/components/workflow/nodes/agent/panel.tsx b/web/app/components/workflow/nodes/agent/panel.tsx index d0f3f83b99..c81f3ce27e 100644 --- a/web/app/components/workflow/nodes/agent/panel.tsx +++ b/web/app/components/workflow/nodes/agent/panel.tsx @@ -6,8 +6,10 @@ import type { StrategyParamItem } from '@/app/components/plugins/types' import { memo } from 'react' import { useTranslation } from 'react-i18next' import { toType } from '@/app/components/tools/utils/to-form-schema' +import { Resolution } from '@/types/app' import { useStore } from '../../store' import { AgentStrategy } from '../_base/components/agent-strategy' +import ConfigVision from '../_base/components/config-vision' import Field from '../_base/components/field' import MemoryConfig from '../_base/components/memory-config' import OutputVars, { VarItem } from '../_base/components/output-vars' @@ -40,6 +42,8 @@ const AgentPanel: FC> = (props) => { readOnly, outputSchema, handleMemoryChange, + handleVisionEnabledChange, + handleVisionConfigChange, canChooseMCPTool, } = useConfig(props.id, props.data) const { t } = useTranslation() @@ -85,12 +89,11 @@ const AgentPanel: FC> = (props) => { canChooseMCPTool={canChooseMCPTool} /> -
+
{isChatMode && currentStrategy?.features?.includes(AgentFeature.HISTORY_MESSAGES) && ( <> > = (props) => { /> )} +
diff --git a/web/app/components/workflow/nodes/agent/types.ts b/web/app/components/workflow/nodes/agent/types.ts index efc7c0cd9a..6cf1a37c62 100644 --- a/web/app/components/workflow/nodes/agent/types.ts +++ b/web/app/components/workflow/nodes/agent/types.ts @@ -1,6 +1,11 @@ import type { ToolVarInputs } from '../tool/types' import type { PluginMeta } from '@/app/components/plugins/types' -import type { CommonNodeType, Memory } from '@/app/components/workflow/types' +import type { CommonNodeType, Memory, VisionSetting } from '@/app/components/workflow/types' + +export type AgentVisionConfig = { + enabled: boolean + configs?: VisionSetting +} export type AgentNodeType = CommonNodeType & { agent_strategy_provider_name?: string @@ -11,6 +16,7 @@ export type AgentNodeType = CommonNodeType & { output_schema: Record plugin_unique_identifier?: string memory?: Memory + vision?: AgentVisionConfig version?: string tool_node_version?: string } diff --git a/web/app/components/workflow/nodes/agent/use-config.ts b/web/app/components/workflow/nodes/agent/use-config.ts index 49af451f4c..5214bba66c 100644 --- a/web/app/components/workflow/nodes/agent/use-config.ts +++ b/web/app/components/workflow/nodes/agent/use-config.ts @@ -1,4 +1,4 @@ -import type { Memory, Var } from '../../types' +import type { Memory, Var, VisionSetting } from '../../types' import type { ToolVarInputs } from '../tool/types' import type { AgentNodeType } from './types' import { produce } from 'immer' @@ -11,6 +11,7 @@ import { } from '@/app/components/workflow/hooks' import { useCheckInstalled, useFetchPluginsInMarketPlaceByIds } from '@/service/use-plugins' import { useStrategyProviderDetail } from '@/service/use-strategy' +import { Resolution } from '@/types/app' import { isSupportMCP } from '@/utils/plugin-version-feature' import { VarType as VarKindType } from '../../types' import useAvailableVarList from '../_base/hooks/use-available-var-list' @@ -204,7 +205,34 @@ const useConfig = (id: string, payload: AgentNodeType) => { }) setInputs(newInputs) }, [inputs, setInputs]) + const isChatMode = useIsChatMode() + + const handleVisionEnabledChange = useCallback((enabled: boolean) => { + const newInputs = produce(inputs, (draft) => { + if (!draft.vision) { + draft.vision = { enabled: false } + } + draft.vision.enabled = enabled + if (enabled && isChatMode) { + draft.vision.configs = { + detail: Resolution.high, + variable_selector: ['sys', 'files'], + } + } + }) + setInputs(newInputs) + }, [inputs, setInputs, isChatMode]) + + const handleVisionConfigChange = useCallback((config: VisionSetting) => { + const newInputs = produce(inputs, (draft) => { + if (!draft.vision) { + draft.vision = { enabled: true } + } + draft.vision.configs = config + }) + setInputs(newInputs) + }, [inputs, setInputs]) return { readOnly, inputs, @@ -221,6 +249,8 @@ const useConfig = (id: string, payload: AgentNodeType) => { availableNodesWithParent, outputSchema, handleMemoryChange, + handleVisionEnabledChange, + handleVisionConfigChange, isChatMode, canChooseMCPTool: isSupportMCP(inputs.meta?.version), } diff --git a/web/i18n/ar-TN/workflow.ts b/web/i18n/ar-TN/workflow.ts index 005045c4a7..eee12c3bfa 100644 --- a/web/i18n/ar-TN/workflow.ts +++ b/web/i18n/ar-TN/workflow.ts @@ -1024,6 +1024,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'تم إهمال هذا النموذج', }, + vision: 'الرؤية', outputVars: { text: 'محتوى تم إنشاؤه بواسطة الوكيل', usage: 'معلومات استخدام النموذج', diff --git a/web/i18n/de-DE/workflow.ts b/web/i18n/de-DE/workflow.ts index d670d97d5b..a14ee6df1c 100644 --- a/web/i18n/de-DE/workflow.ts +++ b/web/i18n/de-DE/workflow.ts @@ -901,6 +901,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'Dieses Modell ist veraltet', }, + vision: 'Vision', outputVars: { files: { type: 'Art der Unterstützung. Jetzt nur noch Image unterstützen', diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 2122c20aaa..3da5963ac7 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -1024,6 +1024,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'This model is deprecated', }, + vision: 'vision', outputVars: { text: 'agent generated content', usage: 'Model Usage Information', diff --git a/web/i18n/es-ES/workflow.ts b/web/i18n/es-ES/workflow.ts index ab853882f5..7d41ea7c75 100644 --- a/web/i18n/es-ES/workflow.ts +++ b/web/i18n/es-ES/workflow.ts @@ -901,6 +901,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'Este modelo está en desuso', }, + vision: 'visión', outputVars: { files: { url: 'URL de la imagen', diff --git a/web/i18n/fa-IR/workflow.ts b/web/i18n/fa-IR/workflow.ts index a2a83acdfb..41f5a23f0e 100644 --- a/web/i18n/fa-IR/workflow.ts +++ b/web/i18n/fa-IR/workflow.ts @@ -901,6 +901,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'این مدل منسوخ شده است', }, + vision: 'بینایی', outputVars: { files: { transfer_method: 'روش انتقال. ارزش remote_url یا local_file', diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index e22385c17e..d429b05cf7 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -901,6 +901,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'Ce modèle est obsolète', }, + vision: 'vision', outputVars: { files: { title: 'Fichiers générés par l’agent', diff --git a/web/i18n/hi-IN/workflow.ts b/web/i18n/hi-IN/workflow.ts index 9f5c9828cd..818dbab4c9 100644 --- a/web/i18n/hi-IN/workflow.ts +++ b/web/i18n/hi-IN/workflow.ts @@ -921,6 +921,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'यह मॉडल अप्रचलित है।', }, + vision: 'दृष्टि', outputVars: { files: { transfer_method: 'स्थानांतरण विधि। मान या तो remote_url है या local_file।', diff --git a/web/i18n/id-ID/workflow.ts b/web/i18n/id-ID/workflow.ts index 887650ec2f..6f9eb2bdd7 100644 --- a/web/i18n/id-ID/workflow.ts +++ b/web/i18n/id-ID/workflow.ts @@ -940,6 +940,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'Model ini tidak digunakan lagi', }, + vision: 'penglihatan', outputVars: { files: { transfer_method: 'Metode transfer. Nilai adalah remote_url atau local_file', diff --git a/web/i18n/it-IT/workflow.ts b/web/i18n/it-IT/workflow.ts index 33aef2cea0..fbb36a2876 100644 --- a/web/i18n/it-IT/workflow.ts +++ b/web/i18n/it-IT/workflow.ts @@ -927,6 +927,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'Questo modello è deprecato', }, + vision: 'vision', outputVars: { files: { type: 'Tipo di supporto. Ora supporta solo l\'immagine', diff --git a/web/i18n/ja-JP/workflow.ts b/web/i18n/ja-JP/workflow.ts index 24f05d6c31..20904de535 100644 --- a/web/i18n/ja-JP/workflow.ts +++ b/web/i18n/ja-JP/workflow.ts @@ -960,6 +960,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'このモデルは廃止されました', }, + vision: 'ビジョン', outputVars: { files: { url: '画像の URL', diff --git a/web/i18n/ko-KR/workflow.ts b/web/i18n/ko-KR/workflow.ts index 964f331c85..c41fee999b 100644 --- a/web/i18n/ko-KR/workflow.ts +++ b/web/i18n/ko-KR/workflow.ts @@ -943,6 +943,7 @@ const translation = { modelSelectorTooltips: { deprecated: '이 모델은 더 이상 사용되지 않습니다.', }, + vision: '비전', outputVars: { files: { url: '이미지 URL', diff --git a/web/i18n/pl-PL/workflow.ts b/web/i18n/pl-PL/workflow.ts index 3ddb1ce69b..aed63bb989 100644 --- a/web/i18n/pl-PL/workflow.ts +++ b/web/i18n/pl-PL/workflow.ts @@ -901,6 +901,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'Ten model jest przestarzały', }, + vision: 'wizja', outputVars: { files: { title: 'Pliki generowane przez agenta', diff --git a/web/i18n/pt-BR/workflow.ts b/web/i18n/pt-BR/workflow.ts index 240635dd4e..5e4211c803 100644 --- a/web/i18n/pt-BR/workflow.ts +++ b/web/i18n/pt-BR/workflow.ts @@ -901,6 +901,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'Este modelo está obsoleto', }, + vision: 'visão', outputVars: { files: { type: 'Tipo de suporte. Agora suporta apenas imagem', diff --git a/web/i18n/ro-RO/workflow.ts b/web/i18n/ro-RO/workflow.ts index a542a18807..059ed725f0 100644 --- a/web/i18n/ro-RO/workflow.ts +++ b/web/i18n/ro-RO/workflow.ts @@ -901,6 +901,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'Acest model este învechit', }, + vision: 'viziune', outputVars: { files: { upload_file_id: 'Încărcați ID-ul fișierului', diff --git a/web/i18n/ru-RU/workflow.ts b/web/i18n/ru-RU/workflow.ts index 183c113b20..491d86f6b9 100644 --- a/web/i18n/ru-RU/workflow.ts +++ b/web/i18n/ru-RU/workflow.ts @@ -901,6 +901,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'Эта модель устарела', }, + vision: 'зрение', outputVars: { files: { transfer_method: 'Способ переноса. Ценность составляет remote_url или local_file', diff --git a/web/i18n/sl-SI/workflow.ts b/web/i18n/sl-SI/workflow.ts index d92d92da4a..7a5ee5bee8 100644 --- a/web/i18n/sl-SI/workflow.ts +++ b/web/i18n/sl-SI/workflow.ts @@ -940,6 +940,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'Ta model je zastarelo', }, + vision: 'vizija', outputVars: { files: { type: 'Vrsta podpore. Zdaj podpiramo samo slike.', diff --git a/web/i18n/th-TH/workflow.ts b/web/i18n/th-TH/workflow.ts index d91461f535..78dfb63347 100644 --- a/web/i18n/th-TH/workflow.ts +++ b/web/i18n/th-TH/workflow.ts @@ -901,6 +901,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'โมเดลนี้เลิกใช้แล้ว', }, + vision: 'การมองเห็น', outputVars: { files: { transfer_method: 'วิธีการโอน ค่าเป็น remote_url หรือ local_file', diff --git a/web/i18n/tr-TR/workflow.ts b/web/i18n/tr-TR/workflow.ts index b32b0b31e4..6b5c79e8d2 100644 --- a/web/i18n/tr-TR/workflow.ts +++ b/web/i18n/tr-TR/workflow.ts @@ -901,6 +901,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'Bu model kullanım dışıdır', }, + vision: 'görsel', outputVars: { files: { upload_file_id: 'Dosya kimliğini karşıya yükle', diff --git a/web/i18n/uk-UA/workflow.ts b/web/i18n/uk-UA/workflow.ts index fe8f5e448f..924fcff059 100644 --- a/web/i18n/uk-UA/workflow.ts +++ b/web/i18n/uk-UA/workflow.ts @@ -901,6 +901,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'Ця модель вважається застарілою', }, + vision: 'бачення', outputVars: { files: { upload_file_id: 'Завантажити ідентифікатор файлу', diff --git a/web/i18n/vi-VN/workflow.ts b/web/i18n/vi-VN/workflow.ts index b3767ee6e7..73a618cad2 100644 --- a/web/i18n/vi-VN/workflow.ts +++ b/web/i18n/vi-VN/workflow.ts @@ -901,6 +901,7 @@ const translation = { modelSelectorTooltips: { deprecated: 'Mô hình này không còn được dùng nữa', }, + vision: 'tầm nhìn', outputVars: { files: { title: 'Tệp do tác nhân tạo', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index a6daa56667..91a1ca0cf4 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -980,6 +980,7 @@ const translation = { modelSelectorTooltips: { deprecated: '此模型已弃用', }, + vision: '视觉', outputVars: { text: 'agent 生成的内容', usage: '模型用量信息', diff --git a/web/i18n/zh-Hant/workflow.ts b/web/i18n/zh-Hant/workflow.ts index 3da4cc172a..8bf502d032 100644 --- a/web/i18n/zh-Hant/workflow.ts +++ b/web/i18n/zh-Hant/workflow.ts @@ -906,6 +906,7 @@ const translation = { modelSelectorTooltips: { deprecated: '此模型已棄用', }, + vision: '視覺', outputVars: { files: { type: '支撐類型。現在僅支援鏡像', From 460e6e127eaee5b680e4d65043e339d2dd9c231b Mon Sep 17 00:00:00 2001 From: yujiosaka Date: Fri, 26 Dec 2025 00:09:38 +0900 Subject: [PATCH 2/4] refactor: improve agent node tests to call _generate_agent_parameters directly Address Gemini Code Assist High Priority review comment: - Refactor tests to call actual _generate_agent_parameters method instead of re-implementing the logic - Move imports to top of file (using fixture pattern to handle circular imports) - Tests now properly instantiate AgentNode, prepare inputs, call the method, and assert the returned dict is correct - Add new test case for for_log parameter behavior - Add new test case for non-query/instruction parameters --- .../workflow/nodes/agent/test_agent_node.py | 517 +++++++++++++----- 1 file changed, 382 insertions(+), 135 deletions(-) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py index 8ca1f93734..56d8b4b504 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py @@ -1,24 +1,65 @@ """Unit tests for AgentNode file handling.""" -from unittest.mock import patch +import sys +import types +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch import pytest -from core.file import File, FileTransferMethod, FileType -from core.model_runtime.entities.message_entities import ( - ImagePromptMessageContent, - TextPromptMessageContent, -) -from core.variables import ArrayFileSegment, FileSegment, StringSegment -from core.variables.segment_group import SegmentGroup +if TYPE_CHECKING: + from core.workflow.nodes.agent.agent_node import AgentNode + + +def _setup_stubs(monkeypatch): + """Set up stubs for circular import issues.""" + module_name = "core.ops.ops_trace_manager" + if module_name not in sys.modules: + ops_stub = types.ModuleType(module_name) + ops_stub.TraceQueueManager = object + ops_stub.TraceTask = object + monkeypatch.setitem(sys.modules, module_name, ops_stub) + + +@pytest.fixture +def agent_imports(monkeypatch) -> dict[str, Any]: + """Set up stubs and return imported modules.""" + _setup_stubs(monkeypatch) + + from core.agent.plugin_entities import AgentStrategyParameter + from core.file import File, FileTransferMethod, FileType, file_manager + from core.model_runtime.entities.message_entities import ImagePromptMessageContent + from core.variables import FileSegment, StringSegment + from core.workflow.nodes.agent.agent_node import AgentNode + from core.workflow.nodes.agent.entities import AgentNodeData, VisionConfig, VisionConfigOptions + from core.workflow.runtime.variable_pool import VariablePool + + return { + "AgentNode": AgentNode, + "AgentNodeData": AgentNodeData, + "VisionConfig": VisionConfig, + "VisionConfigOptions": VisionConfigOptions, + "VariablePool": VariablePool, + "AgentStrategyParameter": AgentStrategyParameter, + "File": File, + "FileTransferMethod": FileTransferMethod, + "FileType": FileType, + "file_manager": file_manager, + "ImagePromptMessageContent": ImagePromptMessageContent, + "FileSegment": FileSegment, + "StringSegment": StringSegment, + } class TestAgentNodeFileHandling: """Tests for file handling in query, instruction, and vision variable selector.""" @pytest.fixture - def mock_file(self) -> File: + def mock_file(self, agent_imports): """Create a mock file.""" + File = agent_imports["File"] + FileType = agent_imports["FileType"] + FileTransferMethod = agent_imports["FileTransferMethod"] return File( id="test-file-id", tenant_id="test-tenant", @@ -32,8 +73,11 @@ class TestAgentNodeFileHandling: ) @pytest.fixture - def mock_custom_file(self) -> File: + def mock_custom_file(self, agent_imports): """Create a mock custom (unsupported) file.""" + File = agent_imports["File"] + FileType = agent_imports["FileType"] + FileTransferMethod = agent_imports["FileTransferMethod"] return File( id="test-custom-id", tenant_id="test-tenant", @@ -46,175 +90,378 @@ class TestAgentNodeFileHandling: size=4096, ) - def test_query_with_text_only_returns_string(self): + @pytest.fixture + def mock_strategy(self, agent_imports) -> MagicMock: + """Create a mock agent strategy.""" + AgentStrategyParameter = agent_imports["AgentStrategyParameter"] + strategy = MagicMock() + strategy.get_parameters.return_value = [ + AgentStrategyParameter( + name="query", + type=AgentStrategyParameter.AgentStrategyParameterType.STRING, + required=True, + label={"en_US": "Query"}, + ), + AgentStrategyParameter( + name="instruction", + type=AgentStrategyParameter.AgentStrategyParameterType.STRING, + required=False, + label={"en_US": "Instruction"}, + ), + ] + return strategy + + @pytest.fixture + def base_node_data(self, agent_imports) -> dict: + """Create base node data for tests.""" + VisionConfig = agent_imports["VisionConfig"] + return { + "title": "Test Agent", + "agent_strategy_provider_name": "test-provider", + "agent_strategy_name": "test-strategy", + "agent_strategy_label": "Test Strategy", + "agent_parameters": {}, + "vision": VisionConfig(enabled=False), + } + + def _create_agent_node(self, agent_imports) -> "AgentNode": + """Create an AgentNode instance for testing.""" + AgentNode = agent_imports["AgentNode"] + node = object.__new__(AgentNode) + node.tenant_id = "test-tenant" + node.app_id = "test-app" + return node + + def test_query_with_text_only_returns_string(self, agent_imports, mock_strategy, base_node_data): """When query contains only text, it should return a string.""" - segment_group = SegmentGroup(value=[StringSegment(value="Hello, world!")]) + # Arrange + VariablePool = agent_imports["VariablePool"] + AgentNodeData = agent_imports["AgentNodeData"] + StringSegment = agent_imports["StringSegment"] - contents: list[dict] = [] - has_file = False - for segment in segment_group.value: - if not isinstance(segment, (ArrayFileSegment, FileSegment)): - if segment.text: - contents.append(TextPromptMessageContent(data=segment.text).model_dump()) + variable_pool = VariablePool() + variable_pool.add(["node1", "text_var"], StringSegment(value="Hello, world!")) - result = contents if has_file else segment_group.text + base_node_data["agent_parameters"] = { + "query": AgentNodeData.AgentInput( + type="mixed", + value="{{#node1.text_var#}}", + ), + } + node_data = AgentNodeData.model_validate(base_node_data) + agent_parameters = mock_strategy.get_parameters() - assert result == "Hello, world!" - assert isinstance(result, str) + agent_node = self._create_agent_node(agent_imports) - def test_query_with_file_returns_list(self, mock_file): + # Act + result = agent_node._generate_agent_parameters( + agent_parameters=agent_parameters, + variable_pool=variable_pool, + node_data=node_data, + for_log=False, + strategy=mock_strategy, + ) + + # Assert + assert result["query"] == "Hello, world!" + assert isinstance(result["query"], str) + + def test_query_with_file_returns_list(self, agent_imports, mock_file, mock_strategy, base_node_data): """When query contains a file, it should return a list.""" - segment_group = SegmentGroup(value=[FileSegment(value=mock_file)]) + # Arrange + VariablePool = agent_imports["VariablePool"] + AgentNodeData = agent_imports["AgentNodeData"] + FileSegment = agent_imports["FileSegment"] + file_manager = agent_imports["file_manager"] + ImagePromptMessageContent = agent_imports["ImagePromptMessageContent"] - with patch("core.file.file_manager.to_prompt_message_content") as mock_to_content: + variable_pool = VariablePool() + variable_pool.add(["node1", "file_var"], FileSegment(value=mock_file)) + + base_node_data["agent_parameters"] = { + "query": AgentNodeData.AgentInput( + type="mixed", + value="{{#node1.file_var#}}", + ), + } + node_data = AgentNodeData.model_validate(base_node_data) + agent_parameters = mock_strategy.get_parameters() + + agent_node = self._create_agent_node(agent_imports) + + with patch.object(file_manager, "to_prompt_message_content") as mock_to_content: mock_to_content.return_value = ImagePromptMessageContent( url="http://example.com/test.png", mime_type="image/png", format="png" ) - contents: list[dict] = [] - has_file = False - for segment in segment_group.value: - if isinstance(segment, FileSegment): - file = segment.value - if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: - from core.file import file_manager + # Act + result = agent_node._generate_agent_parameters( + agent_parameters=agent_parameters, + variable_pool=variable_pool, + node_data=node_data, + for_log=False, + strategy=mock_strategy, + ) - contents.append(file_manager.to_prompt_message_content(file).model_dump()) - has_file = True + # Assert + assert isinstance(result["query"], list) + assert len(result["query"]) == 1 + assert result["query"][0]["type"] == "image" - result = contents if has_file else segment_group.text - - assert isinstance(result, list) - assert len(result) == 1 - assert result[0]["type"] == "image" - - def test_query_with_text_and_file_returns_list_with_both(self, mock_file): + def test_query_with_text_and_file_returns_list_with_both( + self, agent_imports, mock_file, mock_strategy, base_node_data + ): """When query contains both text and file, it should return a list with both.""" - segment_group = SegmentGroup(value=[ - StringSegment(value="Describe this: "), - FileSegment(value=mock_file), - ]) + # Arrange + VariablePool = agent_imports["VariablePool"] + AgentNodeData = agent_imports["AgentNodeData"] + FileSegment = agent_imports["FileSegment"] + file_manager = agent_imports["file_manager"] + ImagePromptMessageContent = agent_imports["ImagePromptMessageContent"] - with patch("core.file.file_manager.to_prompt_message_content") as mock_to_content: + variable_pool = VariablePool() + variable_pool.add(["node1", "file_var"], FileSegment(value=mock_file)) + + base_node_data["agent_parameters"] = { + "query": AgentNodeData.AgentInput( + type="mixed", + value="Describe this: {{#node1.file_var#}}", + ), + } + node_data = AgentNodeData.model_validate(base_node_data) + agent_parameters = mock_strategy.get_parameters() + + agent_node = self._create_agent_node(agent_imports) + + with patch.object(file_manager, "to_prompt_message_content") as mock_to_content: mock_to_content.return_value = ImagePromptMessageContent( url="http://example.com/test.png", mime_type="image/png", format="png" ) - contents: list[dict] = [] - has_file = False - for segment in segment_group.value: - if isinstance(segment, FileSegment): - file = segment.value - if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: - from core.file import file_manager + # Act + result = agent_node._generate_agent_parameters( + agent_parameters=agent_parameters, + variable_pool=variable_pool, + node_data=node_data, + for_log=False, + strategy=mock_strategy, + ) - contents.append(file_manager.to_prompt_message_content(file).model_dump()) - has_file = True - elif segment.text: - contents.append(TextPromptMessageContent(data=segment.text).model_dump()) + # Assert + assert isinstance(result["query"], list) + assert len(result["query"]) == 2 + assert result["query"][0]["type"] == "text" + assert result["query"][1]["type"] == "image" - result = contents if has_file else segment_group.text + def test_custom_file_type_is_ignored(self, agent_imports, mock_custom_file, mock_strategy, base_node_data): + """Custom file types should be ignored and return text only.""" + # Arrange + VariablePool = agent_imports["VariablePool"] + AgentNodeData = agent_imports["AgentNodeData"] + FileSegment = agent_imports["FileSegment"] - assert isinstance(result, list) - assert len(result) == 2 - assert result[0]["type"] == "text" - assert result[1]["type"] == "image" + variable_pool = VariablePool() + variable_pool.add(["node1", "file_var"], FileSegment(value=mock_custom_file)) - def test_custom_file_type_is_ignored(self, mock_custom_file): - """Custom file types should be ignored.""" - segment_group = SegmentGroup(value=[FileSegment(value=mock_custom_file)]) + base_node_data["agent_parameters"] = { + "query": AgentNodeData.AgentInput( + type="mixed", + value="{{#node1.file_var#}}", + ), + } + node_data = AgentNodeData.model_validate(base_node_data) + agent_parameters = mock_strategy.get_parameters() - has_file = False - for segment in segment_group.value: - if isinstance(segment, FileSegment): - if segment.value.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: - has_file = True + agent_node = self._create_agent_node(agent_imports) - assert has_file is False + # Act + result = agent_node._generate_agent_parameters( + agent_parameters=agent_parameters, + variable_pool=variable_pool, + node_data=node_data, + for_log=False, + strategy=mock_strategy, + ) - def test_instruction_with_file_returns_list(self, mock_file): + # Assert + # Custom file types are ignored, so result should be the text representation + assert isinstance(result["query"], str) + + def test_instruction_with_file_returns_list(self, agent_imports, mock_file, mock_strategy, base_node_data): """When instruction contains a file, it should return a list (same as query).""" - segment_group = SegmentGroup(value=[ - StringSegment(value="You are a helpful assistant. "), - FileSegment(value=mock_file), - ]) + # Arrange + VariablePool = agent_imports["VariablePool"] + AgentNodeData = agent_imports["AgentNodeData"] + FileSegment = agent_imports["FileSegment"] + file_manager = agent_imports["file_manager"] + ImagePromptMessageContent = agent_imports["ImagePromptMessageContent"] - with patch("core.file.file_manager.to_prompt_message_content") as mock_to_content: + variable_pool = VariablePool() + variable_pool.add(["node1", "file_var"], FileSegment(value=mock_file)) + + base_node_data["agent_parameters"] = { + "instruction": AgentNodeData.AgentInput( + type="mixed", + value="You are a helpful assistant. {{#node1.file_var#}}", + ), + } + node_data = AgentNodeData.model_validate(base_node_data) + agent_parameters = mock_strategy.get_parameters() + + agent_node = self._create_agent_node(agent_imports) + + with patch.object(file_manager, "to_prompt_message_content") as mock_to_content: mock_to_content.return_value = ImagePromptMessageContent( url="http://example.com/test.png", mime_type="image/png", format="png" ) - contents: list[dict] = [] - has_file = False - for segment in segment_group.value: - if isinstance(segment, FileSegment): - file = segment.value - if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: - from core.file import file_manager + # Act + result = agent_node._generate_agent_parameters( + agent_parameters=agent_parameters, + variable_pool=variable_pool, + node_data=node_data, + for_log=False, + strategy=mock_strategy, + ) - contents.append(file_manager.to_prompt_message_content(file).model_dump()) - has_file = True - elif segment.text: - contents.append(TextPromptMessageContent(data=segment.text).model_dump()) + # Assert + assert isinstance(result["instruction"], list) + assert len(result["instruction"]) == 2 + assert result["instruction"][0]["type"] == "text" + assert result["instruction"][1]["type"] == "image" - result = contents if has_file else segment_group.text - - assert isinstance(result, list) - assert len(result) == 2 - assert result[0]["type"] == "text" - assert result[1]["type"] == "image" - - def test_vision_variable_selector_files_added_to_query(self, mock_file): + def test_vision_variable_selector_files_added_to_query( + self, agent_imports, mock_file, mock_strategy, base_node_data + ): """Vision variable selector files should be added to query only.""" - vision_files = [mock_file] + # Arrange + VariablePool = agent_imports["VariablePool"] + AgentNodeData = agent_imports["AgentNodeData"] + StringSegment = agent_imports["StringSegment"] + FileSegment = agent_imports["FileSegment"] + VisionConfig = agent_imports["VisionConfig"] + VisionConfigOptions = agent_imports["VisionConfigOptions"] + file_manager = agent_imports["file_manager"] + ImagePromptMessageContent = agent_imports["ImagePromptMessageContent"] - with patch("core.file.file_manager.to_prompt_message_content") as mock_to_content: + variable_pool = VariablePool() + variable_pool.add(["node1", "text_var"], StringSegment(value="Describe this image")) + variable_pool.add(["sys", "files"], FileSegment(value=mock_file)) + + base_node_data["agent_parameters"] = { + "query": AgentNodeData.AgentInput( + type="mixed", + value="{{#node1.text_var#}}", + ), + } + base_node_data["vision"] = VisionConfig( + enabled=True, + configs=VisionConfigOptions( + variable_selector=["sys", "files"], + ), + ) + node_data = AgentNodeData.model_validate(base_node_data) + agent_parameters = mock_strategy.get_parameters() + + agent_node = self._create_agent_node(agent_imports) + + with patch.object(file_manager, "to_prompt_message_content") as mock_to_content: mock_to_content.return_value = ImagePromptMessageContent( url="http://example.com/test.png", mime_type="image/png", format="png" ) - contents: list[dict] = [] - has_file = False - - for file in vision_files: - if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: - from core.file import file_manager - - contents.append(file_manager.to_prompt_message_content(file).model_dump()) - has_file = True - - assert has_file is True - assert len(contents) == 1 - assert contents[0]["type"] == "image" - - def test_query_with_text_and_vision_files(self, mock_file): - """Query text combined with vision variable selector files.""" - segment_group = SegmentGroup(value=[StringSegment(value="Describe this image")]) - vision_files = [mock_file] - - with patch("core.file.file_manager.to_prompt_message_content") as mock_to_content: - mock_to_content.return_value = ImagePromptMessageContent( - url="http://example.com/test.png", mime_type="image/png", format="png" + # Act + result = agent_node._generate_agent_parameters( + agent_parameters=agent_parameters, + variable_pool=variable_pool, + node_data=node_data, + for_log=False, + strategy=mock_strategy, ) - contents: list[dict] = [] - has_file = False + # Assert + assert isinstance(result["query"], list) + assert len(result["query"]) == 2 + assert result["query"][0]["type"] == "text" + assert result["query"][0]["data"] == "Describe this image" + assert result["query"][1]["type"] == "image" - for segment in segment_group.value: - if segment.text: - contents.append(TextPromptMessageContent(data=segment.text).model_dump()) + def test_for_log_returns_text_representation(self, agent_imports, mock_file, mock_strategy, base_node_data): + """When for_log is True, files should be represented as log text.""" + # Arrange + VariablePool = agent_imports["VariablePool"] + AgentNodeData = agent_imports["AgentNodeData"] + FileSegment = agent_imports["FileSegment"] - for file in vision_files: - if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: - from core.file import file_manager + variable_pool = VariablePool() + variable_pool.add(["node1", "file_var"], FileSegment(value=mock_file)) - contents.append(file_manager.to_prompt_message_content(file).model_dump()) - has_file = True + base_node_data["agent_parameters"] = { + "query": AgentNodeData.AgentInput( + type="mixed", + value="{{#node1.file_var#}}", + ), + } + node_data = AgentNodeData.model_validate(base_node_data) + agent_parameters = mock_strategy.get_parameters() - result = contents if has_file else segment_group.text + agent_node = self._create_agent_node(agent_imports) - assert isinstance(result, list) - assert len(result) == 2 - assert result[0]["type"] == "text" - assert result[0]["data"] == "Describe this image" - assert result[1]["type"] == "image" + # Act + result = agent_node._generate_agent_parameters( + agent_parameters=agent_parameters, + variable_pool=variable_pool, + node_data=node_data, + for_log=True, + strategy=mock_strategy, + ) + + # Assert + # for_log=True should return log representation, not a list + assert isinstance(result["query"], str) + + def test_non_query_instruction_parameter_returns_text( + self, agent_imports, mock_file, mock_strategy, base_node_data + ): + """Parameters other than query/instruction should return text even with files.""" + # Arrange + AgentStrategyParameter = agent_imports["AgentStrategyParameter"] + VariablePool = agent_imports["VariablePool"] + AgentNodeData = agent_imports["AgentNodeData"] + FileSegment = agent_imports["FileSegment"] + + mock_strategy.get_parameters.return_value = [ + AgentStrategyParameter( + name="other_param", + type=AgentStrategyParameter.AgentStrategyParameterType.STRING, + required=False, + label={"en_US": "Other"}, + ), + ] + + variable_pool = VariablePool() + variable_pool.add(["node1", "file_var"], FileSegment(value=mock_file)) + + base_node_data["agent_parameters"] = { + "other_param": AgentNodeData.AgentInput( + type="mixed", + value="{{#node1.file_var#}}", + ), + } + node_data = AgentNodeData.model_validate(base_node_data) + agent_parameters = mock_strategy.get_parameters() + + agent_node = self._create_agent_node(agent_imports) + + # Act + result = agent_node._generate_agent_parameters( + agent_parameters=agent_parameters, + variable_pool=variable_pool, + node_data=node_data, + for_log=False, + strategy=mock_strategy, + ) + + # Assert + # Non-query/instruction parameters should return text representation + assert isinstance(result["other_param"], str) From dd021838e7f6d09ab7af28e1a4cefbd26fc01f1b Mon Sep 17 00:00:00 2001 From: yujiosaka Date: Fri, 26 Dec 2025 00:13:09 +0900 Subject: [PATCH 3/4] refactor: consolidate ArrayFileSegment and FileSegment handling Address Gemini Code Assist Medium Priority review comment: - Combine ArrayFileSegment and FileSegment handling logic to reduce code duplication - Use isinstance check with tuple to handle both segment types in a single branch - Extract files list conditionally based on segment type --- api/core/workflow/nodes/agent/agent_node.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index ac595a8928..7ff65c62c0 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -232,22 +232,15 @@ class AgentNode(Node[AgentNodeData]): ) for segment in segment_group.value: - if isinstance(segment, ArrayFileSegment): - for file in segment.value: + if isinstance(segment, (ArrayFileSegment, FileSegment)): + files = segment.value if isinstance(segment, ArrayFileSegment) else [segment.value] + for file in files: if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: file_content = file_manager.to_prompt_message_content( file, image_detail_config=vision_detail ) contents.append(file_content.model_dump()) has_file = True - elif isinstance(segment, FileSegment): - file = segment.value - if file.type in {FileType.IMAGE, FileType.VIDEO, FileType.AUDIO, FileType.DOCUMENT}: - file_content = file_manager.to_prompt_message_content( - file, image_detail_config=vision_detail - ) - contents.append(file_content.model_dump()) - has_file = True else: text = segment.text if text: From 3d5a34871ff9e6d8062feec317925f77fa790396 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 26 Dec 2025 08:45:48 +0000 Subject: [PATCH 4/4] [autofix.ci] apply automated fixes --- api/core/workflow/nodes/agent/agent_node.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py index 7ff65c62c0..6e2f56879f 100644 --- a/api/core/workflow/nodes/agent/agent_node.py +++ b/api/core/workflow/nodes/agent/agent_node.py @@ -227,9 +227,7 @@ class AgentNode(Node[AgentNodeData]): if parameter_name in ("query", "instruction") and not for_log: contents: list[dict[str, Any]] = [] has_file = False - vision_detail = ( - node_data.vision.configs.detail if node_data.vision.enabled else None - ) + vision_detail = node_data.vision.configs.detail if node_data.vision.enabled else None for segment in segment_group.value: if isinstance(segment, (ArrayFileSegment, FileSegment)):