diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py
index 0dcdcb35b36532..b044e2b4cacc39 100644
--- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py
+++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py
@@ -53,6 +53,7 @@
from graphon.file import File, FileTransferMethod
from graphon.variables.segments import Segment
from graphon.variables.template_resolution import convert_template
+from libs.workflow_template_values import resolve_template_value
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentKnowledgeMetadataFilteringConfig,
@@ -216,7 +217,7 @@ def build(self, context: WorkflowAgentRuntimeBuildContext) -> WorkflowAgentRunti
runtime_config_skills=runtime_config_skills,
)
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
- knowledge_config = build_knowledge_layer_config(agent_soul)
+ knowledge_config = build_knowledge_layer_config(agent_soul, variable_pool=context.variable_pool)
context_window_tokens = resolve_model_context_window(
run_context=context.dify_context,
provider_name=agent_soul.model.model_provider,
@@ -743,7 +744,9 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
)
-def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBaseLayerConfig | None:
+def build_knowledge_layer_config(
+ agent_soul: AgentSoulConfig, *, variable_pool: VariablePoolReader | None = None
+) -> DifyKnowledgeBaseLayerConfig | None:
"""Map Agent Soul knowledge sets into one Dify knowledge-base layer.
Agent Soul DTO validation owns malformed set rejection. Runtime mapping is
@@ -751,6 +754,8 @@ def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBa
policy, dataset refs, retrieval controls, and metadata-filtering controls.
``score_threshold=None`` means disabled threshold filtering and maps to the
inner retrieval request's ``0.0`` default through the Agent backend DTO.
+ Workflow callers supply the current run's variable pool; ``None`` preserves
+ literal filters for standalone Agent requests, which have no workflow scope.
"""
if not agent_soul.knowledge.sets:
return None
@@ -774,7 +779,9 @@ def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBa
value=knowledge_set.query.value,
),
retrieval=_knowledge_retrieval_config(knowledge_set.retrieval),
- metadata_filtering=_knowledge_metadata_filtering_config(knowledge_set.metadata_filtering),
+ metadata_filtering=_knowledge_metadata_filtering_config(
+ knowledge_set.metadata_filtering, variable_pool=variable_pool
+ ),
)
for knowledge_set in agent_soul.knowledge.sets
],
@@ -821,17 +828,33 @@ def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> Dif
def _knowledge_metadata_filtering_config(
metadata_filtering: AgentKnowledgeMetadataFilteringConfig,
+ *,
+ variable_pool: VariablePoolReader | None,
) -> DifyKnowledgeMetadataFilteringConfig:
+ conditions = metadata_filtering.conditions
+ if conditions is not None and metadata_filtering.mode == "manual" and variable_pool is not None:
+ try:
+ conditions = conditions.model_copy(
+ update={
+ "conditions": [
+ condition.model_copy(update={"value": resolve_template_value(condition.value, variable_pool)})
+ for condition in conditions.conditions
+ ]
+ }
+ )
+ except ValueError as error:
+ raise WorkflowAgentRuntimeRequestBuildError(
+ "agent_metadata_filter_variable_not_found", str(error)
+ ) from error
+
return DifyKnowledgeMetadataFilteringConfig(
mode=metadata_filtering.mode,
model_config=_knowledge_model_config(metadata_filtering.metadata_model_config),
conditions=cast(
Any,
- metadata_filtering.conditions.model_dump(
- mode="json", exclude={"conditions": {"__all__": {"id", "metadata_id"}}}
- ),
+ conditions.model_dump(mode="json", exclude={"conditions": {"__all__": {"id", "metadata_id"}}}),
)
- if metadata_filtering.conditions is not None
+ if conditions is not None
else None,
)
diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py
index c0f532a6750c21..ed71946728f324 100644
--- a/api/core/workflow/workflow_entry.py
+++ b/api/core/workflow/workflow_entry.py
@@ -22,6 +22,8 @@
is_start_node_type,
resolve_workflow_node_class,
)
+from core.workflow.nodes.agent_v2.binding_resolver import WorkflowAgentBindingResolver
+from core.workflow.nodes.agent_v2.discriminator import is_dify_agent_node_data
from core.workflow.nodes.human_input.boundary import HumanInputFormEventFilter
from core.workflow.system_variables import (
default_system_variables,
@@ -47,6 +49,8 @@
from graphon.nodes.container_effects import ContainerAwaitRequest
from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool
from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool
+from libs.workflow_template_values import template_value_selectors
+from models.agent_config_entities import AgentSoulConfig
from models.workflow import Workflow
logger = logging.getLogger(__name__)
@@ -271,6 +275,28 @@ def single_step_run(
)
except NotImplementedError:
variable_mapping = {}
+ if is_dify_agent_node_data(node_config_data):
+ # Agent Soul lives in its owned snapshot, outside graph node data.
+ # Single-step execution must also load references used only by its
+ # retrieval filters, which are absent from the task prompt.
+ bundle = WorkflowAgentBindingResolver().resolve(
+ tenant_id=workflow.tenant_id,
+ app_id=workflow.app_id,
+ workflow_id=workflow.id,
+ node_id=node_id,
+ )
+ agent_soul = AgentSoulConfig.model_validate(bundle.snapshot.config_snapshot_dict)
+ variable_mapping = {
+ **variable_mapping,
+ **{
+ f"{node_id}.metadata_filter.{'.'.join(selector)}": selector
+ for knowledge_set in agent_soul.knowledge.sets
+ if knowledge_set.metadata_filtering.mode == "manual"
+ and knowledge_set.metadata_filtering.conditions is not None
+ for condition in knowledge_set.metadata_filtering.conditions.conditions
+ for selector in template_value_selectors(condition.value)
+ },
+ }
variable_mapping = inject_default_system_variable_mappings(
node_id=node_id,
node_type=node_type,
diff --git a/api/libs/workflow_template_values.py b/api/libs/workflow_template_values.py
new file mode 100644
index 00000000000000..86f37a571a517b
--- /dev/null
+++ b/api/libs/workflow_template_values.py
@@ -0,0 +1,52 @@
+from collections.abc import Iterator, Sequence
+from typing import Protocol
+
+from graphon.nodes.base.variable_template_parser import extract_selectors_from_template
+from graphon.variables.segments import Segment
+from graphon.variables.template_resolution import VARIABLE_PATTERN
+
+type TemplateValue = str | Sequence[str] | int | float | None
+
+
+class TemplateVariablePool(Protocol):
+ def get(self, selector: Sequence[str], /) -> Segment | None: ...
+
+
+def template_value_selectors(value: TemplateValue) -> Iterator[list[str]]:
+ """Extract workflow references from a scalar string or string sequence."""
+ values = [value] if isinstance(value, str) else value
+ if isinstance(values, Sequence):
+ for template in values:
+ for reference in extract_selectors_from_template(template):
+ yield list(reference.value_selector)
+
+
+def resolve_template_value(value: TemplateValue, variable_pool: TemplateVariablePool) -> TemplateValue:
+ """Resolve scalar values and string sequences against a workflow variable pool.
+
+ A whole scalar reference retains its string or numeric type;
+ interpolated text and elements of string lists remain strings. Missing
+ references fail explicitly rather than becoming literal selector text.
+ """
+
+ def get_segment(selector: str) -> Segment:
+ segment = variable_pool.get(selector.split("."))
+ if segment is None:
+ raise ValueError(f"Workflow variable {selector} is not available.")
+ return segment
+
+ def resolve_template(template: str) -> str | int | float | None:
+ if match := VARIABLE_PATTERN.fullmatch(template):
+ resolved = get_segment(match.group(1)).to_object()
+ if resolved is None or isinstance(resolved, str | float):
+ return resolved
+ if isinstance(resolved, int) and not isinstance(resolved, bool):
+ return resolved
+ return str(resolved)
+ return VARIABLE_PATTERN.sub(lambda match: get_segment(match.group(1)).text, template)
+
+ if isinstance(value, str):
+ return resolve_template(value)
+ if isinstance(value, Sequence):
+ return [str(resolve_template(item)) for item in value]
+ return value
diff --git a/api/tests/unit_tests/commands/test_fix_app_site_missing.py b/api/tests/unit_tests/commands/test_fix_app_site_missing.py
index c69f4eb6463746..8666429fc9786f 100644
--- a/api/tests/unit_tests/commands/test_fix_app_site_missing.py
+++ b/api/tests/unit_tests/commands/test_fix_app_site_missing.py
@@ -66,6 +66,13 @@ def _bind_command_database(
return command_sessions, command_session
+def _run_fix_app_site_missing() -> None:
+ """Invoke the command callback through a narrowed, non-optional reference."""
+ callback = system_commands.fix_app_site_missing.callback
+ assert callback is not None
+ callback()
+
+
def test_fix_app_site_missing_passes_loaded_session_to_signal(
monkeypatch: pytest.MonkeyPatch,
sqlite_engine: Engine,
@@ -89,7 +96,7 @@ def create_site(sender: App, *, account: Account, session: Session) -> None:
monkeypatch.setattr(system_commands.app_was_created, "send", send)
try:
- system_commands.fix_app_site_missing.callback()
+ _run_fix_app_site_missing()
finally:
command_sessions.remove()
@@ -123,7 +130,7 @@ def fail_signal(sender: App, **_kwargs: object) -> None:
monkeypatch.setattr(system_commands.app_was_created, "send", MagicMock(side_effect=fail_signal))
try:
- system_commands.fix_app_site_missing.callback()
+ _run_fix_app_site_missing()
finally:
command_sessions.remove()
diff --git a/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py
index bb3201bbdaafb5..24afa1cb44a16e 100644
--- a/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py
+++ b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py
@@ -26,8 +26,10 @@
def _invoke_reset() -> int:
+ callback = commands.reset_encrypt_key_pair.callback
+ assert callback is not None
try:
- commands.reset_encrypt_key_pair.callback()
+ callback()
except SystemExit as e:
return int(e.code or 0)
return 0
@@ -87,7 +89,7 @@ def _bind_command_to_sqlite(monkeypatch: pytest.MonkeyPatch, session: Session) -
monkeypatch.setattr(system_commands, "db", SimpleNamespace(engine=session.get_bind()))
-def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys):
+def test_reset_aborts_when_not_self_hosted(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
exit_code = _invoke_reset()
@@ -128,8 +130,12 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant(
assert TENANT_ID in captured.out
sqlite_session.expire_all()
- assert sqlite_session.get(Tenant, TENANT_ID).encrypt_public_key == f"new-key-{TENANT_ID}"
- assert sqlite_session.get(Tenant, OTHER_TENANT_ID).encrypt_public_key == f"new-key-{OTHER_TENANT_ID}"
+ tenant_after = sqlite_session.get(Tenant, TENANT_ID)
+ other_tenant_after = sqlite_session.get(Tenant, OTHER_TENANT_ID)
+ assert tenant_after is not None
+ assert other_tenant_after is not None
+ assert tenant_after.encrypt_public_key == f"new-key-{TENANT_ID}"
+ assert other_tenant_after.encrypt_public_key == f"new-key-{OTHER_TENANT_ID}"
assert sqlite_session.scalars(select(Provider).where(Provider.provider_type == ProviderType.CUSTOM)).all() == []
assert sqlite_session.scalars(select(ProviderModel)).all() == []
assert sqlite_session.scalars(select(BuiltinToolProvider)).all() == []
diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py
index 2678610cb80bf6..76162384b102c8 100644
--- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py
+++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py
@@ -10,6 +10,7 @@
from dify_agent.layers.prompt import Config as PromptConfig
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID
from dify_agent.protocol.snapshot import SessionSnapshot
+from sqlalchemy.orm import Session
from clients.agent_backend import (
DIFY_CONFIG_LAYER_ID,
@@ -27,9 +28,18 @@
WorkflowAgentRuntimeRequestBuildError,
build_shell_layer_config,
)
+from core.workflow.workflow_entry import WorkflowEntry
from graphon.file import File, FileTransferMethod, FileType
+from graphon.runtime.variable_pool import VariablePool
from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment
-from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
+from models.agent import (
+ Agent,
+ AgentConfigSnapshot,
+ AgentScope,
+ AgentSource,
+ WorkflowAgentBindingType,
+ WorkflowAgentNodeBinding,
+)
from models.agent_config_entities import (
AgentSoulConfig,
AgentSoulModelConfig,
@@ -40,6 +50,7 @@
DeclaredOutputType,
WorkflowNodeJobConfig,
)
+from models.workflow import Workflow
from tests.unit_tests.config_override import apply_config_overrides
@@ -228,6 +239,192 @@ def _request_layers(result) -> dict[str, dict[str, object]]:
return {layer["name"]: layer for layer in dumped["composition"]["layers"]}
+def _metadata_filter_context() -> WorkflowAgentRuntimeBuildContext:
+ context = _context()
+ soul = context.snapshot.config_snapshot_dict
+ soul["knowledge"] = {
+ "sets": [
+ {
+ "id": "exhibits",
+ "name": "Exhibits",
+ "datasets": [{"id": "dataset-1"}],
+ "query": {"mode": "generated_query"},
+ "retrieval": {"mode": "multiple", "top_k": 4},
+ "metadata_filtering": {
+ "mode": "manual",
+ "conditions": {
+ "logical_operator": "and",
+ "conditions": [
+ {"name": name, "comparison_operator": "=", "value": "{{#start." + name + "#}}"}
+ for name in ("guide_program_id", "guide_item_id")
+ ],
+ },
+ },
+ }
+ ],
+ }
+ context.snapshot.config_snapshot = soul
+ return context
+
+
+def test_workflow_metadata_filters_resolve_current_run_values_without_mutating_snapshot():
+ context = _metadata_filter_context()
+ saved_snapshot = context.snapshot.config_snapshot_dict
+ builder = WorkflowAgentRuntimeRequestBuilder()
+
+ for program_id, item_id in [(123, 456), (789, 0), ("00123", "00456")]:
+ pool = VariablePool()
+ pool.add(["start", "guide_program_id"], program_id)
+ pool.add(["start", "guide_item_id"], item_id)
+ pool.add(["sys", "query"], "Describe this exhibit")
+ result = builder.build(replace(context, variable_pool=pool))
+ layer = result.request.model_dump(mode="json")["composition"]["layers"]
+ knowledge = next(item for item in layer if item["name"] == "knowledge")
+ filtering = knowledge["config"]["sets"][0]["metadata_filtering"]
+
+ assert filtering["conditions"] == {
+ "logical_operator": "and",
+ "conditions": [
+ {"name": "guide_program_id", "comparison_operator": "=", "value": program_id},
+ {"name": "guide_item_id", "comparison_operator": "=", "value": item_id},
+ ],
+ }
+ assert context.snapshot.config_snapshot_dict == saved_snapshot
+
+
+def test_workflow_metadata_filter_rejects_missing_variable_before_starting_agent():
+ context = _metadata_filter_context()
+ with pytest.raises(WorkflowAgentRuntimeRequestBuildError, match="start.guide_program_id"):
+ WorkflowAgentRuntimeRequestBuilder().build(replace(context, variable_pool=VariablePool()))
+
+
+def test_single_step_loads_variables_referenced_only_by_knowledge_filters(
+ sqlite_session: Session, monkeypatch: pytest.MonkeyPatch
+):
+ apply_config_overrides(monkeypatch, AGENT_BACKEND_BASE_URL="http://agent-backend.invalid")
+ context = _metadata_filter_context()
+ context.binding.workflow_version = "draft"
+ context.binding.binding_type = WorkflowAgentBindingType.INLINE_AGENT
+ context.agent.scope = AgentScope.WORKFLOW_ONLY
+ context.agent.source = AgentSource.WORKFLOW
+ sqlite_session.add_all([context.agent, context.snapshot, context.binding])
+ sqlite_session.commit()
+ workflow = Workflow(
+ id=context.workflow_id,
+ tenant_id=context.dify_context.tenant_id,
+ app_id=context.dify_context.app_id,
+ graph=json.dumps(
+ {
+ "nodes": [
+ {
+ "id": context.node_id,
+ "data": {"type": "agent", "version": "2", "agent_node_kind": "dify_agent", "title": "Agent"},
+ }
+ ],
+ "edges": [],
+ }
+ ),
+ )
+ source_pool = VariablePool()
+ source_pool.add(["start", "guide_program_id"], "00123")
+ source_pool.add(["start", "guide_item_id"], 456)
+
+ class DraftVariableLoader:
+ def load_variables(self, selectors):
+ return [value for selector in selectors if (value := source_pool.get(selector)) is not None]
+
+ pool = VariablePool()
+ WorkflowEntry.single_step_run(
+ workflow=workflow,
+ node_id=context.node_id,
+ user_id=context.dify_context.user_id,
+ user_inputs={},
+ variable_pool=pool,
+ variable_loader=DraftVariableLoader(),
+ )
+ result = WorkflowAgentRuntimeRequestBuilder().build(replace(context, variable_pool=pool))
+ conditions = _request_layers(result)["knowledge"]["config"]["sets"][0]["metadata_filtering"]["conditions"][
+ "conditions"
+ ]
+ assert [condition["value"] for condition in conditions] == ["00123", 456]
+
+
+def test_single_step_legacy_agent_v2_does_not_resolve_new_agent_binding(monkeypatch):
+ legacy_node = SimpleNamespace(id="legacy-agent")
+ monkeypatch.setattr(WorkflowEntry, "_run_node_with_layers", lambda *_args, **_kwargs: iter(()))
+ monkeypatch.setattr(
+ "core.workflow.workflow_entry.DifyNodeFactory.from_graph_init_context",
+ lambda **_kwargs: SimpleNamespace(create_node=lambda _config: legacy_node),
+ )
+
+ def reject_new_agent_binding(*_args, **_kwargs):
+ raise AssertionError("Legacy Agent must not resolve an Agent v2 binding")
+
+ monkeypatch.setattr("core.workflow.workflow_entry.WorkflowAgentBindingResolver.resolve", reject_new_agent_binding)
+ workflow = Workflow(
+ id="workflow-id",
+ tenant_id="tenant-id",
+ app_id="app-id",
+ graph=json.dumps(
+ {
+ "nodes": [{"id": "legacy-agent", "data": {"type": "agent", "version": "2", "title": "Legacy Agent"}}],
+ "edges": [],
+ }
+ ),
+ )
+ node, _ = WorkflowEntry.single_step_run(
+ workflow=workflow,
+ node_id="legacy-agent",
+ user_id="user-id",
+ user_inputs={},
+ variable_pool=VariablePool(),
+ )
+ assert node is legacy_node
+
+
+@pytest.mark.parametrize(
+ ("value", "expected"),
+ [
+ ("program:{{#start.guide_program_id#}}/{{#start.guide_item_id#}}", "program:123/00456"),
+ (["{{#start.guide_program_id#}}", "{{#start.guide_item_id#}}", "constant"], ["123", "00456", "constant"]),
+ (0, 0),
+ (None, None),
+ ("", ""),
+ ],
+)
+def test_metadata_filters_resolve_templates_and_lists_preserving_constants(value, expected):
+ context = _metadata_filter_context()
+ soul = context.snapshot.config_snapshot_dict
+ condition = soul["knowledge"]["sets"][0]["metadata_filtering"]["conditions"]["conditions"][0]
+ condition.update(value=value, id="condition-1", metadata_id="program-metadata")
+ context.snapshot.config_snapshot = soul
+ pool = VariablePool()
+ pool.add(["start", "guide_program_id"], 123)
+ pool.add(["start", "guide_item_id"], "00456")
+ result = WorkflowAgentRuntimeRequestBuilder().build(replace(context, variable_pool=pool))
+ conditions = _request_layers(result)["knowledge"]["config"]["sets"][0]["metadata_filtering"]["conditions"][
+ "conditions"
+ ]
+ assert conditions[0] == {"name": "guide_program_id", "comparison_operator": "=", "value": expected}
+
+
+@pytest.mark.parametrize("mode", ["disabled", "automatic"])
+def test_inactive_manual_filters_do_not_require_workflow_variables(mode):
+ context = _metadata_filter_context()
+ soul = context.snapshot.config_snapshot_dict
+ soul["knowledge"]["sets"][0]["metadata_filtering"]["mode"] = mode
+ if mode == "automatic":
+ soul["knowledge"]["sets"][0]["metadata_filtering"]["model_config"] = {
+ "provider": "openai",
+ "name": "gpt-test",
+ "mode": "chat",
+ "completion_params": {},
+ }
+ context.snapshot.config_snapshot = soul
+ result = WorkflowAgentRuntimeRequestBuilder().build(replace(context, variable_pool=VariablePool()))
+ assert _request_layers(result)["knowledge"]["config"]["sets"][0]["metadata_filtering"]["mode"] == mode
+
+
def _workflow_user_prompt(result) -> str:
from clients.agent_backend import WORKFLOW_USER_PROMPT_LAYER_ID
diff --git a/api/tests/unit_tests/libs/test_workflow_template_values.py b/api/tests/unit_tests/libs/test_workflow_template_values.py
new file mode 100644
index 00000000000000..9ef7025b8bc0bf
--- /dev/null
+++ b/api/tests/unit_tests/libs/test_workflow_template_values.py
@@ -0,0 +1,27 @@
+import pytest
+
+from graphon.runtime.variable_pool import VariablePool
+from libs.workflow_template_values import resolve_template_value, template_value_selectors
+
+
+@pytest.mark.parametrize("value", [0, 1.5, None])
+def test_constant_filter_values_do_not_request_workflow_variables(value: int | float | None) -> None:
+ assert list(template_value_selectors(value)) == []
+
+
+@pytest.mark.parametrize(
+ ("value", "expected"),
+ [
+ (True, "True"),
+ (False, "False"),
+ (["I10", "I20"], "['I10', 'I20']"),
+ ({"guide_item_id": "I10"}, "{'guide_item_id': 'I10'}"),
+ ],
+)
+def test_boolean_and_collection_filter_references_resolve_to_strings(
+ value: bool | list[str] | dict[str, str], expected: str
+) -> None:
+ pool = VariablePool()
+ pool.add(["start", "filter_value"], value)
+
+ assert resolve_template_value("{{#start.filter_value#}}", pool) == expected
diff --git a/api/tests/unit_tests/pyrefly.toml b/api/tests/unit_tests/pyrefly.toml
index ac608fef71b6a4..62936434dd4f13 100644
--- a/api/tests/unit_tests/pyrefly.toml
+++ b/api/tests/unit_tests/pyrefly.toml
@@ -18,11 +18,9 @@ project-excludes = [
"commands/test_clean_expired_messages.py",
"commands/test_data_migration_commands.py",
"commands/test_data_migration_wizard.py",
- "commands/test_fix_app_site_missing.py",
"commands/test_generate_swagger_markdown_docs.py",
"commands/test_generate_swagger_specs.py",
"commands/test_lint_response_contracts.py",
- "commands/test_reset_encrypt_key_pair.py",
"commands/test_upgrade_db.py",
"configs/test_dify_config.py",
"configs/test_env_consistency.py",
diff --git a/packages/dify-ui/docs/selection.md b/packages/dify-ui/docs/selection.md
index 167622ceadf36b..5fdf3989e6039b 100644
--- a/packages/dify-ui/docs/selection.md
+++ b/packages/dify-ui/docs/selection.md
@@ -29,6 +29,13 @@ Autocomplete, Combobox, and Select popups use Base UI's `--anchor-width` and `--
variables to follow their trigger while clamping to the viewport. Do not replace that sizing with
a fixed width or an unclamped minimum width.
+When a Combobox renders its input inside the popup, do not add a control that clears the typed
+query. The query is per-open state that Base UI resets: choosing an item clears it in `multiple`
+mode and closes the popup in single mode, and closing the popup (Escape, outside press) clears it.
+A clear control there adds a second tab stop to the popup's focus loop and a second x that means
+something other than `ComboboxClear`, which clears the selected value. Outside the popup, use
+`ComboboxClear` or `AutocompleteClear`.
+
Use `Radio` for the default radio appearance. Use `RadioItem` when custom content itself is a
radio item, and put `RadioControl` inside it for Dify UI's visual indicator. `RadioControl` is a
visual part, not a standalone radio.
diff --git a/packages/dify-ui/src/autocomplete/index.stories.tsx b/packages/dify-ui/src/autocomplete/index.stories.tsx
index b47cd73a9cbc71..7aabbd20741597 100644
--- a/packages/dify-ui/src/autocomplete/index.stories.tsx
+++ b/packages/dify-ui/src/autocomplete/index.stories.tsx
@@ -300,7 +300,7 @@ function SuggestionItem({ item, dense }: { item: Suggestion; dense?: boolean })
)}
-
{item.label}
+
{item.label}
{!dense && item.description && (
{item.description}
)}
@@ -330,7 +330,7 @@ function VirtualizedSuggestionItem({
)}
-
{item.label}
+
{item.label}
{!dense && item.description && (
{item.description}
)}
@@ -347,7 +347,7 @@ function VirtualizedSuggestionItem({
function TagSuggestionItem({ item }: { item: Suggestion }) {
return (
- {item.label}
+ {item.label}
{item.description && (
{item.description}
@@ -367,7 +367,7 @@ function BasicTagAutocomplete({ size = 'medium' }: { size?: 'small' | 'medium' |
>
{groups.map((group, groupIndex) => (
- 0 ? 'mt-1 border-t border-divider-subtle pt-2' : undefined}
- >
- {group.label}
-
+ {groupIndex > 0 && }
+ {group.label}
>
{(item) => (
@@ -433,7 +430,7 @@ function CommandPaletteList() {
/>
)}
- {item.label}
+ {item.label}
{item.description}
@@ -515,7 +512,7 @@ function AsyncSearchDemo() {
>
()
return (
-
+
{filteredItems.length} matching suggestions. Selecting one only replaces the input text.
)
@@ -647,7 +644,7 @@ function FuzzyMatchingDemo() {
>
)}
-
+
@@ -738,7 +735,7 @@ export const InlineAutocomplete: Story = {
>
-
+
No suggestion. Use the text as entered.
@@ -813,7 +810,7 @@ export const LimitResults: Story = {
>
-
-
+
+
>
@@ -855,7 +852,7 @@ export const CommandPalette: Story = {
>
-
+
No suggestion. Free-form text is still valid.
@@ -932,7 +929,7 @@ export const Empty: Story = {
>
({ className, ...props }: Autocomplete
type AutocompleteItemTextProps = React.ComponentProps<'span'>
function AutocompleteItemText({ className, ...props }: AutocompleteItemTextProps) {
- return (
-
- )
+ return
}
type AutocompleteGroupLabelProps = BaseAutocomplete.GroupLabel.Props
+// The list's own padding supplies the rest of the inset, so the label text lines up with the
+// item text.
function AutocompleteGroupLabel({ className, ...props }: AutocompleteGroupLabelProps) {
return (
cn(floatingGroupLabelClassName, resolveClassName(className, state))}
+ className={(state) =>
+ cn(floatingGroupLabelClassName, 'px-2', resolveClassName(className, state))
+ }
{...props}
/>
)
@@ -376,7 +381,10 @@ function AutocompleteStatus({ className, ...props }: AutocompleteStatusProps) {
return (
- cn('px-3 py-2 system-sm-regular text-text-tertiary', resolveClassName(className, state))
+ cn(
+ 'px-3 py-2 system-sm-regular text-text-tertiary empty:p-0',
+ resolveClassName(className, state),
+ )
}
{...props}
/>
diff --git a/packages/dify-ui/src/combobox/index.stories.tsx b/packages/dify-ui/src/combobox/index.stories.tsx
index 5e14fc361b261f..f05ed771607858 100644
--- a/packages/dify-ui/src/combobox/index.stories.tsx
+++ b/packages/dify-ui/src/combobox/index.stories.tsx
@@ -300,13 +300,8 @@ async function searchOptions(
}
const renderOptionItem = (option: Option) => (
-
-
+
+
{option.icon && (
)}
@@ -337,9 +332,8 @@ const renderVirtualizedOptionItem = (option: Option, index: number, itemCount: n
aria-posinset={index + 1}
aria-setsize={itemCount}
disabled={option.disabled}
- className="h-auto min-h-8 py-1.5"
>
-
+
{option.icon && (
)}
@@ -451,11 +445,7 @@ function VirtualizedModelList({
function FilteredModelStatus() {
const filteredItems = useComboboxFilteredItems
-
{
+ void reloadMetadata()
+ }}
metadataFilterMode={metadataFilterMode}
metadataFilteringConditions={metadataFilteringConditions}
handleMetadataFilterModeChange={(nextMode) =>
@@ -731,6 +776,7 @@ function AgentKnowledgeRetrievalDialogContent({
}
export function AgentKnowledgeRetrievalDialog({
+ workflowNodeId,
item,
initialName,
onItemCreate,
@@ -745,6 +791,7 @@ export function AgentKnowledgeRetrievalDialog({
className="flex h-130 max-h-[calc(100dvh-2rem)] w-100 flex-col overflow-hidden p-0"
>
& {
+ workflowNodeId?: string
+ metadataUnavailable?: boolean
+ onRetryMetadata?: () => void
+}
+
+const filterStringVar = (variable: Var) => variable.type === VarType.string
+const filterNumberVar = (variable: Var) => variable.type === VarType.number
+
+function WorkflowMetadataFilter({ workflowNodeId, ...props }: Props & { workflowNodeId: string }) {
+ const strings = useAvailableVarList(workflowNodeId, {
+ onlyLeafNodeVar: false,
+ filterVar: filterStringVar,
+ })
+ const numbers = useAvailableVarList(workflowNodeId, {
+ onlyLeafNodeVar: false,
+ filterVar: filterNumberVar,
+ })
+
+ return (
+
+ )
+}
+
+export function AgentMetadataFilter({
+ workflowNodeId,
+ metadataUnavailable = false,
+ onRetryMetadata,
+ ...props
+}: Props) {
+ const { t } = useTranslation(['appApi', 'common'])
+ // Condition editors initialize their value mode from the metadata type.
+ // Mount them only once definitions are available so saved variables stay variables.
+ if (props.selectedDatasetsLoaded === false) {
+ if (metadataUnavailable) {
+ return (
+
+
+ {t(($) => $['dynamicSelect.error'], { ns: 'common' })}
+
+
+
+
+
+
+ )
+ }
+ return (
+
+ $.loading, { ns: 'appApi' })} />
+
+ )
+ }
+
+ // Standalone Agent composers do not have a workflow canvas or variable scope.
+ if (workflowNodeId) return
+
+ return
+}
diff --git a/web/features/agent-v2/agent-detail/logs/components/source-picker.tsx b/web/features/agent-v2/agent-detail/logs/components/source-picker.tsx
index 9d817b252e6a44..36a55bd02f644c 100644
--- a/web/features/agent-v2/agent-detail/logs/components/source-picker.tsx
+++ b/web/features/agent-v2/agent-detail/logs/components/source-picker.tsx
@@ -164,7 +164,7 @@ export function AgentLogSourcePicker({
className="min-h-7 grid-cols-[1fr] gap-0 px-1 py-1"
render={(props, state) => (
-
+
{source.app_name}
diff --git a/web/features/skills/detail/skill-metadata.tsx b/web/features/skills/detail/skill-metadata.tsx
index cffd3a9fe00708..b3d6e8544ae0af 100644
--- a/web/features/skills/detail/skill-metadata.tsx
+++ b/web/features/skills/detail/skill-metadata.tsx
@@ -247,8 +247,6 @@ export function SkillTagsEditor({
setShowTagManagement(true)}
diff --git a/web/features/tag-management/__tests__/tag-filter.spec.tsx b/web/features/tag-management/__tests__/tag-filter.spec.tsx
index 04d642f4d3b6cf..1219a15bfa0307 100644
--- a/web/features/tag-management/__tests__/tag-filter.spec.tsx
+++ b/web/features/tag-management/__tests__/tag-filter.spec.tsx
@@ -299,30 +299,6 @@ describe('TagFilter', () => {
expect(screen.getByText(i18n.noTag)).toBeInTheDocument()
expect(searchInput).toHaveFocus()
})
-
- it('should clear search and show all tags when clear icon is clicked', async () => {
- const user = userEvent.setup()
-
- render()
-
- await user.click(screen.getByText(i18n.placeholder))
-
- const searchInput = screen.getByRole('combobox', { name: i18n.selectorPlaceholder })
- await user.type(searchInput, 'Front')
-
- expect(screen.queryByText('Backend')).not.toBeInTheDocument()
-
- const clearButton = screen.getByRole('button', { name: i18n.operationClear })
- clearButton.focus()
- await user.keyboard('{Enter}')
-
- expect(searchInput).toHaveValue('')
- expect(searchInput).toHaveFocus()
-
- expect(screen.getByText('Backend')).toBeInTheDocument()
- expect(screen.getByText('Frontend')).toBeInTheDocument()
- expect(screen.getByText('API Design')).toBeInTheDocument()
- })
})
describe('Edge Cases', () => {
diff --git a/web/features/tag-management/__tests__/tag-search-content.spec.tsx b/web/features/tag-management/__tests__/tag-search-content.spec.tsx
index cb4ab21e981b0d..913e24ac11ed99 100644
--- a/web/features/tag-management/__tests__/tag-search-content.spec.tsx
+++ b/web/features/tag-management/__tests__/tag-search-content.spec.tsx
@@ -109,8 +109,6 @@ const PanelHarness = ({
>
@@ -144,29 +142,6 @@ describe('TagSearchContent', () => {
expect(screen.queryByRole('option', { name: /API/i })).not.toBeInTheDocument()
})
- it('clears only the search input from the input clear button', async () => {
- const user = userEvent.setup()
- render()
-
- const input = screen.getByRole('combobox', { name: i18n.selectorPlaceholder })
- await user.type(input, 'Back')
- expect(input).toHaveValue('Back')
- vi.clearAllMocks()
-
- const clearButton = screen.getByRole('button', { name: i18n.operationClear })
- await user.pointer({ keys: '[MouseLeft>]', target: clearButton })
- expect(input).toHaveFocus()
- await user.pointer({ keys: '[/MouseLeft]', target: clearButton })
-
- expect(input).toHaveValue('')
- expect(input).toHaveFocus()
- expect(onValueChangeSpy).not.toHaveBeenCalled()
- expect(screen.getByRole('option', { name: /Frontend/i })).toHaveAttribute(
- 'aria-selected',
- 'true',
- )
- })
-
it('shows a create option when the query is not an existing tag name', async () => {
const user = userEvent.setup()
render()
diff --git a/web/features/tag-management/components/tag-filter.tsx b/web/features/tag-management/components/tag-filter.tsx
index 14a3104d25a35e..86a40a870bc5f6 100644
--- a/web/features/tag-management/components/tag-filter.tsx
+++ b/web/features/tag-management/components/tag-filter.tsx
@@ -171,8 +171,6 @@ export const TagFilter = ({
>
void
onOpenTagManagement?: () => void
onClose?: () => void
canBindOrUnbindTags?: boolean
@@ -36,8 +32,6 @@ type TagSearchContentViewProps = TagSearchContentProps & {
export const TagSearchContentView = ({
type,
- inputValue,
- onInputValueChange,
onOpenTagManagement,
onClose,
canBindOrUnbindTags = false,
@@ -45,42 +39,24 @@ export const TagSearchContentView = ({
canManageTags,
}: TagSearchContentViewProps) => {
const { t } = useTranslation(['common'])
- const inputRef = useRef(null)
const placeholder = t(($) => $['tag.selectorPlaceholder'], { ns: 'common' }) || ''
- const handleClearInput = () => {
- onInputValueChange('')
- inputRef.current?.focus()
- }
-
return (
<>
- {inputValue && (
- $['operation.clear'], { ns: 'common' })}
- className="shrink-0 hover:bg-components-input-bg-hover focus-visible:bg-components-input-bg-hover"
- onClick={handleClearInput}
- onMouseDown={(event) => event.preventDefault()}
- >
-
-
- )}
className="max-h-58">
{(tag) => {
if (isCreateTagOption(tag) && canManageTags) {
return (
key={tag.id} value={tag.id}>
-
+
handleOpenChange(false)}