mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
253 lines
11 KiB
Python
253 lines
11 KiB
Python
"""Builder reference policy for the native ApiTool message envelope.
|
|
|
|
The native implementation emits JSON/text messages, never named response fields.
|
|
Its HTTP response declaration and model-generated output_schema therefore cannot
|
|
expand the node's outputs. Legacy or unknown tool identities retain the generator's
|
|
existing dynamic-output handling; this owner performs no metadata/runtime lookup.
|
|
"""
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
from re import Pattern
|
|
from uuid import UUID
|
|
|
|
_NATIVE_OUTPUTS = frozenset({"text", "json", "files"})
|
|
_SELECTOR_FIELDS = frozenset({"value_selector", "variable_selector"})
|
|
_NODE_SELECTOR_FIELDS = {
|
|
"iteration": ("iterator_selector", "output_selector"),
|
|
"knowledge-retrieval": ("query_variable_selector", "query_attachment_selector"),
|
|
"question-classifier": ("query_variable_selector",),
|
|
"list-operator": ("variable",),
|
|
"parameter-extractor": ("query",),
|
|
"assigner": ("assigned_variable_selector", "input_variable_selector"),
|
|
}
|
|
_AGGREGATOR_TYPES = frozenset({"variable-aggregator", "variable-assigner"})
|
|
# Declaration/default subtrees and literal values remain data even when a
|
|
# business object happens to contain a key that looks like a selector field.
|
|
_NON_REFERENCE_FIELDS = frozenset(
|
|
{
|
|
"value",
|
|
"default",
|
|
"options",
|
|
"allowed_file_types",
|
|
"allowed_file_extensions",
|
|
"allowed_file_upload_methods",
|
|
"structured_output",
|
|
"required",
|
|
"enum",
|
|
"type",
|
|
"output_schema",
|
|
"input_schema",
|
|
"parameter_schema",
|
|
"paramSchemas",
|
|
"schema",
|
|
"metadata",
|
|
"tool_configurations",
|
|
"example",
|
|
"examples",
|
|
}
|
|
)
|
|
|
|
|
|
def is_native_api_tool(data: Mapping[str, object]) -> bool:
|
|
"""Recognize the native API identity carried by catalogue-hydrated nodes."""
|
|
if data.get("type") != "tool" or data.get("provider_type") != "api":
|
|
return False
|
|
provider_id = data.get("provider_id")
|
|
if not isinstance(provider_id, str):
|
|
return False
|
|
try:
|
|
return str(UUID(provider_id)) == provider_id
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def declares_native_api_output(variable: str) -> bool:
|
|
"""Only whole native envelopes are referenceable; parse their values in Code.
|
|
|
|
``text`` is scalar and ``json``/``files`` are arrays. A business root or a
|
|
dotted/index path cannot become a named output through a response schema.
|
|
"""
|
|
return variable in _NATIVE_OUTPUTS
|
|
|
|
|
|
def native_api_output_error_hint() -> str:
|
|
"""Return an actionable static hint without provider labels or input values."""
|
|
return (
|
|
"; native api tools expose only the standard roots text, files, json. "
|
|
"The json output is an array of message payloads, not named response fields; text is scalar. "
|
|
"Pass the whole json or text envelope to a native Code node, explicitly parse and validate "
|
|
"the response there, and declare typed outputs before consuming business fields."
|
|
)
|
|
|
|
|
|
def collect_native_api_selector_refs(
|
|
nodes: Sequence[Mapping[str, object]], *, placeholder_pattern: Pattern[str]
|
|
) -> set[tuple[str, str]]:
|
|
"""Collect authoritative references for known native API producers.
|
|
|
|
Only selector fields and typed variable input values prove that a list is a
|
|
reference. Real placeholders outside literal/declaration subtrees use the
|
|
caller's existing runtime-matching pattern, preserving its syntax protocol.
|
|
Callers replace generic refs to known API producers with this set; other
|
|
providers retain the generic walk. This also deduplicates error references.
|
|
No Code execution, credential inspection, or metadata lookup occurs here.
|
|
"""
|
|
known_ids: set[str] = set()
|
|
for node in nodes:
|
|
node_id = node.get("id")
|
|
data = node.get("data")
|
|
if isinstance(node_id, str) and isinstance(data, Mapping):
|
|
if is_native_api_tool(data):
|
|
known_ids.add(node_id)
|
|
refs: set[tuple[str, str]] = set()
|
|
|
|
def collect_selector(value: object) -> None:
|
|
if not isinstance(value, list) or len(value) < 2:
|
|
return
|
|
if not all(isinstance(part, str) for part in value):
|
|
return
|
|
selector = [part.strip() for part in value if isinstance(part, str)]
|
|
if selector[0] in known_ids and all(selector):
|
|
refs.add((selector[0], ".".join(selector[1:])))
|
|
|
|
def walk(value: object) -> None:
|
|
if isinstance(value, str):
|
|
for match in placeholder_pattern.finditer(value):
|
|
node_id, variable = match.group(1).strip(), match.group(2).strip()
|
|
if node_id in known_ids and variable:
|
|
refs.add((node_id, variable))
|
|
elif isinstance(value, Mapping):
|
|
fields = value
|
|
if fields.get("type") == "constant":
|
|
return
|
|
for name in _SELECTOR_FIELDS:
|
|
if name in fields:
|
|
collect_selector(fields[name])
|
|
if fields.get("type") == "variable":
|
|
collect_selector(fields.get("value"))
|
|
for name, item in fields.items():
|
|
if name == "value":
|
|
# Runtime comparisons and rendered body/input fields use
|
|
# string templates. Arrays/objects and typed constants stay data.
|
|
if (
|
|
isinstance(item, str)
|
|
and fields.get("input_type") != "constant"
|
|
and fields.get("value_type") != "constant"
|
|
):
|
|
walk(item)
|
|
continue
|
|
if fields.get("type") == "code" and name == "code":
|
|
# Python source is static; Code inputs use declared selectors.
|
|
continue
|
|
if fields.get("type") == "llm" and name == "context":
|
|
if not isinstance(item, Mapping) or not item.get("enabled"):
|
|
continue
|
|
if fields.get("type") in ("llm", "question-classifier", "parameter-extractor") and name == "vision":
|
|
if not isinstance(item, Mapping) or not item.get("enabled"):
|
|
continue
|
|
if fields.get("type") == "http-request" and name == "body":
|
|
# Body references are selected by the active HTTP body branch.
|
|
continue
|
|
if fields.get("type") == "list-operator" and name == "filter_by":
|
|
if not isinstance(item, Mapping) or not item.get("enabled"):
|
|
continue
|
|
if name in _NON_REFERENCE_FIELDS:
|
|
continue
|
|
if name == "outputs" and isinstance(item, Mapping):
|
|
# Code outputs are declarations; End outputs are selector lists.
|
|
continue
|
|
if fields.get("type") == "start" and name == "variables":
|
|
continue
|
|
if fields.get("type") in ("variable-aggregator", "variable-assigner") and name in {
|
|
"variables",
|
|
"advanced_settings",
|
|
}:
|
|
continue
|
|
if fields.get("type") == "human-input" and name == "inputs":
|
|
continue
|
|
walk(item)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
walk(item)
|
|
|
|
def mapping_items(value: object) -> list[Mapping[str, object]]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
return [item for item in value if isinstance(item, Mapping)]
|
|
|
|
def collect_selector_list(value: object) -> None:
|
|
if isinstance(value, list):
|
|
for selector in value:
|
|
collect_selector(selector)
|
|
|
|
def collect_runtime_fields(data: Mapping[str, object]) -> None:
|
|
node_type = data.get("type")
|
|
if not isinstance(node_type, str):
|
|
return
|
|
for name in _NODE_SELECTOR_FIELDS.get(node_type, ()):
|
|
collect_selector(data.get(name))
|
|
if node_type in _AGGREGATOR_TYPES:
|
|
settings = data.get("advanced_settings")
|
|
if isinstance(settings, Mapping) and settings.get("group_enabled"):
|
|
for group in mapping_items(settings.get("groups")):
|
|
collect_selector_list(group.get("variables"))
|
|
else:
|
|
collect_selector_list(data.get("variables"))
|
|
elif node_type == "human-input":
|
|
# StringSource/StringListSource retain their old constant value when
|
|
# switched to variable mode. Only the active selector is a reference.
|
|
for form_input in mapping_items(data.get("inputs")):
|
|
source = None
|
|
if form_input.get("type") == "paragraph":
|
|
source = form_input.get("default")
|
|
elif form_input.get("type") == "select":
|
|
source = form_input.get("option_source")
|
|
if isinstance(source, Mapping) and source.get("type") == "variable":
|
|
collect_selector(source.get("selector"))
|
|
elif node_type == "assigner":
|
|
for item in mapping_items(data.get("items")):
|
|
if item.get("input_type") == "variable":
|
|
collect_selector(item.get("value"))
|
|
elif node_type == "loop":
|
|
for variable in mapping_items(data.get("loop_variables")):
|
|
if variable.get("value_type") == "variable":
|
|
collect_selector(variable.get("value"))
|
|
elif node_type == "http-request":
|
|
body = data.get("body")
|
|
if isinstance(body, Mapping):
|
|
body_type = body.get("type")
|
|
entries = mapping_items(body.get("data"))
|
|
if body_type == "binary":
|
|
if len(entries) == 1:
|
|
collect_selector(entries[0].get("file"))
|
|
elif body_type in ("json", "raw-text", "x-www-form-urlencoded", "form-data"):
|
|
if body_type in ("json", "raw-text"):
|
|
entries = entries[:1]
|
|
for entry in entries:
|
|
key = entry.get("key")
|
|
if isinstance(key, str):
|
|
walk(key)
|
|
if body_type == "form-data" and entry.get("type") != "text":
|
|
collect_selector(entry.get("file"))
|
|
else:
|
|
value = entry.get("value")
|
|
if isinstance(value, str):
|
|
walk(value)
|
|
elif node_type in ("tool", "agent"):
|
|
parameters = data.get("tool_parameters" if node_type == "tool" else "agent_parameters")
|
|
if isinstance(parameters, Mapping):
|
|
for binding in parameters.values():
|
|
if isinstance(binding, Mapping) and binding.get("type") in ("mixed", "constant"):
|
|
value = binding.get("value")
|
|
# Tool/Agent render mixed and constant string inputs.
|
|
# Literal array/object contents are not selectors.
|
|
if isinstance(value, str):
|
|
walk(value)
|
|
|
|
for node in nodes:
|
|
data = node.get("data")
|
|
if isinstance(data, Mapping):
|
|
collect_runtime_fields(data)
|
|
walk(data)
|
|
return refs
|