Files
dify/api/services/dify_builder/json_tool_runtime.py
Charles Yao 231e423431 Integrate validated Builder backend testing and CI repairs
Track backend integration, Ruff cleanup and migration reconciliation in ENG-1271. Retain required copy contracts without implementing frontend interactions.
2026-10-20 15:40:06 -07:00

413 lines
18 KiB
Python

"""Request-bound native JSON Parse Tool adapter.
Only trusted in-process callers can issue a binding. Graphon's Tool node still
resolves its LLM inputs and the native Tool/PluginTool retain type conversion
and daemon dispatch. This adapter guards the final scalar call and TEXT stream.
"""
from collections.abc import Callable, Generator, Mapping
from dataclasses import dataclass
from typing import Any, Protocol, override
from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.app_invoke_entities import DifyRunContext
from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler
from core.db.session_factory import session_factory
from core.tools.entities.tool_entities import ToolInvokeFrom, ToolInvokeMessage, ToolProviderType
from core.tools.plugin_tool.tool import PluginTool
from core.tools.tool_engine import ToolEngine
from core.tools.tool_manager import ToolManager
from core.tools.utils.message_transformer import ToolFileMessageTransformer
from core.workflow.node_runtime import DifyToolNodeRuntime
from graphon.nodes.tool.entities import ToolNodeData
from graphon.nodes.tool.exc import ToolRuntimeInvocationError, ToolRuntimeResolutionError
from graphon.nodes.tool_runtime_entities import ToolRuntimeHandle, ToolRuntimeMessage
from runtime_contracts.builder.execution import (
BuilderExecutionObservation,
BuilderExecutionPolicyError,
BuilderExecutionRecorder,
JsonToolExecutionContext,
canonical_digest,
context_digest,
)
from services.dify_builder.json_tool_execution import validate_json_parameters
from services.dify_builder.json_tool_metadata import (
PROVIDER_ID,
TOOL_NAME,
UNIQUE_IDENTIFIER,
InstalledJsonParse,
require_same_json_installation,
resolve_installed_json_parse,
validate_current_json_controller,
)
@dataclass(frozen=True, slots=True)
class JsonRuntimeBinding:
"""Private issuer state; never part of graph data or persisted context."""
context: JsonToolExecutionContext
native_run_id: str
native_task_id: str
native_node_execution_id: str
expected_parameters: Mapping[str, str | bool]
recorder: BuilderExecutionRecorder
metadata_reader: Callable[[str], InstalledJsonParse] = lambda tenant_id: resolve_installed_json_parse(
tenant_id=tenant_id
)
class JsonToolRuntimeIssuer(Protocol):
def json_runtime_binding(
self, *, node_id: str, native_run_id: str, native_node_execution_id: str
) -> JsonRuntimeBinding: ...
class _GuardedJsonPluginTool(PluginTool):
"""Preserve PluginTool.invoke and its conversion; guard its final _invoke."""
def __init__(self, native: PluginTool, expected_parameters: Mapping[str, str | bool]):
super().__init__(
entity=native.entity,
runtime=native.runtime,
tenant_id=native.tenant_id,
icon=native.icon,
plugin_unique_identifier=native.plugin_unique_identifier,
)
self._expected_parameters = dict(expected_parameters)
self._texts: list[str] = []
self._text_bytes = 0
self._invoked = False
self._complete = False
self._active_stream: Generator[ToolInvokeMessage] | None = None
self._guard_stream: Generator[ToolInvokeMessage] | None = None
@property
def text_output(self) -> str:
return "".join(self._texts)
@property
def text_bytes(self) -> int:
return self._text_bytes
@property
def text_messages(self) -> int:
return len(self._texts)
@property
def complete(self) -> bool:
return self._complete
def close_stream(self) -> None:
if self._guard_stream is not None:
self._guard_stream.close()
self._guard_stream = None
if self._active_stream is not None:
self._active_stream.close()
self._active_stream = None
@override
def invoke(
self,
session: Session,
user_id: str,
tool_parameters: dict[str, Any],
conversation_id: str | None = None,
app_id: str | None = None,
message_id: str | None = None,
) -> Generator[ToolInvokeMessage]:
# super() is Tool.invoke: declared type casting remains native.
stream = super().invoke(session, user_id, tool_parameters, conversation_id, app_id, message_id)
self._guard_stream = stream
return stream
@override
def _invoke(
self,
session: Session,
user_id: str,
tool_parameters: dict[str, Any],
conversation_id: str | None = None,
app_id: str | None = None,
message_id: str | None = None,
) -> Generator[ToolInvokeMessage]:
if self._invoked:
raise BuilderExecutionPolicyError("json_tool_invocation_replayed")
self._invoked = True
if session.in_transaction():
raise BuilderExecutionPolicyError("json_tool_session_in_transaction")
validate_json_parameters(tool_parameters)
if tool_parameters != self._expected_parameters or canonical_digest(tool_parameters) != canonical_digest(
self._expected_parameters
):
raise BuilderExecutionPolicyError("json_tool_parameters_changed")
messages = super()._invoke(
session=session,
user_id=user_id,
tool_parameters=tool_parameters,
conversation_id=conversation_id,
app_id=app_id,
message_id=message_id,
)
self._active_stream = messages
try:
for message in messages:
if session.in_transaction():
raise BuilderExecutionPolicyError("json_tool_session_in_transaction")
if (
message.type != ToolInvokeMessage.MessageType.TEXT
or type(message.message) is not ToolInvokeMessage.TextMessage
):
raise BuilderExecutionPolicyError("unsupported_json_tool_output")
text = message.message.text
if type(text) is not str or len(text) > 65536:
raise BuilderExecutionPolicyError("json_execution_limit")
try:
size = len(text.encode("utf-8", errors="strict"))
except UnicodeError:
raise BuilderExecutionPolicyError("unsupported_json_tool_output") from None
if len(self._texts) >= 8 or self._text_bytes + size > 65536:
raise BuilderExecutionPolicyError("json_execution_limit")
self._texts.append(text)
self._text_bytes += size
yield message
self._complete = True
finally:
messages.close()
self._active_stream = None
class QualifiedJsonToolNodeRuntime(DifyToolNodeRuntime):
"""One owned JSON Tool node; Graphon's native Tool protocol remains in use."""
def __init__(
self,
run_context: Mapping[str, Any] | DifyRunContext,
*,
native_run_id: str,
native_task_id: str,
issuer: JsonToolRuntimeIssuer,
session_maker: sessionmaker[Session] | None = None,
) -> None:
super().__init__(run_context, session_maker=session_maker)
self._native_run_id = native_run_id
self._native_task_id = native_task_id
self._issuer = issuer
self._binding: JsonRuntimeBinding | None = None
self._tool: _GuardedJsonPluginTool | None = None
self._handle: ToolRuntimeHandle | None = None
self._pre_installation: InstalledJsonParse | None = None
self._spent = False
self._invoke_started = False
self._healthy = True
@staticmethod
@override
def _require_no_transaction(session: Session) -> None:
if session.in_transaction():
raise BuilderExecutionPolicyError("json_tool_session_in_transaction")
@override
def build_file_reference(self, *, mapping: Mapping[str, Any]):
raise BuilderExecutionPolicyError("unsupported_json_tool_file")
@override
def get_runtime(
self,
*,
node_id: str,
node_data: ToolNodeData,
variable_pool,
node_execution_id: str | None = None,
) -> ToolRuntimeHandle:
if self._binding is not None or self._spent or not self._healthy:
raise ToolRuntimeResolutionError("json_tool_runtime_replayed")
# Spend resolution before an issuer or transport can fail.
self._spent = True
try:
if type(node_execution_id) is not str or not node_execution_id or variable_pool is None:
raise BuilderExecutionPolicyError("execution_launch_mismatch")
binding = self._issuer.json_runtime_binding(
node_id=node_id, native_run_id=self._native_run_id, native_node_execution_id=node_execution_id
)
context = binding.context
if (
binding.native_run_id != self._native_run_id
or binding.native_task_id != self._native_task_id
or binding.native_node_execution_id != node_execution_id
or context.qualified_tool.node_id != node_id
or context.tenant_id != self._run_context.tenant_id
or context.app_id != self._run_context.app_id
or context.actor_id != self._run_context.user_id
or context.context_digest != context_digest(context)
or not binding.recorder.healthy
):
raise BuilderExecutionPolicyError("execution_launch_mismatch")
validate_json_parameters(binding.expected_parameters)
if canonical_digest(binding.expected_parameters) != context.qualified_tool.final_parameters_digest:
raise BuilderExecutionPolicyError("json_tool_parameters_changed")
self._validate_native_node(node_data, binding.expected_parameters)
with (self._session_maker or session_factory.get_session_maker())() as session:
self._require_no_transaction(session)
installation = binding.metadata_reader(context.tenant_id)
self._require_no_transaction(session)
require_same_json_installation(context.qualified_tool, installation)
spec = self._build_tool_runtime_spec(node_data)
# A raw persisted bool is admitted, but the v2 FORM converter
# requires native ToolInput shape. Do not alter node_data/graph.
if type(spec.tool_configurations["ensure_ascii"]) is bool:
spec.tool_configurations["ensure_ascii"] = {
"type": "constant",
"value": spec.tool_configurations["ensure_ascii"],
}
native = ToolManager.get_tool_runtime(
provider_type=spec.provider_type,
provider_id=spec.provider_id,
tool_name=spec.tool_name,
tenant_id=context.tenant_id,
user_id=self._run_context.user_id,
invoke_from=self._run_context.invoke_from,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
credential_id=spec.credential_id,
json_provider_validator=lambda controller, tool: validate_current_json_controller(
controller, tool, expected=installation
),
)
self._require_no_transaction(session)
if type(native) is not PluginTool:
raise BuilderExecutionPolicyError("json_tool_controller_mismatch")
converted = ToolManager._convert_tool_parameters_type(
native.get_merged_runtime_parameters(), variable_pool, spec.tool_configurations, typ="workflow"
)
self._require_no_transaction(session)
if converted != {"ensure_ascii": binding.expected_parameters["ensure_ascii"]}:
raise BuilderExecutionPolicyError("json_tool_parameters_changed")
native.runtime.runtime_parameters.update(converted)
guarded = _GuardedJsonPluginTool(native, binding.expected_parameters)
handle = ToolRuntimeHandle(raw=guarded)
self._binding = binding
self._tool = guarded
self._handle = handle
self._pre_installation = installation
return handle
except Exception as exc:
self._healthy = False
reason = exc.reason_code if isinstance(exc, BuilderExecutionPolicyError) else "json_tool_resolution_failed"
raise ToolRuntimeResolutionError(str(BuilderExecutionPolicyError(reason))) from None
@staticmethod
def _validate_native_node(node_data: ToolNodeData, expected: Mapping[str, str | bool]) -> None:
if (
node_data.provider_type.value != ToolProviderType.BUILT_IN.value
or node_data.provider_id != PROVIDER_ID
or node_data.provider_name != PROVIDER_ID
or node_data.plugin_unique_identifier not in (None, UNIQUE_IDENTIFIER)
or node_data.tool_name != TOOL_NAME
or node_data.tool_node_version != "2"
or node_data.credential_id is not None
or set(node_data.tool_configurations) != {"ensure_ascii"}
or set(node_data.tool_parameters)
not in ({"content", "json_filter"}, {"content", "json_filter", "ensure_ascii"})
):
raise BuilderExecutionPolicyError("unsupported_json_tool_configuration")
flag = node_data.tool_configurations["ensure_ascii"]
if type(flag) is not bool or flag is not expected["ensure_ascii"]:
raise BuilderExecutionPolicyError("unsupported_json_tool_configuration")
if "ensure_ascii" in node_data.tool_parameters:
derived = node_data.tool_parameters["ensure_ascii"]
if derived.model_dump(mode="python") != {"type": "constant", "value": flag}:
raise BuilderExecutionPolicyError("unsupported_json_tool_configuration")
@override
def invoke(
self,
*,
tool_runtime: ToolRuntimeHandle,
tool_parameters: Mapping[str, Any],
workflow_call_depth: int,
provider_name: str,
) -> Generator[ToolRuntimeMessage]:
binding, tool, installation = self._binding, self._tool, self._pre_installation
if (
not self._healthy
or binding is None
or tool is None
or installation is None
or tool_runtime is not self._handle
or self._invoke_started
or not binding.recorder.healthy
):
raise ToolRuntimeInvocationError("json_tool_runtime_unavailable")
# Generator creation is lazy, so latch at first iteration, then never retry.
self._invoke_started = True
completed = False
try:
with (self._session_maker or session_factory.get_session_maker())() as session:
self._require_no_transaction(session)
messages = ToolEngine.generic_invoke(
session=session,
tool=tool,
tool_parameters=dict(tool_parameters),
user_id=self._run_context.user_id,
workflow_tool_callback=DifyWorkflowCallbackHandler(),
workflow_call_depth=workflow_call_depth,
app_id=self._run_context.app_id,
conversation_id=None,
)
transformed = ToolFileMessageTransformer.transform_tool_invoke_messages(
messages=messages,
user_id=self._run_context.user_id,
tenant_id=self._run_context.tenant_id,
conversation_id=None,
)
try:
yield from self._adapt_messages(transformed, provider_name=provider_name)
self._require_no_transaction(session)
finally:
transformed.close()
messages.close()
tool.close_stream()
if not tool.complete:
raise BuilderExecutionPolicyError("json_tool_stream_incomplete")
# Metadata is read only after the native stream is fully exhausted.
with (self._session_maker or session_factory.get_session_maker())() as session:
self._require_no_transaction(session)
post = binding.metadata_reader(binding.context.tenant_id)
self._require_no_transaction(session)
require_same_json_installation(binding.context.qualified_tool, post)
if post != installation or not binding.recorder.healthy:
raise BuilderExecutionPolicyError("json_tool_installation_changed")
receipt = BuilderExecutionObservation(
observation_id=binding.native_node_execution_id,
invocation_id=binding.native_node_execution_id,
request_id=binding.context.request_id,
node_id=binding.context.qualified_tool.node_id,
kind="qualified_json_completed",
implementation_version=(
f"{binding.context.admitted_nodes[1].implementation}:"
f"{binding.context.admitted_nodes[1].node_version}"
),
reason_code="completed",
native_node_execution_id=binding.native_node_execution_id,
qualification_digest=installation.qualification_digest,
installed_binding_digest=installation.binding_digest,
final_parameters_digest=binding.context.qualified_tool.final_parameters_digest,
pre_binding_digest=installation.binding_digest,
post_binding_digest=post.binding_digest,
native_text_output_digest=canonical_digest(tool.text_output),
text_bytes=tool.text_bytes,
text_messages=tool.text_messages,
)
binding.recorder.record(receipt)
if not binding.recorder.healthy:
raise BuilderExecutionPolicyError("json_tool_recorder_unhealthy")
completed = True
except Exception as exc:
self._healthy = False
reason = exc.reason_code if isinstance(exc, BuilderExecutionPolicyError) else "json_tool_invocation_failed"
raise ToolRuntimeInvocationError(str(BuilderExecutionPolicyError(reason))) from None
finally:
if not completed:
self._healthy = False