feat(agent): guard native-test tool and model dispatch

Keep native admission disabled until authenticated lineage, workspace and remaining physical effect owners are composed.
This commit is contained in:
Charles Yao
2026-10-15 16:01:44 -07:00
parent a7e159110e
commit 895761db8a
31 changed files with 3800 additions and 125 deletions
@@ -1 +1,5 @@
"""Agent backend inner API controllers."""
from . import native_testing as _native_testing
__all__ = ["_native_testing"]
+33
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Literal
from dify_agent.adapters.native_testing.client import AgentPhysicalDispatchPayload
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from sqlalchemy.orm import Session
@@ -20,6 +21,8 @@ from core.tools.signature import bind_file_uri, get_signed_file_uri_for_plugin
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.exception import BaseHTTPException
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError
from services.dify_builder.native_testing.agent_bridge import claim_agent_endpoint
from services.file_request_service import FileRequestService
@@ -40,11 +43,13 @@ class AgentFileUploadRequestPayload(RequestRequestUploadFile):
user_id: str
user_from: Literal["account", "end-user"] | None = None
max_size: int = Field(ge=0, description="Maximum upload size in bytes")
native_dispatch: AgentPhysicalDispatchPayload | None = None
model_config = ConfigDict(extra="forbid")
class AgentFileDownloadRequestPayload(BaseModel):
native_dispatch: AgentPhysicalDispatchPayload | None = None
tenant_id: str
user_id: str
user_from: Literal["account", "end-user"]
@@ -107,6 +112,20 @@ class AgentFileUploadRequestApi(Resource):
status_code=400,
) from exc
try:
claim_agent_endpoint(
boundary="file_upload",
caller={"tenant_id": payload.tenant_id, "user_id": payload.user_id},
envelope=payload.native_dispatch,
target={"direction": "upload"},
parameters=payload.model_dump(mode="json", exclude={"native_dispatch"}),
kind="file",
physical_owner_ready=False,
)
except BuilderExecutionPolicyError:
raise AgentFileRequestHttpError(
error_code="native_agent_control_refused", description="Native Agent dispatch refused.", status_code=409
) from None
tenant = application_services().workspaces.management.get(payload.tenant_id)
if tenant is None:
raise AgentFileRequestHttpError(
@@ -164,6 +183,20 @@ class AgentFileDownloadRequestApi(Resource):
status_code=400,
) from exc
try:
claim_agent_endpoint(
boundary="file_download",
caller={"tenant_id": payload.tenant_id, "user_id": payload.user_id},
envelope=payload.native_dispatch,
target={"direction": "download"},
parameters=payload.model_dump(mode="json", exclude={"native_dispatch"}),
kind="file",
physical_owner_ready=False,
)
except BuilderExecutionPolicyError:
raise AgentFileRequestHttpError(
error_code="native_agent_control_refused", description="Native Agent dispatch refused.", status_code=409
) from None
if application_services().workspaces.management.get(payload.tenant_id) is None:
raise AgentFileRequestHttpError(
error_code="tenant_not_found",
+19
View File
@@ -15,6 +15,8 @@ from controllers.inner_api.wraps import agent_inner_api_only
from core.errors.error import QuotaExceededError
from libs.exception import BaseHTTPException
from services.agent_llm_inner_service import AgentLLMInnerService, AgentLLMInnerServiceError
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError
from services.dify_builder.native_testing.agent_bridge import claim_agent_endpoint
from services.entities.agent_llm_inner import AgentLLMInvokeRequest
@@ -51,6 +53,23 @@ class AgentLLMInvokeApi(Resource):
status_code=400,
) from exc
try:
claim_agent_endpoint(
boundary="model",
caller=payload.caller.model_dump(mode="json"),
envelope=payload.native_dispatch,
target={
"plugin_id": "/".join(payload.target.provider.split("/")[:2]),
"provider": payload.target.provider,
"model": payload.target.model,
},
parameters={**payload.target.model_dump(mode="json", exclude={"provider", "model"}), "stream": True},
kind="model",
)
except BuilderExecutionPolicyError:
raise AgentLLMInvokeHttpError(
error_code="native_agent_control_refused", description="Native Agent dispatch refused.", status_code=409
) from None
service = AgentLLMInnerService()
try:
prepared = service.prepare(payload)
@@ -0,0 +1,136 @@
"""Authenticated service-only native effect control; human decisions live in C04."""
from dify_agent.adapters.native_testing.client import (
AgentBeforeCallPayload,
AgentMaterialization,
AgentMaterializePayload,
AgentOutcomePayload,
AgentPhysicalDispatchPayload,
)
from dify_agent.protocol.native_testing import AgentEffectDecision, AgentEffectReceipt
from flask import request
from flask_restx import Resource
from pydantic import RootModel, ValidationError
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import agent_inner_api_only
from fields.base import ResponseModel
from libs.exception import BaseHTTPException
from libs.external_api import error_responses
from libs.helper import dump_response
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError
from services.dify_builder.native_testing.agent_bridge import agent_bridge_for_ref, api_intent, api_permit, api_ref
class AgentBeforeCallResponse(RootModel[AgentEffectDecision]):
"""Preserve the exact discriminated C01 decision on the independent wire."""
class AgentOutcomeResponse(AgentEffectReceipt, ResponseModel):
pass
class AgentMaterializeResponse(AgentMaterialization, ResponseModel):
pass
class AgentDispatchClaimResponse(ResponseModel):
claimed: bool
class AgentNativeControlRefused(BaseHTTPException):
error_code = "native_agent_control_refused"
description = "Native Agent control is unavailable."
code = 409
def _parse(model):
request.max_content_length = 1024 * 1024
try:
return model.model_validate(inner_api_ns.payload or {})
except ValidationError:
raise ValueError("Invalid native Agent control payload") from None
register_schema_models(
inner_api_ns, AgentBeforeCallPayload, AgentOutcomePayload, AgentMaterializePayload, AgentPhysicalDispatchPayload
)
register_response_schema_models(
inner_api_ns, AgentBeforeCallResponse, AgentOutcomeResponse, AgentMaterializeResponse, AgentDispatchClaimResponse
)
@inner_api_ns.route("/agent/native-testing/before-call")
class AgentNativeBeforeCallApi(Resource):
@agent_inner_api_only
@inner_api_ns.expect(inner_api_ns.models[AgentBeforeCallPayload.__name__])
@inner_api_ns.response(200, "Native effect decision", inner_api_ns.models[AgentBeforeCallResponse.__name__])
@error_responses(AgentNativeControlRefused)
def post(self):
payload = _parse(AgentBeforeCallPayload)
try:
result = agent_bridge_for_ref(payload.ref).before_call(ref=payload.ref, intent=payload.intent)
except BuilderExecutionPolicyError:
raise AgentNativeControlRefused() from None
return dump_response(AgentBeforeCallResponse, result)
@inner_api_ns.route("/agent/native-testing/outcome")
class AgentNativeOutcomeApi(Resource):
@agent_inner_api_only
@inner_api_ns.expect(inner_api_ns.models[AgentOutcomePayload.__name__])
@inner_api_ns.response(200, "Effect outcome recorded", inner_api_ns.models[AgentOutcomeResponse.__name__])
@error_responses(AgentNativeControlRefused)
def post(self):
payload = _parse(AgentOutcomePayload)
try:
result = agent_bridge_for_ref(payload.ref).record_outcome(
ref=payload.ref, permit=payload.permit, outcome=payload.outcome, result_ref=payload.result_ref
)
except BuilderExecutionPolicyError:
raise AgentNativeControlRefused() from None
return dump_response(AgentOutcomeResponse, result)
@inner_api_ns.route("/agent/native-testing/materialize")
class AgentNativeMaterializeApi(Resource):
@agent_inner_api_only
@inner_api_ns.expect(inner_api_ns.models[AgentMaterializePayload.__name__])
@inner_api_ns.response(
200, "Operation-local materialization", inner_api_ns.models[AgentMaterializeResponse.__name__]
)
@error_responses(AgentNativeControlRefused)
def post(self):
payload = _parse(AgentMaterializePayload)
try:
result = agent_bridge_for_ref(payload.ref).materialize(
ref=payload.ref,
intent=payload.intent,
permit=payload.permit,
tool_ref=payload.tool_ref,
phase=payload.phase,
)
except BuilderExecutionPolicyError:
raise AgentNativeControlRefused() from None
return dump_response(AgentMaterializeResponse, result)
@inner_api_ns.route("/agent/native-testing/claim-dispatch")
class AgentNativeDispatchClaimApi(Resource):
@agent_inner_api_only
@inner_api_ns.expect(inner_api_ns.models[AgentPhysicalDispatchPayload.__name__])
@inner_api_ns.response(200, "Physical dispatch claimed", inner_api_ns.models[AgentDispatchClaimResponse.__name__])
@error_responses(AgentNativeControlRefused)
def post(self):
payload = _parse(AgentPhysicalDispatchPayload)
try:
if payload.intent.kind != "tool":
raise BuilderExecutionPolicyError("native_materialization_phase_mismatch")
bridge = agent_bridge_for_ref(payload.ref)
bridge.authorization.claim_dispatch(
ref=api_ref(payload.ref), intent=api_intent(payload.intent), permit=api_permit(payload.permit)
)
except BuilderExecutionPolicyError:
raise AgentNativeControlRefused() from None
return dump_response(AgentDispatchClaimResponse, AgentDispatchClaimResponse(claimed=True))
+19
View File
@@ -10,6 +10,8 @@ from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import agent_inner_api_only
from libs.exception import BaseHTTPException
from services.agent_tool_inner_service import AgentToolInnerService
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError
from services.dify_builder.native_testing.agent_bridge import claim_agent_endpoint
from services.entities.agent_tool_inner import AgentToolInvokeRequest, AgentToolInvokeResponse
from services.errors.agent_tool_inner import AgentToolInnerServiceError
@@ -59,7 +61,24 @@ class AgentToolInvokeApi(Resource):
) from exc
try:
claim_agent_endpoint(
boundary="core_tool",
caller=payload.caller.model_dump(mode="json"),
envelope=payload.native_dispatch,
target={
"provider_type": payload.tool.provider_type,
"provider_id": payload.tool.provider_id,
"tool_name": payload.tool.tool_name,
},
parameters={**payload.tool.runtime_parameters, **payload.tool.tool_parameters},
kind="tool",
physical_owner_ready=False,
)
response = AgentToolInnerService().invoke(session=session, request=payload)
except BuilderExecutionPolicyError:
raise AgentToolInvokeHttpError(
error_code="native_agent_control_refused", description="Native Agent dispatch refused.", status_code=409
) from None
except AgentToolInnerServiceError as exc:
raise AgentToolInvokeHttpError(
error_code=exc.error_code,
@@ -17,6 +17,8 @@ from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import plugin_inner_api_only
from core.workflow.nodes.knowledge_retrieval import exc as retrieval_exc
from libs.exception import BaseHTTPException
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError
from services.dify_builder.native_testing.agent_bridge import claim_agent_endpoint
from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveRequest, InnerKnowledgeRetrieveResponse
from services.errors.knowledge_retrieval import ExternalKnowledgeRetrievalError, InnerKnowledgeRetrievalServiceError
from services.knowledge_retrieval_inner_service import InnerKnowledgeRetrievalService
@@ -84,7 +86,20 @@ class InnerKnowledgeRetrieveApi(Resource):
) from exc
try:
claim_agent_endpoint(
boundary="knowledge",
caller=payload.caller.model_dump(mode="json"),
envelope=payload.native_dispatch,
target={"dataset_ids": payload.dataset_ids},
parameters=payload.model_dump(mode="json", exclude={"caller", "native_dispatch"}),
kind="knowledge",
physical_owner_ready=False,
)
response = InnerKnowledgeRetrievalService().retrieve(payload, session=session)
except BuilderExecutionPolicyError:
raise InnerKnowledgeRetrievalHttpError(
error_code="native_agent_control_refused", description="Native Agent dispatch refused.", status_code=409
) from None
except InnerKnowledgeRetrievalServiceError as exc:
raise InnerKnowledgeRetrievalHttpError(
error_code=exc.error_code,
@@ -97,6 +97,7 @@ class WorkflowAgentDifyToolLayersBuilder(Protocol):
user_id: str | None,
tools: AgentSoulToolsConfig,
invoke_from: InvokeFrom,
native_test_required: bool = False,
) -> WorkflowAgentToolLayers: ...
@@ -174,6 +175,7 @@ class WorkflowAgentDifyToolsBuilder:
user_id: str | None,
tools: AgentSoulToolsConfig,
invoke_from: InvokeFrom,
native_test_required: bool = False,
) -> WorkflowAgentToolLayers:
"""Resolve user-selected Dify tools into direct/core Agent backend DTOs.
@@ -182,6 +184,17 @@ class WorkflowAgentDifyToolsBuilder:
be threaded through to `ToolManager` so credential quotas, rate limits,
and audit tags match the actual call site.
"""
if native_test_required:
from services.dify_builder.native_testing.agent_tool_declarations import AgentToolDeclarationService
if user_id is None:
raise WorkflowAgentDifyToolsBuildError(
"agent_tool_owner_missing", "Native tool declarations require an actor."
)
prepared = AgentToolDeclarationService().prepare(
tenant_id=tenant_id, actor_id=user_id, app_id=app_id, tools=tools
)
return WorkflowAgentToolLayers(plugin_tools=prepared.plugin_tools, core_tools=prepared.core_tools)
enabled_tools = [tool for tool in tools.dify_tools if tool.enabled]
if not enabled_tools:
return WorkflowAgentToolLayers()
@@ -165,6 +165,10 @@ class WorkflowAgentRuntimeRequestBuilder:
self._dify_tools_builder = dify_tools_builder or WorkflowAgentDifyToolsBuilder()
def build(self, context: WorkflowAgentRuntimeBuildContext) -> WorkflowAgentRuntimeRequest:
if context.dify_context.native_test_required != (context.dify_context.native_test_ref is not None):
raise WorkflowAgentRuntimeRequestBuildError(
"native_context_missing", "Native Agent authority is unavailable."
)
agent_soul = AgentSoulConfig.model_validate(context.snapshot.config_snapshot_dict)
node_job = WorkflowNodeJobConfig.model_validate(context.binding.node_job_config_dict)
node_job.output_routes.validate_for_execution()
@@ -189,6 +193,7 @@ class WorkflowAgentRuntimeRequestBuilder:
user_id=context.dify_context.user_id,
tools=agent_soul.tools,
invoke_from=context.dify_context.invoke_from,
native_test_required=context.dify_context.native_test_required,
)
except WorkflowAgentDifyToolsBuildError as error:
raise WorkflowAgentRuntimeRequestBuildError(error.error_code, str(error)) from error
@@ -276,6 +281,19 @@ class WorkflowAgentRuntimeRequestBuilder:
metadata=metadata,
)
)
if context.dify_context.native_test_required:
from dify_agent.protocol.native_testing import AgentNativeTestRef
assert context.dify_context.native_test_ref is not None
request = request.model_copy(
update={
"execution_mode": "builder_test",
"native_test_protocol_version": 1,
"native_test_ref": AgentNativeTestRef.model_validate(
context.dify_context.native_test_ref.model_dump(mode="json")
),
}
)
redacted = cast(dict[str, Any], redact_for_agent_backend_log(request))
return WorkflowAgentRuntimeRequest(
request=request,
@@ -293,6 +311,7 @@ class WorkflowAgentRuntimeRequestBuilder:
user_id: str | None,
tools: AgentSoulToolsConfig,
invoke_from: InvokeFrom,
native_test_required: bool = False,
) -> WorkflowAgentToolLayers:
# Production workflow runs intentionally keep existing plugin configs on
# the direct `dify.plugin.tools` route. This builder emits plugin tools
@@ -303,6 +322,7 @@ class WorkflowAgentRuntimeRequestBuilder:
user_id=user_id,
tools=tools,
invoke_from=invoke_from,
**({"native_test_required": True} if native_test_required else {}),
)
@staticmethod
@@ -0,0 +1,539 @@
"""Agent wire adapter to the existing authorization and credential owners.
The private wire has no actor or tenant authority. Server composition is mandatory
and reconstructs the owner from the persisted request before granting access.
Validation does not consume a transport fence and never replaces one.
"""
import json
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Literal, Protocol
from dify_agent.adapters.native_testing.client import AgentMaterialization, AgentPhysicalDispatchPayload
from dify_agent.protocol.native_testing import (
AgentApprovalWait,
AgentDispatchPermit,
AgentEffectDecision,
AgentEffectIntent,
AgentEffectReceipt,
AgentNativeTestRef,
AgentPolicyStop,
AgentRecordedResult,
)
from flask import current_app
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.dify_builder.models import Actor
from extensions.ext_database import db
from models.dify_builder import DifyBuilderExecutionRequest
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError, canonical_digest, canonical_json
from services.dify_builder.native_testing.authorization import EffectAuthorizationService
from services.dify_builder.native_testing.contracts import (
ApprovalWait,
DispatchPermit,
EffectIntent,
NativeTestContextRef,
OperationKey,
Outcome,
PolicyStop,
RecordedResult,
)
from services.dify_builder.native_testing.state_store import NativeExecutionStateStore
from services.tools.builtin_tools_manage_service import BuilderCredentialRuntime, BuiltinToolManageService
@dataclass(frozen=True)
class AgentCredentialBinding:
"""Trusted protected configuration resolution, never constructed from wire authority."""
actor: Actor
tool_ref: str
provider_id: str
credential_id: str | None
verified_plugin: bool
implementation_revision: str
requirements_ref: str
class NativeAgentCredentialMaterializer:
"""Separate OAuth and business phases using the existing native credential owners.
Selection is metadata only. Claim, decrypt, OAuth and CAS are sequential and
no network/crypto call runs in a SQL transaction. A business permit cannot
refresh; it is consumed at the eventual physical tool dispatcher, not here.
"""
def __init__(self, *, authorization: EffectAuthorizationService, resolve_binding: Callable):
self.authorization = authorization
self.resolve_binding = resolve_binding
def inspect(
self, *, ref: NativeTestContextRef, tool_ref: str
) -> tuple[AgentCredentialBinding, BuilderCredentialRuntime | None]:
self.authorization.loader.load(ref=ref)
binding = self.resolve_binding(ref=ref, tool_ref=tool_ref)
if (
not isinstance(binding, AgentCredentialBinding)
or binding.actor != self.authorization.loader.actor
or binding.tool_ref != tool_ref
):
raise BuilderExecutionPolicyError("native_owner_mismatch")
if binding.credential_id is None:
return binding, None
try:
selection = BuiltinToolManageService.load_builder_credential(
actor=binding.actor,
provider_id=binding.provider_id,
credential_id=binding.credential_id,
verified_plugin=binding.verified_plugin,
)
except BuilderExecutionPolicyError as error:
if error.reason_code != "needs_setup":
raise
return binding, None
return binding, selection
@staticmethod
def account_ref(selection: BuilderCredentialRuntime) -> str:
return canonical_digest(selection.snapshot.reference.model_dump(mode="json"))
@staticmethod
def refresh_due(selection: BuilderCredentialRuntime) -> bool:
reference = selection.snapshot.reference
return (
reference.credential_type == "oauth2"
and reference.expires_at != -1
and reference.expires_at - 60 < int(time.time())
)
def materialize(
self,
*,
ref: NativeTestContextRef,
intent: EffectIntent,
permit: DispatchPermit,
tool_ref: str,
phase: Literal["refresh", "business"],
) -> AgentMaterialization:
if (
intent.resource_ref != tool_ref
or intent.kind != ("oauth" if phase == "refresh" else "tool")
or phase not in {"refresh", "business"}
):
raise BuilderExecutionPolicyError("native_materialization_phase_mismatch")
self.authorization.validate_dispatch(ref=ref, intent=intent, permit=permit)
binding, selection = self.inspect(ref=ref, tool_ref=tool_ref)
if selection is None or binding.credential_id is None:
raise BuilderExecutionPolicyError("needs_setup")
if (
intent.account_ref != self.account_ref(selection)
or intent.implementation_revision != binding.implementation_revision
):
raise BuilderExecutionPolicyError("native_credential_drift")
due = self.refresh_due(selection)
if (phase == "business" and due) or (phase == "refresh" and not due):
raise BuilderExecutionPolicyError("native_materialization_phase_mismatch")
from configs import dify_config
from core.entities import PluginCredentialType
from core.helper.credential_utils import runtime_check_credential_policy_compliance
from core.helper.provider_cache import NoOpProviderCredentialCache, ToolProviderCredentialsCache
from core.plugin.entities.plugin_daemon import CredentialType
from core.plugin.impl.oauth import OAuthHandler
from core.tools.tool_manager import ToolManager
from core.tools.utils.encryption import create_provider_encrypter
from models.provider_ids import ToolProviderID
controller = ToolManager.get_builtin_provider(binding.provider_id, binding.actor.tenant_id)
if getattr(controller, "plugin_unique_identifier", None) != binding.implementation_revision:
raise BuilderExecutionPolicyError("installed_tool_unavailable")
credential_type = CredentialType.of(selection.snapshot.reference.credential_type)
# Refresh is the sole physical owner of this permit; the claim commits
# before decrypting any credential or contacting the OAuth endpoint.
if phase == "refresh":
self.authorization.claim_dispatch(ref=ref, intent=intent, permit=permit)
try:
runtime_check_credential_policy_compliance(
credential_id=binding.credential_id,
provider=binding.provider_id,
credential_type=PluginCredentialType.TOOL,
check_existence=False,
)
encrypter, _ = create_provider_encrypter(
tenant_id=binding.actor.tenant_id,
config=[
x.to_basic_provider_config() for x in controller.get_credentials_schema_by_type(credential_type)
],
cache=NoOpProviderCredentialCache(),
)
stored = json.loads(selection.snapshot.encrypted_credentials or "{}")
if not isinstance(stored, dict):
raise ValueError("credential shape")
credentials = encrypter.decrypt(stored)
if phase == "business":
return AgentMaterialization(
credential_type=credential_type.value, credentials=dict(credentials), runtime_parameters={}
)
provider = ToolProviderID(binding.provider_id)
client = BuiltinToolManageService.decrypt_builder_oauth_client(
tenant_id=binding.actor.tenant_id, provider_controller=controller, client=selection.snapshot.client
)
refreshed = OAuthHandler().refresh_credentials(
tenant_id=binding.actor.tenant_id,
user_id=selection.owner_user_id,
plugin_id=provider.plugin_id,
provider=provider.provider_name,
redirect_uri=f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{binding.provider_id}/tool/callback",
system_credentials=client,
credentials=credentials,
)
reference = BuiltinToolManageService.persist_native_agent_refresh(
actor=binding.actor,
provider_id=binding.provider_id,
verified_plugin=binding.verified_plugin,
selection=selection,
encrypted_credentials=json.dumps(encrypter.encrypt(refreshed.credentials)),
expires_at=refreshed.expires_at,
)
ToolProviderCredentialsCache(
tenant_id=binding.actor.tenant_id, provider=binding.provider_id, credential_id=binding.credential_id
).delete()
# The refresh result contains only an opaque reference; tokens never
# enter the durable result owner or the returned refresh envelope.
return AgentMaterialization(
credential_type="oauth2",
credentials={},
runtime_parameters={"credential_ref": canonical_digest(reference.model_dump(mode="json"))},
)
except BuilderExecutionPolicyError:
raise
except Exception:
raise BuilderExecutionPolicyError("native_credential_phase_failed") from None
@dataclass
class NativeAgentCredentialPhases:
"""Called by the trusted continuation owner with its frozen child intent.
The caller must persist that OAuth child intent before calling refresh and
keep replaying it after restart, even if the rotated credential is now fresh.
No business permit is requested until this child has a completed receipt.
"""
authorization: EffectAuthorizationService
materializer: NativeAgentCredentialMaterializer
state_store: NativeExecutionStateStore
def prepare_business(
self,
*,
ref: NativeTestContextRef,
key: OperationKey,
tool_ref: str,
target,
parameters,
input_digest: str,
capability_digest: str,
refresh_intent: EffectIntent | None,
) -> EffectIntent | ApprovalWait | PolicyStop:
"""Readiness precedes freezing the actual account or reserving business.
The continuation owner supplies its durable OAuth child intent when one
exists, including after a crash following CAS. Absence is never inferred
from the now-fresh token. A due token with no frozen child fails closed.
"""
wait = self.configuration_wait(ref=ref, tool_ref=tool_ref)
if wait is not None:
return wait
if refresh_intent is not None:
expected_key = key.model_copy(update={"occurrence_path": (*key.occurrence_path, "oauth_refresh")})
if refresh_intent.key != expected_key or refresh_intent.resource_ref != tool_ref:
raise BuilderExecutionPolicyError("native_refresh_owner_mismatch")
wait = self.refresh(ref=ref, intent=refresh_intent)
if wait is not None:
return wait
binding, selection = self.materializer.inspect(ref=ref, tool_ref=tool_ref)
if selection is None:
raise BuilderExecutionPolicyError("native_credential_drift")
if self.materializer.refresh_due(selection):
return PolicyStop(reason_code="native_refresh_continuation_required")
return EffectIntent(
key=key,
kind="tool",
resource_ref=tool_ref,
implementation_revision=binding.implementation_revision,
account_ref=self.materializer.account_ref(selection),
target=target,
parameters=parameters,
input_digest=input_digest,
capability_digest=capability_digest,
)
def configuration_wait(self, *, ref: NativeTestContextRef, tool_ref: str) -> ApprovalWait | None:
from services.dify_builder.native_testing.pending_actions import NativePendingActionService
binding, selection = self.materializer.inspect(ref=ref, tool_ref=tool_ref)
if selection is not None:
return None
owned = self.authorization.loader.load(ref=ref)
return NativePendingActionService(self.authorization.loader).configuration(
ref=ref,
requirements_ref=binding.requirements_ref,
expected_generation=owned.generation,
)
def refresh(self, *, ref: NativeTestContextRef, intent: EffectIntent) -> ApprovalWait | PolicyStop | None:
if intent.kind != "oauth":
raise BuilderExecutionPolicyError("native_materialization_phase_mismatch")
decision = self.authorization.before_call(ref=ref, intent=intent)
if isinstance(decision, (ApprovalWait, PolicyStop)):
return decision
if isinstance(decision, RecordedResult):
result = self.state_store.load_result(ref=ref, result_ref=decision.result_ref)
if not isinstance(result, dict) or not isinstance(result.get("credential_ref"), str):
raise BuilderExecutionPolicyError("native_refresh_result_invalid")
return None
try:
materialized = self.materializer.materialize(
ref=ref, intent=intent, permit=decision, tool_ref=intent.resource_ref, phase="refresh"
)
result_ref = self.state_store.store_result(ref=ref, key=intent.key, result=materialized.runtime_parameters)
except Exception:
self.authorization.record_outcome(permit=decision, outcome="unknown", result_ref=None)
raise
self.authorization.record_outcome(permit=decision, outcome="completed", result_ref=result_ref)
return None
def api_ref(ref: AgentNativeTestRef) -> NativeTestContextRef:
return NativeTestContextRef(
policy_version=ref.policy_version,
request_id=ref.request_id,
invocation_id=ref.invocation_id,
context_digest=ref.context_digest,
)
def api_intent(intent: AgentEffectIntent) -> EffectIntent:
return EffectIntent(
key=OperationKey(
invocation_id=intent.key.invocation_id, occurrence_path=intent.key.occurrence_path, cursor=intent.key.cursor
),
kind=intent.kind,
resource_ref=intent.resource_ref,
implementation_revision=intent.implementation_revision,
account_ref=intent.account_ref,
target=intent.target,
parameters=intent.parameters,
input_digest=intent.input_digest,
capability_digest=intent.capability_digest,
)
def api_permit(permit: AgentDispatchPermit) -> DispatchPermit:
return DispatchPermit(
permit_id=permit.permit_id,
attempt_id=permit.attempt_id,
operation_id=permit.operation_id,
expires_at=permit.expires_at,
)
class AgentReadinessPort(Protocol):
"""Resolve configuration/form ownership before creating an actual-account effect.
Implementations use NativePendingActionService, preserve its durable owner
reference, and perform only inert metadata reads. They do not refresh tokens.
"""
def prepare(self, *, ref: NativeTestContextRef, intent: EffectIntent) -> ApprovalWait | None: ...
class AgentDispatchFence(Protocol):
"""NW4 durable at-most-once physical dispatch claim across workers/restart.
Must reject repeated or concurrent requests for the same permit, including
after ambiguous failure. There is no in-memory/default implementation.
"""
def claim_dispatch(
self,
*,
ref: NativeTestContextRef,
intent: EffectIntent,
permit: DispatchPermit,
) -> None: ...
class AgentCredentialMaterializer(Protocol):
"""Existing credential owner, exact phase, outside SQL. Business never refreshes.
Refresh must persist through the existing credential CAS before returning;
its successful outcome cannot authorize or spend a business permit.
"""
def materialize(
self,
*,
ref: NativeTestContextRef,
intent: EffectIntent,
permit: DispatchPermit,
tool_ref: str,
phase: Literal["refresh", "business"],
) -> AgentMaterialization: ...
@dataclass
class AgentNativeTestingBridge:
authorization: EffectAuthorizationService
readiness: AgentReadinessPort | None
materializer: AgentCredentialMaterializer | None
fence: AgentDispatchFence | None
def before_call(self, *, ref: AgentNativeTestRef, intent: AgentEffectIntent) -> AgentEffectDecision:
owned_ref, effect = api_ref(ref), api_intent(intent)
if self.readiness is not None:
self.authorization.loader.load(ref=owned_ref)
wait = self.readiness.prepare(ref=owned_ref, intent=effect)
if wait is not None:
if wait.wait_reason == "approval":
raise BuilderExecutionPolicyError("native_readiness_reason_invalid")
return AgentApprovalWait(pending_operation_id=wait.pending_operation_id, wait_reason=wait.wait_reason)
decision = self.authorization.before_call(ref=owned_ref, intent=effect)
if isinstance(decision, ApprovalWait):
return AgentApprovalWait(
pending_operation_id=decision.pending_operation_id, wait_reason=decision.wait_reason
)
if isinstance(decision, PolicyStop):
return AgentPolicyStop(reason_code=decision.reason_code)
if isinstance(decision, RecordedResult):
return AgentRecordedResult(operation_id=decision.operation_id, result_ref=decision.result_ref)
return AgentDispatchPermit(
permit_id=decision.permit_id,
attempt_id=decision.attempt_id,
operation_id=decision.operation_id,
expires_at=decision.expires_at,
)
def record_outcome(
self, *, ref: AgentNativeTestRef, permit: AgentDispatchPermit, outcome: Outcome, result_ref: str | None
) -> AgentEffectReceipt:
native_permit = api_permit(permit)
# Settlement retains the original request owner even after revocation.
if self.authorization._permit_ref(native_permit) != api_ref(ref):
raise BuilderExecutionPolicyError("invalid_dispatch_permit")
receipt = self.authorization.record_outcome(permit=native_permit, outcome=outcome, result_ref=result_ref)
return AgentEffectReceipt(
operation_id=receipt.operation_id,
attempt_id=receipt.attempt_id,
outcome=receipt.outcome,
result_ref=receipt.result_ref,
)
def materialize(
self,
*,
ref: AgentNativeTestRef,
intent: AgentEffectIntent,
permit: AgentDispatchPermit,
tool_ref: str,
phase: Literal["refresh", "business"],
) -> AgentMaterialization:
owned_ref, effect, native_permit = api_ref(ref), api_intent(intent), api_permit(permit)
if (
(phase == "refresh" and effect.kind != "oauth")
or (phase == "business" and effect.kind != "tool")
or effect.resource_ref != tool_ref
):
raise BuilderExecutionPolicyError("native_materialization_phase_mismatch")
if self.fence is not self.authorization:
raise BuilderExecutionPolicyError("native_dispatch_fence_unavailable")
if self.materializer is None:
raise BuilderExecutionPolicyError("native_materialization_unavailable")
self.authorization.validate_dispatch(ref=owned_ref, intent=effect, permit=native_permit)
# The materializer owns refresh claim_dispatch immediately before its
# physical OAuth boundary. Business consumption belongs to the tool
# dispatcher. Never consume the same permit again at this HTTP adapter.
return self.materializer.materialize(
ref=owned_ref, intent=effect, permit=native_permit, tool_ref=tool_ref, phase=phase
)
def agent_bridge_for_ref(ref: AgentNativeTestRef) -> AgentNativeTestingBridge:
"""Service-authenticated route supplies only a ref; actor comes from SQL."""
with Session(db.engine) as session:
row = session.scalar(
select(DifyBuilderExecutionRequest).where(DifyBuilderExecutionRequest.id == ref.request_id)
)
if row is None or row.context_digest != ref.context_digest:
raise BuilderExecutionPolicyError("native_owner_mismatch")
actor = Actor(account_id=row.actor_id, tenant_id=row.tenant_id)
factory = current_app.extensions.get("native_agent_bridge_factory")
if factory is None:
raise BuilderExecutionPolicyError("native_agent_bridge_unavailable")
bridge = factory(actor=actor)
if not isinstance(bridge, AgentNativeTestingBridge) or bridge.authorization.loader.actor != actor:
raise BuilderExecutionPolicyError("native_agent_bridge_unavailable")
return bridge
class AgentEndpointLineagePort(Protocol):
"""A4/A5 resolves authenticated principal + stored run, never an input flag.
None means a positively identified ordinary call. Missing/ambiguous native
identity must raise, not return None. This port must cover omission of an
envelope as well as the presence of one before native admission is enabled.
"""
def resolve(self, *, boundary: str, caller: dict) -> AgentNativeTestRef | None: ...
def claim_agent_endpoint(
*,
boundary: str,
caller: dict,
envelope: AgentPhysicalDispatchPayload | None,
target,
parameters,
kind: str,
physical_owner_ready: bool = True,
) -> None:
lineage = current_app.extensions.get("native_agent_endpoint_lineage")
if lineage is None:
if envelope is not None:
raise BuilderExecutionPolicyError("native_endpoint_lineage_unavailable")
# Compatibility for ordinary deployments only. The native admission
# capability MUST remain disabled until the trusted lineage port covers
# every endpoint, including an omitted envelope. This is not native proof.
return
required = lineage.resolve(boundary=boundary, caller=caller)
if required is None:
if envelope is not None:
raise BuilderExecutionPolicyError("native_endpoint_owner_mismatch")
return
if envelope is None or envelope.ref != required:
raise BuilderExecutionPolicyError("native_endpoint_owner_mismatch")
effect = envelope.intent
if (
effect.kind != kind
or canonical_json(effect.target) != canonical_json(target)
or canonical_json(effect.parameters) != canonical_json(parameters)
):
raise BuilderExecutionPolicyError("native_endpoint_arguments_mismatch")
if not physical_owner_ready:
raise BuilderExecutionPolicyError("native_endpoint_dependency_unavailable")
bridge = agent_bridge_for_ref(required)
owned = bridge.authorization.loader.load(ref=api_ref(required))
if (
caller.get("tenant_id") != owned.tenant_id
or caller.get("app_id") != owned.app_id
or caller.get("user_id") != owned.actor_id
or caller.get("user_from") != "account"
):
raise BuilderExecutionPolicyError("native_endpoint_owner_mismatch")
bridge.authorization.claim_dispatch(
ref=api_ref(required), intent=api_intent(effect), permit=api_permit(envelope.permit)
)
@@ -0,0 +1,257 @@
"""Inert Agent tool declarations, separate from credential/runtime construction.
Only declared schemas and opaque runtime references cross this boundary. Dynamic
runtime schemas are not fetched here: those are capability operations themselves.
"""
from collections.abc import Callable
from copy import deepcopy
from dataclasses import dataclass
from typing import Literal
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolParameter, DifyPluginToolsLayerConfig
from pydantic import JsonValue, TypeAdapter
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.tools.entities.tool_entities import ToolEntity, ToolProviderType
from core.tools.tool_manager import ToolManager
from extensions.ext_database import db
from models.agent_config_entities import AgentSoulToolsConfig
from models.tools import WorkflowToolProvider
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError, canonical_digest
@dataclass(frozen=True)
class AgentToolMetadata:
entity: ToolEntity
implementation_revision: str
requires_credentials: bool
@dataclass(frozen=True)
class AgentToolRuntimeReference:
tool_ref: str
provider_id: str
tool_name: str
credential_id: str | None
implementation_revision: str
readiness: Literal["ready", "needs_setup", "unresolved"]
@dataclass(frozen=True)
class PreparedAgentToolDeclarations:
plugin_tools: DifyPluginToolsLayerConfig | None
core_tools: DifyCoreToolsLayerConfig | None
references: tuple[AgentToolRuntimeReference, ...]
def _metadata(*, tenant_id: str, provider_type: ToolProviderType, provider_id: str, tool_name: str | None):
if provider_type in {ToolProviderType.PLUGIN, ToolProviderType.BUILT_IN}:
controller = (
ToolManager.get_plugin_provider(provider_id, tenant_id)
if provider_type == ToolProviderType.PLUGIN
else ToolManager.get_builtin_provider(provider_id, tenant_id)
)
declared = controller.get_tools() or []
revision = getattr(controller, "plugin_unique_identifier", None)
needs_credentials = controller.need_credentials
elif provider_type == ToolProviderType.API:
controller, _encrypted = ToolManager.get_api_provider_controller(tenant_id, provider_id)
declared = controller.get_tools(tenant_id) or []
revision = None
needs_credentials = True # Readiness is unresolved, never infer an account from schema.
elif provider_type == ToolProviderType.WORKFLOW:
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
row = db.session.scalar(
select(WorkflowToolProvider).where(
WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == provider_id
)
)
if row is None:
raise BuilderExecutionPolicyError("agent_tool_declaration_not_found")
declared = WorkflowToolProviderController.from_db(row).get_tools(tenant_id) or []
revision = None
needs_credentials = False
elif provider_type == ToolProviderType.MCP:
from core.mcp.types import Tool as RemoteMCPTool
from services.tools.mcp_tools_manage_service import MCPToolManageService
from services.tools.tools_transform_service import ToolTransformService
# Read stored declarations directly. MCPToolProvider.to_entity() resolves
# encrypted URL/headers, so it is intentionally not a metadata boundary.
with Session(db.engine) as session:
row = MCPToolManageService(session=session).get_provider_by_persisted_reference(
id_or_server_identifier=provider_id, tenant_id=tenant_id
)
stored_tools = row.tool_dict
revision = canonical_digest({"id": row.id, "updated_at": row.updated_at.isoformat(), "tools": stored_tools})
results = []
for raw in stored_tools:
remote = RemoteMCPTool.model_validate(raw)
if tool_name is not None and remote.name != tool_name:
continue
description = remote.description or ""
entity = ToolEntity.model_validate(
{
"identity": {
"author": "",
"name": remote.name,
"label": {"en_US": remote.title or remote.name},
"provider": provider_id,
},
"description": {"human": {"en_US": description}, "llm": description},
"parameters": ToolTransformService.convert_mcp_schema_to_parameter(remote.inputSchema),
"output_schema": remote.outputSchema or {},
}
)
results.append(AgentToolMetadata(entity, revision, True))
if not results:
raise BuilderExecutionPolicyError("agent_tool_declaration_not_found")
return results
else:
raise BuilderExecutionPolicyError("agent_tool_metadata_unavailable")
results = []
for tool in declared:
if tool_name is not None and tool.entity.identity.name != tool_name:
continue
entity = ToolEntity.model_validate(tool.entity.model_dump(mode="json"))
results.append(
AgentToolMetadata(entity, revision or canonical_digest(entity.model_dump(mode="json")), needs_credentials)
)
if not results:
raise BuilderExecutionPolicyError("agent_tool_declaration_not_found")
return results
def _schema(parameters: list[DifyPluginToolParameter]) -> dict[str, JsonValue]:
properties: dict[str, JsonValue] = {}
required: list[JsonValue] = []
for parameter in parameters:
if parameter.form.value != "llm":
continue
kind = parameter.type.value
item: dict[str, JsonValue]
if kind in {"file", "files", "system-files"}:
from core.workflow.nodes.agent_v2.dify_tools_builder import _plugin_file_input_schema
item = _plugin_file_input_schema(parameter.llm_description or "")
properties[parameter.name] = item if kind == "file" else {"type": "array", "items": item}
else:
normal = parameter.type.as_normal_type()
normal = {"dynamic-select": "string", "app-selector": "object", "model-selector": "object"}.get(
normal, normal
)
item = (
deepcopy(parameter.input_schema)
if parameter.input_schema is not None
else ({} if normal == "any" else {"type": normal})
)
item.setdefault("description", parameter.llm_description or "")
if parameter.options:
item["enum"] = [option.value for option in parameter.options]
properties[parameter.name] = item
if parameter.required:
required.append(parameter.name)
return {"type": "object", "properties": properties, "required": required}
class AgentToolDeclarationService:
def __init__(self, *, metadata: Callable = _metadata):
self.metadata = metadata
def prepare(
self, *, tenant_id: str, actor_id: str, app_id: str, tools: AgentSoulToolsConfig
) -> PreparedAgentToolDeclarations:
plugins, core, references = [], [], []
names: set[str] = set()
for selected in tools.dify_tools:
if not selected.enabled:
continue
provider_id = selected.provider_id or f"{selected.plugin_id}/{selected.provider}"
declarations = self.metadata(
tenant_id=tenant_id,
provider_type=selected.provider_type,
provider_id=provider_id,
tool_name=selected.tool_name,
)
if isinstance(declarations, AgentToolMetadata):
declarations = [declarations]
for declaration in declarations:
name = declaration.entity.identity.name
if name in names:
raise BuilderExecutionPolicyError("agent_tool_name_duplicated")
names.add(name)
parameters = [
DifyPluginToolParameter.model_validate(item.model_dump(mode="json"))
for item in declaration.entity.parameters or []
]
credential_id = selected.credential_ref.id if selected.credential_ref else None
tool_ref = canonical_digest(
{
"tenant_id": tenant_id,
"actor_id": actor_id,
"app_id": app_id,
"provider_id": provider_id,
"tool_name": name,
"credential_id": credential_id,
"implementation_revision": declaration.implementation_revision,
}
)
runtime_parameters = TypeAdapter(dict[str, JsonValue]).validate_python(selected.runtime_parameters)
for parameter in parameters:
if parameter.type.value == "secret-input" and parameter.name in runtime_parameters:
runtime_parameters[parameter.name] = {"secret_ref": f"{tool_ref}:{parameter.name}"}
common = {
"tool_name": name,
"name": name,
"description": selected.description or declaration.entity.description.llm,
"runtime_parameters": runtime_parameters,
"parameters": parameters,
"parameters_json_schema": _schema(parameters),
}
if selected.provider_type == ToolProviderType.PLUGIN or (
selected.provider_type == ToolProviderType.BUILT_IN and provider_id.count("/") == 2
):
plugin_id, provider = provider_id.rsplit("/", 1)
plugins.append(
DifyPluginToolConfig(
plugin_id=plugin_id,
provider=provider,
credential_type=selected.credential_type,
credentials={},
**common,
)
)
else:
core.append(
DifyCoreToolConfig.model_validate(
{
"provider_type": selected.provider_type.value,
"provider_id": provider_id,
"credential_id": credential_id,
**common,
}
)
)
references.append(
AgentToolRuntimeReference(
tool_ref,
provider_id,
name,
credential_id,
declaration.implementation_revision,
"needs_setup"
if declaration.requires_credentials and credential_id is None
else "unresolved"
if declaration.requires_credentials
else "ready",
)
)
return PreparedAgentToolDeclarations(
DifyPluginToolsLayerConfig(tools=plugins) if plugins else None,
DifyCoreToolsLayerConfig(tools=core) if core else None,
tuple(references),
)
+2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import ClassVar, Literal
from dify_agent.adapters.native_testing.client import AgentPhysicalDispatchPayload
from pydantic import BaseModel, ConfigDict, Field, JsonValue
from graphon.model_runtime.entities.message_entities import PromptMessageTool
@@ -53,6 +54,7 @@ class AgentLLMInvokeTarget(BaseModel):
class AgentLLMInvokeRequest(BaseModel):
caller: AgentLLMInvokeCaller
target: AgentLLMInvokeTarget
native_dispatch: AgentPhysicalDispatchPayload | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import ClassVar, Literal
from dify_agent.adapters.native_testing.client import AgentPhysicalDispatchPayload
from pydantic import BaseModel, ConfigDict, Field, JsonValue
type AgentToolProviderType = Literal["plugin", "builtin", "api", "workflow", "mcp"]
@@ -47,6 +48,7 @@ class AgentToolInvokeTarget(BaseModel):
class AgentToolInvokeRequest(BaseModel):
caller: AgentToolInvokeCaller
tool: AgentToolInvokeTarget
native_dispatch: AgentPhysicalDispatchPayload | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@@ -15,6 +15,7 @@ from __future__ import annotations
from typing import Literal
from dify_agent.adapters.native_testing.client import AgentPhysicalDispatchPayload
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from core.rag.data_post_processor.data_post_processor import WeightsDict
@@ -136,6 +137,7 @@ class InnerKnowledgeRetrieveRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
caller: InnerKnowledgeRetrieveCaller
native_dispatch: AgentPhysicalDispatchPayload | None = None
dataset_ids: list[str]
query: str | None = None
retrieval: InnerKnowledgeRetrieveRetrievalConfig
@@ -669,6 +669,91 @@ class BuiltinToolManageService:
binding=binding, reference=reference
)
@staticmethod
def persist_native_agent_refresh(
*,
actor: Actor,
provider_id: str,
verified_plugin: bool,
selection: BuilderCredentialRuntime,
encrypted_credentials: str,
expires_at: int,
) -> CredentialReference:
"""CAS one detached native selection after its OAuth dispatch claim.
The caller owns the unique committed phase claim and native outcome.
This credential owner only compares metadata and persists ciphertext;
encryption, refresh, cache invalidation and external I/O are outside SQL.
"""
from models.credential_permission import CredentialPermission
key = dify_config.SECRET_KEY
if not isinstance(key, str) or not key:
raise BuilderExecutionPolicyError("invalid_scope_key")
if (
not isinstance(encrypted_credentials, str)
or not encrypted_credentials
or type(expires_at) is not int
or expires_at < -1
):
raise BuilderExecutionPolicyError("invalid_refresh_result")
snapshot = selection.snapshot
if snapshot.reference.credential_type != CredentialType.OAUTH2:
raise BuilderExecutionPolicyError("native_materialization_phase_mismatch")
with session_factory.get_session_maker().begin() as session:
row = session.scalar(
select(BuiltinToolProvider)
.where(
BuiltinToolProvider.id == snapshot.reference.credential_id,
BuiltinToolProvider.tenant_id == actor.tenant_id,
)
.with_for_update()
)
if row is None:
raise BuilderExecutionPolicyError("native_credential_drift")
if snapshot.access.grant_ids:
session.scalars(
select(CredentialPermission)
.where(CredentialPermission.id.in_(snapshot.access.grant_ids))
.with_for_update()
).all()
client_model = ToolOAuthTenantClient if snapshot.client.source == "tenant" else ToolOAuthSystemClient
session.scalar(select(client_model).where(client_model.id == snapshot.client.row_id).with_for_update())
current = select_builder_credential(
session,
actor=actor,
provider_id=provider_id,
credential_id=snapshot.reference.credential_id,
verified_plugin=verified_plugin,
revision_key=key.encode(),
)
if current != snapshot or row.user_id != selection.owner_user_id:
raise BuilderExecutionPolicyError("native_credential_drift")
updated = session.scalar(
update(BuiltinToolProvider)
.where(
BuiltinToolProvider.id == row.id,
BuiltinToolProvider.tenant_id == actor.tenant_id,
BuiltinToolProvider.encrypted_credentials == snapshot.encrypted_credentials,
BuiltinToolProvider.expires_at == snapshot.reference.expires_at,
)
.values(encrypted_credentials=encrypted_credentials, expires_at=expires_at)
.returning(BuiltinToolProvider.id)
.execution_options(synchronize_session=False)
)
if updated != row.id:
raise BuilderExecutionPolicyError("native_credential_drift")
session.expire(row)
reference = select_builder_credential(
session,
actor=actor,
provider_id=provider_id,
credential_id=snapshot.reference.credential_id,
verified_plugin=verified_plugin,
revision_key=key.encode(),
).reference
return reference
@staticmethod
def decrypt_builder_oauth_client(
*, tenant_id: str, provider_controller: BuiltinToolProviderController, client: OAuthClientSnapshot
@@ -0,0 +1,786 @@
"""Inert declaration preparation; controlled metadata is not credential qualification."""
import importlib
import importlib.util
from types import SimpleNamespace
import pytest
from core.tools.entities.tool_entities import ToolEntity
from models.agent_config_entities import AgentSoulToolsConfig
def service_module():
assert importlib.util.find_spec("services.dify_builder.native_testing.agent_tool_declarations") is not None, (
"Inert Agent declaration owner is missing"
)
return importlib.import_module("services.dify_builder.native_testing.agent_tool_declarations")
def entity():
return ToolEntity.model_validate(
{
"identity": {"author": "test", "name": "write", "label": {"en_US": "Write"}, "provider": "provider"},
"description": {"human": {"en_US": "Write"}, "llm": "Write"},
"parameters": [
{
"name": "enabled",
"type": "boolean",
"form": "llm",
"label": {"en_US": "Enabled"},
"human_description": {"en_US": "Enabled"},
"llm_description": "Enabled",
"required": True,
"default": True,
}
],
}
)
@pytest.mark.parametrize("credential_id", [None, "credential"])
def test_schema_preparation_never_constructs_runtime_or_refreshes_expired_oauth(credential_id):
module = service_module()
calls = []
def metadata(**kwargs):
calls.append(kwargs)
return module.AgentToolMetadata(
entity=entity(), implementation_revision="plugin@revision", requires_credentials=True
)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_type": "plugin",
"plugin_id": "test/plugin",
"provider": "provider",
"tool_name": "write",
"credential_type": "oauth2" if credential_id else "unauthorized",
**({"credential_ref": {"id": credential_id}} if credential_id else {}),
}
]
}
)
service = module.AgentToolDeclarationService(metadata=metadata)
result = service.prepare(tenant_id="tenant", actor_id="actor", app_id="app", tools=tools)
assert result.plugin_tools.tools[0].credentials == {}
assert result.plugin_tools.tools[0].parameters_json_schema["properties"]["enabled"]["type"] == "boolean"
assert result.references[0].credential_id == credential_id
assert result.references[0].readiness == ("needs_setup" if credential_id is None else "unresolved")
assert "token" not in result.plugin_tools.model_dump_json()
assert len(calls) == 1
def test_default_plugin_metadata_path_does_not_call_dynamic_or_credential_runtime(monkeypatch):
module = service_module()
from core.plugin.impl.oauth import OAuthHandler
from core.tools.tool_manager import ToolManager
from core.tools.utils.configuration import ToolParameterConfigurationManager
def forbidden(*_args, **_kwargs):
pytest.fail("Inert declarations touched dynamic schema, decryption, or OAuth")
tool = SimpleNamespace(
entity=entity(), get_merged_runtime_parameters=forbidden, get_llm_parameters_json_schema=forbidden
)
controller = SimpleNamespace(
need_credentials=True, plugin_unique_identifier="test/plugin@sha", get_tools=lambda: [tool]
)
monkeypatch.setattr(ToolManager, "get_plugin_provider", lambda *_args: controller)
monkeypatch.setattr(ToolManager, "get_agent_tool_runtime", forbidden)
monkeypatch.setattr(OAuthHandler, "refresh_credentials", forbidden)
monkeypatch.setattr(ToolParameterConfigurationManager, "decrypt_tool_parameters", forbidden)
tools = AgentSoulToolsConfig.model_validate(
{
"dify_tools": [
{
"provider_type": "plugin",
"plugin_id": "test/plugin",
"provider": "provider",
"tool_name": "write",
"credential_type": "oauth2",
"credential_ref": {"id": "expired-credential"},
}
]
}
)
result = module.AgentToolDeclarationService().prepare(
tenant_id="tenant", actor_id="actor", app_id="app", tools=tools
)
assert result.references[0].credential_id == "expired-credential"
assert result.plugin_tools.tools[0].credentials == {}
@pytest.mark.parametrize("change", [None, "owner", "intent", "key", "scope", "state", "expiry", "permit_expiry"])
def test_same_permit_validation_does_not_claim_or_spend(change):
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError, canonical_digest
from services.dify_builder.native_testing.authorization import EffectAuthorizationService, _scope_digest
from services.dify_builder.native_testing.contracts import DispatchPermit, NativeTestContextRef, operation_digest
from tests.unit_tests.services.dify_builder.native_testing.test_authorization import binding, intent
now = datetime(2026, 10, 15, tzinfo=UTC)
ref = NativeTestContextRef(request_id="request", invocation_id="inv", context_digest="a" * 64)
effect = intent()
trusted = binding(effect)
expiry = now + timedelta(minutes=1)
permit = DispatchPermit(
permit_id="permit", attempt_id="attempt", operation_id="operation", expires_at=expiry.isoformat()
)
operation = SimpleNamespace(
id="operation",
operation_digest=operation_digest(effect.key),
intent_digest=canonical_digest(effect.model_dump(mode="json")),
scope_digest=_scope_digest(effect, trusted),
state="claimed",
)
attempt = SimpleNamespace(expires_at=expiry)
if change == "intent":
operation.intent_digest = "f" * 64
if change == "key":
operation.operation_digest = "f" * 64
if change == "scope":
operation.scope_digest = "f" * 64
if change == "state":
operation.state = "completed"
if change == "expiry":
attempt.expires_at = now
if change == "permit_expiry":
permit = permit.model_copy(update={"expires_at": (expiry + timedelta(seconds=1)).isoformat()})
@contextmanager
def locked(**_kwargs):
yield (
SimpleNamespace(scalar=lambda _query: operation),
SimpleNamespace(tenant_id="tenant", ref=ref),
SimpleNamespace(expires_at=expiry),
)
loader = SimpleNamespace(load=lambda **_kwargs: None, locked=locked)
class Service(EffectAuthorizationService):
def _permit_ref(self, _permit):
return ref.model_copy(update={"invocation_id": "foreign"}) if change == "owner" else ref
def _attempt(self, *_args):
return attempt
def _binding(self, *_args):
return trusted
def _claim(self, *_args):
pytest.fail("validation spent a second permit")
service = Service(loader, clock=lambda: now)
assert hasattr(service, "validate_dispatch"), "Same-permit validator is missing"
if change is None:
assert service.validate_dispatch(ref=ref, intent=effect, permit=permit) is None
else:
with pytest.raises(BuilderExecutionPolicyError):
service.validate_dispatch(ref=ref, intent=effect, permit=permit)
def test_marked_request_builder_stops_missing_ref_before_metadata():
from core.workflow.nodes.agent_v2.runtime_request_builder import (
WorkflowAgentRuntimeRequestBuilder,
WorkflowAgentRuntimeRequestBuildError,
)
context = SimpleNamespace(dify_context=SimpleNamespace(native_test_required=True, native_test_ref=None))
with pytest.raises(WorkflowAgentRuntimeRequestBuildError, match="authority"):
WorkflowAgentRuntimeRequestBuilder().build(context)
@pytest.mark.parametrize("kind", ["waiting", "stop", "recorded", "dispatch"])
def test_private_bridge_maps_exact_decisions_without_losing_payload(kind):
from dify_agent.protocol.native_testing import AgentEffectIntent, AgentNativeTestRef
from services.dify_builder.native_testing.contracts import ApprovalWait, DispatchPermit, PolicyStop, RecordedResult
from tests.unit_tests.services.dify_builder.native_testing.test_authorization import intent
assert importlib.util.find_spec("services.dify_builder.native_testing.agent_bridge") is not None, (
"Private bridge is missing"
)
module = importlib.import_module("services.dify_builder.native_testing.agent_bridge")
effect = intent()
ref = AgentNativeTestRef(
policy_version="builder-native-test-v1", request_id="request", invocation_id="inv", context_digest="a" * 64
)
decisions = {
"waiting": ApprovalWait(pending_operation_id="pending", wait_reason="configuration"),
"stop": PolicyStop(reason_code="denied"),
"recorded": RecordedResult(operation_id="operation", result_ref="result"),
"dispatch": DispatchPermit(
permit_id="permit", attempt_id="attempt", operation_id="operation", expires_at="2030-01-01T00:00:00Z"
),
}
seen = []
def before_call(**kwargs):
seen.append(kwargs)
return decisions[kind]
bridge = module.AgentNativeTestingBridge(
authorization=SimpleNamespace(before_call=before_call), readiness=None, materializer=None, fence=None
)
response = bridge.before_call(ref=ref, intent=AgentEffectIntent.model_validate(effect.model_dump(mode="json")))
assert response.model_dump(mode="json") == decisions[kind].model_dump(mode="json")
assert seen[0]["intent"] == effect
def test_materialization_rejects_phase_mismatch_and_missing_fence_before_io():
from dify_agent.protocol.native_testing import AgentDispatchPermit, AgentEffectIntent, AgentNativeTestRef
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError
from tests.unit_tests.services.dify_builder.native_testing.test_authorization import intent
assert importlib.util.find_spec("services.dify_builder.native_testing.agent_bridge") is not None, (
"Private bridge is missing"
)
module = importlib.import_module("services.dify_builder.native_testing.agent_bridge")
calls = []
bridge = module.AgentNativeTestingBridge(
authorization=SimpleNamespace(validate_dispatch=lambda **_kwargs: calls.append("validated")),
readiness=None,
materializer=SimpleNamespace(materialize=lambda **_kwargs: pytest.fail("materialized without durable fence")),
fence=None,
)
kwargs = {
"ref": AgentNativeTestRef(
policy_version="builder-native-test-v1", request_id="request", invocation_id="inv", context_digest="a" * 64
),
"intent": AgentEffectIntent.model_validate(intent().model_dump(mode="json")),
"permit": AgentDispatchPermit(
permit_id="permit", attempt_id="attempt", operation_id="operation", expires_at="2030-01-01T00:00:00Z"
),
"tool_ref": "mail.read",
}
with pytest.raises(BuilderExecutionPolicyError, match="phase"):
bridge.materialize(**kwargs, phase="refresh")
assert calls == []
with pytest.raises(BuilderExecutionPolicyError, match="fence"):
bridge.materialize(**kwargs, phase="business")
@pytest.mark.parametrize("path", ["before-call", "outcome", "materialize", "claim-dispatch"])
def test_private_native_routes_require_existing_service_auth(path):
from flask import Flask
from controllers.inner_api import bp
from tests.unit_tests.controllers.inner_api.test_agent_tools import _agent_inner_auth
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(bp)
with _agent_inner_auth():
response = app.test_client().post(f"/inner/api/agent/native-testing/{path}", json={})
assert response.status_code == 404
@pytest.mark.parametrize("reason", ["approval", "configuration", "interaction"])
def test_private_before_call_http_preserves_wait_reason(monkeypatch, reason):
from dify_agent.protocol.native_testing import AgentApprovalWait
from flask import Flask
from controllers.inner_api import bp
from controllers.inner_api.agent import native_testing as controller
from tests.unit_tests.controllers.inner_api.test_agent_tools import _agent_inner_auth, _headers
from tests.unit_tests.services.dify_builder.native_testing.test_authorization import intent
monkeypatch.setattr(
controller,
"agent_bridge_for_ref",
lambda _ref: SimpleNamespace(
before_call=lambda **_kwargs: AgentApprovalWait(pending_operation_id="pending", wait_reason=reason)
),
)
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(bp)
with _agent_inner_auth():
response = app.test_client().post(
"/inner/api/agent/native-testing/before-call",
json={
"ref": {
"policy_version": "builder-native-test-v1",
"request_id": "request",
"invocation_id": "inv",
"context_digest": "a" * 64,
},
"intent": intent().model_dump(mode="json"),
},
headers=_headers(),
)
assert response.status_code == 200
assert response.get_json() == {"kind": "waiting", "pending_operation_id": "pending", "wait_reason": reason}
def test_native_credential_phases_wait_refresh_then_business_without_double_spend():
from services.dify_builder.native_testing import agent_bridge as module
from services.dify_builder.native_testing.contracts import ApprovalWait, DispatchPermit, RecordedResult
from tests.unit_tests.services.dify_builder.native_testing.test_authorization import intent
assert hasattr(module, "NativeAgentCredentialPhases"), "Native credential phase owner is missing"
business = intent()
refresh = business.model_copy(
update={"kind": "oauth", "key": business.key.model_copy(update={"occurrence_path": ("tool", "oauth_refresh")})}
)
permit = DispatchPermit(
permit_id="permit", attempt_id="attempt", operation_id="operation", expires_at="2030-01-01T00:00:00Z"
)
calls = []
decisions = [
ApprovalWait(pending_operation_id="pending"),
permit,
RecordedResult(operation_id="operation", result_ref="receipt"),
]
auth = SimpleNamespace(
before_call=lambda **kw: calls.append(("authorize", kw["intent"].kind)) or decisions.pop(0),
record_outcome=lambda **kw: calls.append(("outcome", kw["outcome"])),
)
owner = module.NativeAgentCredentialPhases(
authorization=auth,
materializer=SimpleNamespace(
materialize=lambda **kw: (
calls.append(("refresh", kw["phase"]))
or SimpleNamespace(runtime_parameters={"credential_ref": "rotated"})
)
),
state_store=SimpleNamespace(
store_result=lambda **_kw: "receipt", load_result=lambda **_kw: {"credential_ref": "rotated"}
),
)
assert owner.refresh(ref="ref", intent=refresh).kind == "waiting"
assert calls == [("authorize", "oauth")]
assert owner.refresh(ref="ref", intent=refresh) is None
assert calls.count(("refresh", "refresh")) == 1
assert ("authorize", "tool") not in calls
assert owner.refresh(ref="ref", intent=refresh) is None
assert calls.count(("refresh", "refresh")) == 1
def test_native_materializer_phase_rejected_before_credential_selection():
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError
from services.dify_builder.native_testing import agent_bridge as module
from tests.unit_tests.services.dify_builder.native_testing.test_authorization import intent
assert hasattr(module, "NativeAgentCredentialMaterializer"), "Native credential materializer is missing"
owner = module.NativeAgentCredentialMaterializer(
authorization=SimpleNamespace(),
resolve_binding=lambda **_kw: pytest.fail("Phase mismatch reached credential selection"),
)
with pytest.raises(BuilderExecutionPolicyError, match="native_materialization_phase_mismatch"):
owner.materialize(ref=None, intent=intent(), permit=None, tool_ref=intent().resource_ref, phase="refresh")
def test_actual_native_materializer_uses_existing_oauth_owner_then_cas_before_business(monkeypatch):
from core.dify_builder.models import Actor
from core.dify_builder.real_call_consent import CredentialReference
from core.plugin.impl.oauth import OAuthHandler
from core.tools.tool_manager import ToolManager
from services.dify_builder.native_testing import agent_bridge as module
from services.dify_builder.native_testing.contracts import ApprovalWait, DispatchPermit, RecordedResult
from tests.unit_tests.services.dify_builder.native_testing.test_authorization import intent
actor = Actor(account_id="actor", tenant_id="tenant")
reference = CredentialReference(
credential_id="credential",
provider_id="test/plugin/provider",
alias="test",
credential_type="oauth2",
visibility="only_me",
revision="a" * 64,
oauth_client_revision="b" * 64,
expires_at=1,
)
snapshot = SimpleNamespace(reference=reference, encrypted_credentials='{"token":"fixture"}', client="client")
selection = SimpleNamespace(snapshot=snapshot, owner_user_id="owner")
binding = module.AgentCredentialBinding(
actor, "resource", "test/plugin/provider", "credential", True, "revision", "configuration-owner"
)
events = []
controller = SimpleNamespace(plugin_unique_identifier="revision", get_credentials_schema_by_type=lambda _: [])
monkeypatch.setattr(ToolManager, "get_builtin_provider", lambda *_a: controller)
monkeypatch.setattr(module.BuiltinToolManageService, "load_builder_credential", lambda **_kw: selection)
monkeypatch.setattr(
module.BuiltinToolManageService, "decrypt_builder_oauth_client", lambda **_kw: {"client": "fixture"}
)
monkeypatch.setattr("core.helper.credential_utils.runtime_check_credential_policy_compliance", lambda **_kw: None)
monkeypatch.setattr(
"core.tools.utils.encryption.create_provider_encrypter",
lambda **_kw: (
SimpleNamespace(decrypt=lambda value: events.append("decrypt") or value, encrypt=lambda value: value),
None,
),
)
monkeypatch.setattr(
"core.helper.provider_cache.ToolProviderCredentialsCache.delete", lambda _self: events.append("cache")
)
def refresh(**_kwargs):
assert events[-2:] == ["claim", "decrypt"]
events.append("refresh")
return SimpleNamespace(credentials={"token": "rotated-fixture"}, expires_at=4102444800)
def persist(**kwargs):
assert events[-1] == "refresh"
events.append("cas")
snapshot.reference = reference.model_copy(update={"revision": "c" * 64, "expires_at": 4102444800})
snapshot.encrypted_credentials = kwargs["encrypted_credentials"]
return snapshot.reference
monkeypatch.setattr(OAuthHandler, "refresh_credentials", lambda _self, **kw: refresh(**kw))
monkeypatch.setattr(module.BuiltinToolManageService, "persist_native_agent_refresh", persist)
permit = DispatchPermit(
permit_id="permit", attempt_id="attempt", operation_id="operation", expires_at="2030-01-01T00:00:00Z"
)
decision = [
ApprovalWait(pending_operation_id="pending"),
permit,
RecordedResult(operation_id="operation", result_ref="result"),
]
auth = SimpleNamespace(
loader=SimpleNamespace(actor=actor, load=lambda **_kw: None),
validate_dispatch=lambda **_kw: None,
claim_dispatch=lambda **_kw: events.append("claim"),
before_call=lambda **_kw: decision.pop(0),
record_outcome=lambda **kw: events.append(kw["outcome"]),
)
materializer = module.NativeAgentCredentialMaterializer(authorization=auth, resolve_binding=lambda **_kw: binding)
phases = module.NativeAgentCredentialPhases(
authorization=auth,
materializer=materializer,
state_store=SimpleNamespace(
store_result=lambda **_kw: "result", load_result=lambda **_kw: {"credential_ref": "opaque"}
),
)
effect = intent().model_copy(
update={
"kind": "oauth",
"resource_ref": "resource",
"implementation_revision": "revision",
"account_ref": materializer.account_ref(selection),
}
)
assert phases.refresh(ref="ref", intent=effect).kind == "waiting"
assert events == []
assert phases.refresh(ref="ref", intent=effect) is None
assert events == ["claim", "decrypt", "refresh", "cas", "cache", "completed"]
assert phases.refresh(ref="ref", intent=effect) is None
assert events.count("refresh") == 1
business = effect.model_copy(update={"kind": "tool", "account_ref": materializer.account_ref(selection)})
result = materializer.materialize(ref="ref", intent=business, permit=permit, tool_ref="resource", phase="business")
assert result.credentials == {"token": "rotated-fixture"}
assert events.count("refresh") == events.count("cas") == events.count("claim") == 1
def test_missing_credential_configuration_wait_precedes_business_intent(monkeypatch):
from services.dify_builder.native_testing import agent_bridge as module
from services.dify_builder.native_testing.contracts import ApprovalWait
from services.dify_builder.native_testing.pending_actions import NativePendingActionService
from tests.unit_tests.services.dify_builder.native_testing.test_authorization import intent
calls = []
monkeypatch.setattr(
NativePendingActionService,
"configuration",
lambda _self, **kw: (
calls.append(kw) or ApprovalWait(pending_operation_id="configuration-id", wait_reason="configuration")
),
)
auth = SimpleNamespace(
loader=SimpleNamespace(load=lambda **_kw: SimpleNamespace(generation=3)),
before_call=lambda **_kw: pytest.fail("Configuration froze an account effect"),
)
owner = module.NativeAgentCredentialPhases(
authorization=auth,
materializer=SimpleNamespace(
inspect=lambda **_kw: (SimpleNamespace(requirements_ref="actual-config-owner"), None)
),
state_store=None,
)
assert hasattr(owner, "prepare_business"), "Readiness must precede business intent freeze"
effect = intent()
result = owner.prepare_business(
ref="ref",
key=effect.key,
tool_ref=effect.resource_ref,
target=effect.target,
parameters=effect.parameters,
input_digest=effect.input_digest,
capability_digest=effect.capability_digest,
refresh_intent=None,
)
assert result.wait_reason == "configuration"
assert calls == [{"ref": "ref", "requirements_ref": "actual-config-owner", "expected_generation": 3}]
@pytest.mark.parametrize("case", ["omitted", "changed", "caller", "matching"])
def test_endpoint_lineage_requires_exact_envelope_before_claim(case, monkeypatch):
from dify_agent.adapters.native_testing.client import AgentPhysicalDispatchPayload
from flask import Flask
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError
from services.dify_builder.native_testing import agent_bridge as module
from tests.unit_tests.services.dify_builder.native_testing.test_authorization import intent
assert hasattr(module, "claim_agent_endpoint"), "Actual endpoint lineage gate is missing"
effect = intent()
payload = AgentPhysicalDispatchPayload.model_validate(
{
"ref": {
"policy_version": "builder-native-test-v1",
"request_id": "request",
"invocation_id": effect.key.invocation_id,
"context_digest": "a" * 64,
},
"intent": effect.model_dump(mode="json"),
"permit": {
"permit_id": "permit",
"attempt_id": "attempt",
"operation_id": "operation",
"expires_at": "2030-01-01T00:00:00Z",
},
}
)
calls = []
app = Flask(__name__)
app.extensions["native_agent_endpoint_lineage"] = SimpleNamespace(resolve=lambda **_kw: payload.ref)
monkeypatch.setattr(
module,
"agent_bridge_for_ref",
lambda _ref: SimpleNamespace(
authorization=SimpleNamespace(
loader=SimpleNamespace(
load=lambda **_kw: SimpleNamespace(tenant_id="tenant", app_id="app", actor_id="actor")
),
claim_dispatch=lambda **kw: calls.append(kw),
)
),
)
with app.app_context():
kwargs = {
"boundary": "model",
"caller": {
"tenant_id": "other" if case == "caller" else "tenant",
"app_id": "app",
"user_id": "actor",
"user_from": "account",
},
"envelope": None if case == "omitted" else payload,
"target": effect.target,
"parameters": {"changed": True} if case == "changed" else effect.parameters,
"kind": effect.kind,
}
if case == "matching":
module.claim_agent_endpoint(**kwargs)
assert len(calls) == 1
else:
with pytest.raises(BuilderExecutionPolicyError):
module.claim_agent_endpoint(**kwargs)
assert calls == []
def test_actual_llm_endpoint_rejects_known_native_omission_before_prepare(monkeypatch):
from dify_agent.protocol.native_testing import AgentNativeTestRef
from services.agent_llm_inner_service import AgentLLMInnerService
from tests.unit_tests.controllers.inner_api.test_agent_llm import _agent_inner_auth, _flask_app, _payload
app = _flask_app()
ref = AgentNativeTestRef(
policy_version="builder-native-test-v1",
request_id="request",
invocation_id="invocation",
context_digest="a" * 64,
)
app.extensions["native_agent_endpoint_lineage"] = SimpleNamespace(resolve=lambda **_kw: ref)
monkeypatch.setattr(
AgentLLMInnerService, "prepare", lambda *_args: pytest.fail("Known native omission reached model preparation")
)
with _agent_inner_auth():
response = app.test_client().post(
"/inner/api/agent/llm/invoke", json=_payload(), headers={"X-Inner-Api-Key": "inner-key"}
)
assert response.status_code == 409
def test_private_claim_route_consumes_once_and_rejects_repeated_request(monkeypatch):
from flask import Flask
from controllers.inner_api import bp
from controllers.inner_api.agent import native_testing as controller
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError
from tests.unit_tests.controllers.inner_api.test_agent_tools import _agent_inner_auth, _headers
from tests.unit_tests.services.dify_builder.native_testing.test_authorization import intent
# Only the HTTP adapter is exercised here. Durable concurrent uniqueness
# is covered by the shared authorization owner's PostgreSQL CI inventory.
claims = []
def claim(**kw):
if claims:
raise BuilderExecutionPolicyError("native_dispatch_already_claimed")
claims.append(kw)
monkeypatch.setattr(
controller,
"agent_bridge_for_ref",
lambda _ref: SimpleNamespace(authorization=SimpleNamespace(claim_dispatch=claim)),
)
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(bp)
payload = {
"ref": {
"policy_version": "builder-native-test-v1",
"request_id": "request",
"invocation_id": "inv",
"context_digest": "a" * 64,
},
"intent": intent().model_dump(mode="json"),
"permit": {
"permit_id": "permit",
"attempt_id": "attempt",
"operation_id": "operation",
"expires_at": "2030-01-01T00:00:00Z",
},
}
with _agent_inner_auth():
first = app.test_client().post(
"/inner/api/agent/native-testing/claim-dispatch", json=payload, headers=_headers()
)
second = app.test_client().post(
"/inner/api/agent/native-testing/claim-dispatch", json=payload, headers=_headers()
)
assert first.status_code == 200
assert first.get_json() == {"claimed": True}
assert second.status_code == 409
assert len(claims) == 1
@pytest.mark.parametrize("drift", [None, "access", "client", "encrypted_credentials"])
def test_native_refresh_cas_rechecks_detached_snapshot_before_write(monkeypatch, drift):
from contextlib import contextmanager
from copy import copy
from core.dify_builder.models import Actor
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError
from services.tools import builtin_tools_manage_service as module
actor = Actor(account_id="actor", tenant_id="tenant")
snapshot = SimpleNamespace(
reference=SimpleNamespace(credential_type="oauth2", credential_id="credential", expires_at=1),
access=SimpleNamespace(grant_ids=()),
client=SimpleNamespace(source="tenant", row_id="client"),
encrypted_credentials="old-ciphertext",
)
selected = SimpleNamespace(snapshot=snapshot, owner_user_id="owner")
current = copy(snapshot)
if drift:
setattr(current, drift, "changed")
reads = []
def scalar(statement):
reads.append(statement)
return [SimpleNamespace(id="credential", user_id="owner"), object(), "credential"][len(reads) - 1]
@contextmanager
def transaction():
yield SimpleNamespace(scalar=scalar, expire=lambda _row: None)
monkeypatch.setattr(module.session_factory, "get_session_maker", lambda: SimpleNamespace(begin=transaction))
monkeypatch.setattr(module.dify_config, "SECRET_KEY", "fixture-only")
monkeypatch.setattr(module, "select_builder_credential", lambda *_args, **_kw: current)
kwargs = {
"actor": actor,
"provider_id": "test/plugin/provider",
"verified_plugin": True,
"selection": selected,
"encrypted_credentials": "new-ciphertext",
"expires_at": 4102444800,
}
if drift:
with pytest.raises(BuilderExecutionPolicyError, match="native_credential_drift"):
module.BuiltinToolManageService.persist_native_agent_refresh(**kwargs)
assert len(reads) == 2
else:
assert module.BuiltinToolManageService.persist_native_agent_refresh(**kwargs) == current.reference
assert len(reads) == 3
@pytest.mark.parametrize("field", ["target", "parameters"])
@pytest.mark.parametrize(
("approved", "actual", "matches"),
[
pytest.param({"nested": {"flag": True}}, {"nested": {"flag": 1}}, False, id="nested-true-integer"),
pytest.param({"items": [{"flag": False}]}, {"items": [{"flag": 0}]}, False, id="array-false-integer"),
pytest.param({"items": [1]}, {"items": [1.0]}, False, id="integer-float"),
pytest.param({"items": [-0.0]}, {"items": [0.0]}, False, id="signed-zero"),
pytest.param(
{"z": [True, 1.0], "a": {"b": 2, "a": 1}}, {"a": {"a": 1, "b": 2}, "z": [True, 1.0]}, True, id="key-order"
),
],
)
def test_endpoint_typed_json_guard_precedes_physical_claim(field, approved, actual, matches, monkeypatch):
from dify_agent.adapters.native_testing.client import AgentPhysicalDispatchPayload
from flask import Flask
from services.dify_builder.execution_contracts import BuilderExecutionPolicyError
from services.dify_builder.native_testing import agent_bridge as module
from tests.unit_tests.services.dify_builder.native_testing.test_authorization import intent
effect = intent().model_copy(update={field: approved})
payload = AgentPhysicalDispatchPayload.model_validate(
{
"ref": {
"policy_version": "builder-native-test-v1",
"request_id": "request",
"invocation_id": effect.key.invocation_id,
"context_digest": "a" * 64,
},
"intent": effect.model_dump(mode="json"),
"permit": {
"permit_id": "permit",
"attempt_id": "attempt",
"operation_id": "operation",
"expires_at": "2030-01-01T00:00:00Z",
},
}
)
claims = []
app = Flask(__name__)
app.extensions["native_agent_endpoint_lineage"] = SimpleNamespace(resolve=lambda **_kw: payload.ref)
monkeypatch.setattr(
module,
"agent_bridge_for_ref",
lambda _ref: SimpleNamespace(
authorization=SimpleNamespace(
loader=SimpleNamespace(
load=lambda **_kw: SimpleNamespace(tenant_id="tenant", app_id="app", actor_id="actor")
),
claim_dispatch=lambda **kw: claims.append(kw),
)
),
)
with app.app_context():
kwargs = {
"boundary": "model",
"caller": {"tenant_id": "tenant", "app_id": "app", "user_id": "actor", "user_from": "account"},
"envelope": payload,
"target": effect.target,
"parameters": effect.parameters,
"kind": effect.kind,
field: actual,
}
if matches:
module.claim_agent_endpoint(**kwargs)
assert len(claims) == 1
else:
with pytest.raises(BuilderExecutionPolicyError, match="native_endpoint_arguments_mismatch"):
module.claim_agent_endpoint(**kwargs)
assert claims == []
@@ -12,15 +12,13 @@ import json
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from itertools import count
from typing import NoReturn, Protocol, cast
from typing import TYPE_CHECKING, NoReturn, Protocol, cast, override
from uuid import NAMESPACE_URL, uuid5
import httpx
from graphon.model_runtime.entities.llm_entities import LLMResultChunk
from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool
from pydantic import BaseModel
from typing_extensions import override
from pydantic import BaseModel, JsonValue, TypeAdapter
from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, UnexpectedModelBehavior, UserError
from pydantic_ai.providers import Provider
@@ -30,6 +28,10 @@ from dify_agent.plugin_daemon_transport import (
to_plugin_daemon_jsonable,
unwrap_plugin_daemon_error,
)
from dify_agent.runtime.native_continuation import NativePolicyStopped
if TYPE_CHECKING:
from dify_agent.runtime.native_effects import NativeEffectSession
class DifyLLMClient(Protocol):
@@ -66,6 +68,7 @@ class DifyApiLLMClient:
execution_context: DifyExecutionContextLayerConfig
agent_run_id: str
http_client: httpx.AsyncClient = field(repr=False)
native_effects: NativeEffectSession | None = field(default=None, repr=False)
_call_counter: count[int] = field(default_factory=lambda: count(1), init=False, repr=False)
def __post_init__(self) -> None:
@@ -81,6 +84,74 @@ class DifyApiLLMClient:
tools: list[PromptMessageTool] | None,
stop: list[str] | None,
stream: bool,
) -> AsyncIterator[LLMResultChunk]:
arguments = {
"provider": provider,
"model": model,
"prompt_messages": prompt_messages,
"model_parameters": model_parameters,
"tools": tools,
"stop": stop,
"stream": stream,
}
if self.native_effects is None:
async for chunk in self._iter_llm_result_chunks(**arguments):
yield chunk
return
# The existing API gateway always invokes a stream, including requests
# from a non-streaming consumer. Freeze that actual effective argument.
arguments["stream"] = True
async def invoke(_permit) -> JsonValue:
chunks: list[JsonValue] = []
size = 0
async for chunk in self._iter_llm_result_chunks(**arguments):
value = chunk.model_dump(mode="json")
size += len(json.dumps(value, ensure_ascii=False).encode())
if size > 1024 * 1024:
raise NativePolicyStopped("native_model_result_too_large")
chunks.append(TypeAdapter(JsonValue).validate_python(value))
return chunks
state = self.native_effects.continuation
result = await self.native_effects.stage(
boundary="model",
call_id=f"model-{state.operation_cursor}",
target={
"plugin_id": self.plugin_id,
"provider": provider if provider.count("/") == 2 else f"{self.plugin_id}/{provider}",
"model": model,
},
parameters=TypeAdapter(JsonValue).validate_python(
to_plugin_daemon_jsonable(
{
"prompt_messages": prompt_messages,
"model_parameters": model_parameters,
"tools": tools,
"stop": stop,
"stream": True,
}
)
),
stage="model",
invoke=invoke,
)
if not isinstance(result, list):
raise UnexpectedModelBehavior("Invalid protected model result")
state.operation_cursor += 1
for chunk in result:
yield LLMResultChunk.model_validate(chunk)
async def _iter_llm_result_chunks(
self,
*,
provider: str,
model: str,
prompt_messages: list[PromptMessage],
model_parameters: dict[str, object],
tools: list[PromptMessageTool] | None,
stop: list[str] | None,
stream: bool,
) -> AsyncIterator[LLMResultChunk]:
call_index = next(self._call_counter)
invocation_id = str(uuid5(NAMESPACE_URL, f"dify-agent:{self.agent_run_id}:llm:{call_index}"))
@@ -120,6 +191,14 @@ class DifyApiLLMClient:
},
}
)
if self.native_effects is not None:
from dify_agent.runtime.native_effects import current_native_dispatch
envelope = current_native_dispatch()
if envelope is None:
raise NativePolicyStopped("native_tool_permit_missing")
payload = TypeAdapter(dict[str, JsonValue]).validate_python(payload)
payload["native_dispatch"] = envelope.wire()
url = f"{self.inner_api_url}/inner/api/agent/llm/invoke"
headers = {
"X-Inner-Api-Key": self.inner_api_key,
@@ -129,6 +208,8 @@ class DifyApiLLMClient:
try:
async with self.http_client.stream("POST", url, headers=headers, json=payload) as response:
if response.is_error:
if self.native_effects is not None:
raise NativePolicyStopped("native_model_transport_unavailable")
body = (await response.aread()).decode("utf-8", errors="replace")
_raise_agent_gateway_http_error(
model_name=model,
@@ -178,6 +259,7 @@ class DifyApiLLMProvider(Provider[DifyLLMClient]):
execution_context: DifyExecutionContextLayerConfig
agent_run_id: str
http_client: httpx.AsyncClient = field(repr=False)
native_effects: NativeEffectSession | None = field(default=None, repr=False)
_client: DifyLLMClient = field(init=False, repr=False)
def __post_init__(self) -> None:
@@ -189,6 +271,7 @@ class DifyApiLLMProvider(Provider[DifyLLMClient]):
execution_context=self.execution_context,
agent_run_id=self.agent_run_id,
http_client=self.http_client,
native_effects=self.native_effects,
)
@override
@@ -0,0 +1 @@
"""Standalone private native-testing API adapter."""
@@ -0,0 +1,117 @@
"""Authenticated policy transport. Wire parsing never grants execution authority."""
from dataclasses import dataclass, field
from typing import Literal
import httpx
from pydantic import Field, JsonValue, TypeAdapter
from dify_agent.protocol.native_testing import (
ID,
AgentDispatchPermit,
AgentEffectDecision,
AgentEffectIntent,
AgentEffectReceipt,
AgentNativeTestRef,
NativeDTO,
)
class AgentBeforeCallPayload(NativeDTO):
ref: AgentNativeTestRef
intent: AgentEffectIntent
class AgentPhysicalDispatchPayload(AgentBeforeCallPayload):
permit: AgentDispatchPermit
class AgentOutcomePayload(NativeDTO):
ref: AgentNativeTestRef
permit: AgentDispatchPermit
outcome: Literal["completed", "failed", "unknown", "not_dispatched"]
result_ref: ID | None
class AgentMaterializePayload(NativeDTO):
ref: AgentNativeTestRef
intent: AgentEffectIntent
permit: AgentDispatchPermit
tool_ref: ID
phase: Literal["refresh", "business"]
class AgentMaterialization(NativeDTO):
"""Operation-local transport data; never persist in history or evidence."""
credential_type: Literal["api-key", "oauth2", "unauthorized"]
credentials: dict[str, str | int | float | bool | None] = Field(repr=False)
runtime_parameters: dict[str, JsonValue] = Field(repr=False)
class AgentPolicyTransportUnavailable(RuntimeError):
"""A control transport failure cannot become retryable tool/model text."""
@dataclass
class HttpAgentPolicyClient:
base_url: str
api_key: str = field(repr=False)
http_client: httpx.AsyncClient = field(repr=False)
# Bound by trusted composition; outcome must retain the original owner ref.
ref: AgentNativeTestRef | None = None
async def _post(self, path: str, payload: NativeDTO):
try:
response = await self.http_client.post(
f"{self.base_url.rstrip('/')}/inner/api/agent/native-testing/{path}",
headers={"X-Inner-Api-Key": self.api_key},
json=payload.model_dump(mode="json"),
)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, ValueError):
raise AgentPolicyTransportUnavailable("native_policy_transport_unavailable") from None
async def before_call(self, *, ref: AgentNativeTestRef, intent: AgentEffectIntent) -> AgentEffectDecision:
if self.ref is not None and self.ref != ref:
raise AgentPolicyTransportUnavailable("native_policy_owner_mismatch")
self.ref = ref
return TypeAdapter(AgentEffectDecision).validate_python(
await self._post("before-call", AgentBeforeCallPayload(ref=ref, intent=intent))
)
async def record_outcome(
self,
*,
permit: AgentDispatchPermit,
outcome: Literal["completed", "failed", "unknown", "not_dispatched"],
result_ref: str | None,
) -> AgentEffectReceipt:
if self.ref is None:
raise AgentPolicyTransportUnavailable("native_policy_owner_missing")
payload = AgentOutcomePayload(ref=self.ref, permit=permit, outcome=outcome, result_ref=result_ref)
return AgentEffectReceipt.model_validate(await self._post("outcome", payload))
async def materialize(
self,
*,
ref: AgentNativeTestRef,
intent: AgentEffectIntent,
permit: AgentDispatchPermit,
tool_ref: str,
phase: Literal["refresh", "business"],
) -> AgentMaterialization:
return AgentMaterialization.model_validate(
await self._post(
"materialize",
AgentMaterializePayload(ref=ref, intent=intent, permit=permit, tool_ref=tool_ref, phase=phase),
)
)
async def claim_dispatch(
self, *, ref: AgentNativeTestRef, intent: AgentEffectIntent, permit: AgentDispatchPermit
) -> None:
result = await self._post("claim-dispatch", AgentPhysicalDispatchPayload(ref=ref, intent=intent, permit=permit))
if result != {"claimed": True}:
raise AgentPolicyTransportUnavailable("native_dispatch_claim_invalid")
@@ -11,19 +11,20 @@ import shlex
from dataclasses import dataclass
from typing import override
from pydantic_ai import RunContext
from pydantic_ai.capabilities import AbstractCapability, CapabilityOrdering
from pydantic_ai.capabilities.abstract import WrapRunHandler
from pydantic_ai.run import AgentRunResult
from dify_agent.runtime.context import Deps
from dify_agent.layers._agent_cli_help import render_agent_stub_cli_help
from dify_agent.layers._agent_file_cli_help import AGENT_FILE_UPLOAD_REPLY_HINT as _AGENT_FILE_UPLOAD_REPLY_HINT
from dify_agent.layers.config.configs import (
DifyConfigLayerConfig,
DifyConfigRuntimeState,
)
from dify_agent.layers.shell.layer import Capability as ShellCapability, ShellSession
from dify_agent.layers.shell.layer import Capability as ShellCapability
from dify_agent.layers.shell.layer import ShellSession
from dify_agent.runtime.context import Deps
_CONFIG_CONTEXT_HEADING = "Current Agent config manifest for this run:"
_CONFIG_CONTEXT_COMMAND = "dify-agent config manifest"
@@ -204,6 +205,25 @@ class _ConfigContext:
self.runtime_state.pulled_file_outputs = _parse_file_pull_outputs(output, names)
async def _run_mentioned_pull(self, *, script: str, target_kind: str) -> str:
if self.shell.deps.native_effects is not None:
async def invoke(_permit):
return await self._run_mentioned_pull_unchecked(script=script, target_kind=target_kind)
result = await self.shell.deps.native_effects.stage(
boundary="config_pull",
call_id=target_kind,
target={"kind": target_kind, "shell": self.shell.name},
parameters={"script": script},
stage="configuration",
invoke=invoke,
)
if not isinstance(result, str):
raise DifyConfigLayerError("Invalid recorded configuration pull result")
return result
return await self._run_mentioned_pull_unchecked(script=script, target_kind=target_kind)
async def _run_mentioned_pull_unchecked(self, *, script: str, target_kind: str) -> str:
result = await self.shell.run_remote_script(
script,
inject_agent_stub_env=True,
@@ -290,4 +310,4 @@ def _format_command_output(command: str, output: str) -> str:
return f"Command:\n$ {command}\nOutput:\n{output}"
__all__ = ["Config", "State", "Capability", "DifyConfigLayerError"]
__all__ = ["Capability", "Config", "DifyConfigLayerError", "State"]
@@ -9,6 +9,7 @@ from typing import ClassVar, cast
import httpx
from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError
from dify_agent.adapters.native_testing.client import AgentPhysicalDispatchPayload
from dify_agent.layers.dify_core_tools.configs import DifyCoreToolConfig
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
@@ -76,6 +77,7 @@ class _DifyCoreToolsRequestTool(BaseModel):
class _DifyCoreToolsInvokeRequest(BaseModel):
caller: _DifyCoreToolsCaller
tool: _DifyCoreToolsRequestTool
native_dispatch: AgentPhysicalDispatchPayload | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@@ -108,7 +110,13 @@ class DifyCoreToolsClient:
) -> DifyCoreToolsInvokeResponse:
_validate_execution_context(execution_context)
from dify_agent.runtime.native_effects import current_native_dispatch
envelope = current_native_dispatch()
request_payload = _DifyCoreToolsInvokeRequest(
native_dispatch=AgentPhysicalDispatchPayload.model_validate(envelope.wire())
if envelope is not None
else None,
caller=_DifyCoreToolsCaller(
tenant_id=execution_context.tenant_id,
user_id=cast(str, execution_context.user_id),
@@ -140,7 +148,7 @@ class DifyCoreToolsClient:
"X-Inner-Api-Key": self.api_key,
"Content-Type": "application/json",
},
json=request_payload.model_dump(mode="json"),
json=request_payload.model_dump(mode="json", exclude={"native_dispatch"} if envelope is None else None),
)
except (httpx.InvalidURL, httpx.UnsupportedProtocol) as exc:
raise DifyCoreToolsClientError(f"Core tools are misconfigured: {exc}", retryable=False) from exc
@@ -13,13 +13,11 @@ from copy import deepcopy
from dataclasses import replace
from typing import cast, override
from pydantic import JsonValue
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
from pydantic_ai import RunContext, Tool
from pydantic_ai.tools import ToolDefinition
from pydantic import BaseModel, ConfigDict
from pydantic_ai.toolsets import FunctionToolset
from dify_agent.runtime.context import Deps
from dify_agent.layers.dify_core_tools.client import (
DifyCoreToolsClient,
DifyCoreToolsClientConfigurationError,
@@ -29,8 +27,11 @@ from dify_agent.layers.dify_core_tools.configs import (
DifyCoreToolConfig,
DifyCoreToolsLayerConfig,
)
from dify_agent.layers.dify_plugin.tools_layer import _prepare_tool_arguments, _require_native_file_dependencies
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from dify_agent.runtime.context import Deps
from dify_agent.runtime.native_continuation import NativePolicyStopped
from dify_agent.runtime.native_effects import NativeToolBoundary
CORE_TOOL_STRICT = False
TEMPORARY_UNAVAILABLE_OBSERVATION = "Tool is temporarily unavailable. Please continue without it if possible."
@@ -85,7 +86,18 @@ class Toolset(FunctionToolset[Deps]):
tool_description = tool_config.description or tool_name
tool_schema = deepcopy(tool_config.parameters_json_schema)
async def native_arguments(arguments: dict[str, object]) -> dict[str, JsonValue]:
# File references stay canonical; the API owner materializes them only
# after validating this same invocation's permit.
prepared = await _prepare_tool_arguments(
tool_config.parameters, tool_config, arguments, file_context=None, materialize_files=False
)
_require_native_file_dependencies(tool_config.parameters, prepared)
return TypeAdapter(dict[str, JsonValue]).validate_python(prepared)
async def invoke_tool(_ctx: RunContext[Deps], **tool_arguments: object) -> str:
if _ctx.deps.native_effects is not None:
raise NativePolicyStopped("native_dispatch_fence_unavailable")
try:
response = await client.invoke(
execution_context=execution_context,
@@ -107,6 +119,17 @@ class Toolset(FunctionToolset[Deps]):
name=tool_name,
description=tool_description,
prepare=prepare_tool_definition,
metadata={
"native_effect": NativeToolBoundary(
boundary="core_tool",
target={
"provider_type": tool_config.provider_type,
"provider_id": tool_config.provider_id,
"tool_name": tool_config.tool_name,
},
prepare_arguments=native_arguments,
)
},
)
@@ -33,6 +33,7 @@ class Capability(AbstractCapability[Deps]):
execution_context=ExecutionContextConfig.model_validate(deps.layers[config.execution_context]["config"]),
agent_run_id=deps.run_id,
http_client=deps.services.dify_api_http_client,
native_effects=deps.native_effects,
)
return DifyLLMAdapterModel(
model=config.model,
@@ -13,21 +13,20 @@ the FastAPI lifespan-owned shared plugin-daemon and Dify API clients into
from __future__ import annotations
from copy import deepcopy
import json
import mimetypes
from collections.abc import Mapping, Sequence
from copy import deepcopy
from dataclasses import dataclass, field, replace
from typing import ClassVar, override
from typing import ClassVar, Protocol, override
from urllib.parse import urlparse
import httpx
from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError
from pydantic_ai import RunContext, Tool
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.toolsets import FunctionToolset
from dify_agent.runtime.context import Deps
from dify_agent.layers.dify_plugin.configs import (
DifyPluginToolConfig,
DifyPluginToolParameter,
@@ -42,7 +41,9 @@ from dify_agent.layers.dify_plugin.tool_client import (
)
from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig
from dify_agent.layers.execution_context.layer import Capability as ExecutionContextCapability
from dify_agent.runtime.context import Deps
from dify_agent.runtime.native_continuation import NativePolicyStopped
from dify_agent.runtime.native_effects import NativeToolBoundary, canonical_effect_json
# Plugin tools intentionally do not expose a per-tool strictness override in the
# public config. The API supplies already-prepared schemas, but Dify Agent always
@@ -227,13 +228,65 @@ def _build_pydantic_ai_tool(
client: DifyPluginDaemonToolClient,
tool_config: DifyPluginToolConfig,
effective_parameters: Sequence[DifyPluginToolParameter],
file_context: "_PluginToolFileContext",
file_context: _PluginToolFileContext,
) -> Tool[Deps]:
tool_name = tool_config.name or tool_config.tool_name
tool_description = tool_config.description or tool_name
tool_schema = deepcopy(tool_config.parameters_json_schema)
def native_target() -> dict[str, JsonValue]:
return {
"plugin_id": tool_config.plugin_id,
"provider": tool_config.provider,
"tool_name": tool_config.tool_name,
}
async def native_arguments(arguments: dict[str, object]) -> dict[str, JsonValue]:
prepared = await _prepare_tool_arguments(
effective_parameters, tool_config, arguments, file_context=file_context, materialize_files=False
)
_require_native_file_dependencies(effective_parameters, prepared)
return TypeAdapter(dict[str, JsonValue]).validate_python(prepared)
async def invoke_tool(_ctx: RunContext[Deps], **tool_arguments: object) -> str:
if _ctx.deps.native_effects is not None:
from dify_agent.adapters.native_testing.client import HttpAgentPolicyClient
from dify_agent.runtime.native_effects import current_native_dispatch
policy = _ctx.deps.native_effects.dispatcher.policy
envelope = current_native_dispatch()
if envelope is None or not isinstance(policy, HttpAgentPolicyClient):
raise NativePolicyStopped("native_dispatch_fence_unavailable")
# No file dependency or unresolved secret may be converted after
# reserving the parent. Both have separate protected owners.
merged = await native_arguments(tool_arguments)
if any(isinstance(value, dict) and "secret_ref" in value for value in merged.values()):
raise NativePolicyStopped("native_parameter_materialization_unavailable")
if canonical_effect_json(native_target()) != canonical_effect_json(
envelope.intent.target
) or canonical_effect_json(merged) != canonical_effect_json(envelope.intent.parameters):
raise NativePolicyStopped("native_effect_arguments_changed")
materialized = await policy.materialize(
ref=envelope.ref,
intent=envelope.intent,
permit=envelope.permit,
tool_ref=envelope.intent.resource_ref,
phase="business",
)
if materialized.runtime_parameters:
raise NativePolicyStopped("native_parameter_materialization_unavailable")
await policy.claim_dispatch(ref=envelope.ref, intent=envelope.intent, permit=envelope.permit)
try:
messages = await client.invoke(
provider=tool_config.provider,
tool_name=tool_config.tool_name,
credential_type=materialized.credential_type,
credentials=dict(materialized.credentials),
tool_parameters=merged,
)
except DifyPluginToolClientError:
raise NativePolicyStopped("native_tool_transport_unavailable") from None
return _convert_tool_response_to_text(messages)
try:
merged_arguments = await _prepare_tool_arguments(
effective_parameters,
@@ -263,15 +316,46 @@ def _build_pydantic_ai_tool(
name=tool_name,
description=tool_description,
prepare=prepare_tool_definition,
metadata={
"native_effect": NativeToolBoundary(
boundary="direct_plugin",
target=native_target(),
prepare_arguments=native_arguments,
)
},
)
class _PreparedToolParameters(Protocol):
@property
def runtime_parameters(self) -> Mapping[str, object]: ...
def _require_native_file_dependencies(
parameters: Sequence[DifyPluginToolParameter], arguments: Mapping[str, object]
) -> None:
# NW4 must complete the stable file child before the parent business permit.
# Canonical refs are preserved by inert preparation; no signed URL is frozen.
if any(
parameter.name in arguments
and parameter.type
in {
DifyPluginToolParameterType.FILE,
DifyPluginToolParameterType.FILES,
DifyPluginToolParameterType.SYSTEM_FILES,
}
for parameter in parameters
):
raise NativePolicyStopped("native_file_dependency_unavailable")
async def _prepare_tool_arguments(
effective_parameters: Sequence[DifyPluginToolParameter],
tool_config: DifyPluginToolConfig,
tool_config: _PreparedToolParameters,
tool_arguments: Mapping[str, object],
*,
file_context: "_PluginToolFileContext",
file_context: _PluginToolFileContext | None,
materialize_files: bool = True,
) -> dict[str, object]:
"""Build the daemon invocation payload from prepared config + model args.
@@ -299,6 +383,14 @@ async def _prepare_tool_arguments(
raise ValueError(f"tool parameter {parameter.name} not found in tool config")
else:
continue
if not materialize_files and parameter.type in {
DifyPluginToolParameterType.FILE,
DifyPluginToolParameterType.FILES,
DifyPluginToolParameterType.SYSTEM_FILES,
DifyPluginToolParameterType.SECRET_INPUT,
}:
prepared_arguments[parameter.name] = value
continue
prepared_arguments[parameter.name] = await _cast_tool_parameter_value(
parameter.type,
value,
@@ -395,7 +487,7 @@ async def _cast_tool_parameter_value(
parameter_type: DifyPluginToolParameterType,
value: object,
*,
file_context: _PluginToolFileContext,
file_context: _PluginToolFileContext | None,
) -> object:
"""Cast prepared tool argument values into daemon-facing wire shapes.
@@ -431,9 +523,13 @@ async def _cast_tool_parameter_value(
return float(value) if "." in value else int(value)
return value
case DifyPluginToolParameterType.SYSTEM_FILES | DifyPluginToolParameterType.FILES:
if file_context is None:
raise DifyPluginToolsClientConfigurationError("File context is unavailable.")
values = value if isinstance(value, list) else [value]
return [await file_context.to_plugin_file_parameter(item) for item in values]
case DifyPluginToolParameterType.FILE:
if file_context is None:
raise DifyPluginToolsClientConfigurationError("File context is unavailable.")
if isinstance(value, list):
if len(value) != 1:
raise ValueError("This parameter only accepts one file but got multiple files while invoking.")
@@ -14,6 +14,7 @@ from typing import ClassVar
import httpx
from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError
from dify_agent.adapters.native_testing.client import AgentPhysicalDispatchPayload
from dify_agent.layers.knowledge.configs import (
DifyKnowledgeMetadataFilteringConfig,
DifyKnowledgeRetrievalConfig,
@@ -53,6 +54,7 @@ class _DifyKnowledgeCaller(BaseModel):
class _DifyKnowledgeRetrieveRequest(BaseModel):
caller: _DifyKnowledgeCaller
native_dispatch: AgentPhysicalDispatchPayload | None = None
dataset_ids: list[str]
query: str
retrieval: dict[str, JsonValue]
@@ -122,7 +124,13 @@ class DifyKnowledgeBaseClient:
are marked retryable because the model may continue gracefully in
those temporary-unavailable cases.
"""
from dify_agent.runtime.native_effects import current_native_dispatch
envelope = current_native_dispatch()
request_payload = _DifyKnowledgeRetrieveRequest(
native_dispatch=AgentPhysicalDispatchPayload.model_validate(envelope.wire())
if envelope is not None
else None,
caller=_DifyKnowledgeCaller(
tenant_id=tenant_id,
user_id=user_id,
@@ -143,7 +151,9 @@ class DifyKnowledgeBaseClient:
"X-Inner-Api-Key": self.api_key,
"Content-Type": "application/json",
},
json=request_payload.model_dump(mode="json", by_alias=True),
json=request_payload.model_dump(
mode="json", by_alias=True, exclude={"native_dispatch"} if envelope is None else None
),
)
except (httpx.InvalidURL, httpx.UnsupportedProtocol) as exc:
raise DifyKnowledgeBaseClientError(
@@ -10,23 +10,22 @@ session snapshots can resume without repeating unchanged retrievals.
from __future__ import annotations
from dataclasses import dataclass, replace
import hashlib
import json
import logging
from dataclasses import dataclass, replace
from typing import cast, override
import httpx
from pydantic_ai import RunContext, Tool
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.capabilities.abstract import WrapRunHandler
from pydantic_ai.run import AgentRunResult
from pydantic_ai.toolsets import FunctionToolset
from pydantic_ai.models import ModelRequestContext
from pydantic_ai.messages import ModelRequest, UserPromptPart
from dify_agent.runtime.context import Deps
from pydantic_ai.models import ModelRequestContext
from pydantic_ai.run import AgentRunResult
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.toolsets import FunctionToolset
from dify_agent.layers.execution_context.layer import Config as ExecutionContextConfig
from dify_agent.layers.knowledge.client import (
DifyKnowledgeBaseClient,
@@ -39,6 +38,9 @@ from dify_agent.layers.knowledge.configs import (
DifyKnowledgeRuntimeState,
DifyKnowledgeSetConfig,
)
from dify_agent.runtime.context import Deps
from dify_agent.runtime.native_continuation import NativePolicyStopped
from dify_agent.runtime.native_effects import NativeToolBoundary
logger = logging.getLogger(__name__)
@@ -173,6 +175,10 @@ class _KnowledgeOperations:
)
set_by_name = {knowledge_set.name: knowledge_set for knowledge_set in generated_sets}
async def native_arguments(arguments):
knowledge_set = set_by_name[arguments["set_name"]]
return self._native_parameters(knowledge_set, arguments["query"].strip())
async def knowledge_base_search(_ctx: RunContext[Deps], set_name: str, query: str) -> str:
knowledge_set = set_by_name.get(set_name)
if knowledge_set is None:
@@ -198,6 +204,13 @@ class _KnowledgeOperations:
name=_KNOWLEDGE_BASE_TOOL_NAME,
description=_tool_description(generated_sets),
prepare=prepare_tool_definition,
metadata={
"native_effect": NativeToolBoundary(
boundary="generated_knowledge",
target=lambda arguments: {"set_id": set_by_name[arguments["set_name"]].id},
prepare_arguments=native_arguments,
)
},
)
]
@@ -247,17 +260,7 @@ class _KnowledgeOperations:
for knowledge_set in user_query_sets:
query = (knowledge_set.query.value or "").strip()
try:
response = await client.retrieve(
tenant_id=caller["tenant_id"],
user_id=caller["user_id"],
app_id=caller["app_id"],
user_from=caller["user_from"],
invoke_from=caller["invoke_from"],
dataset_ids=knowledge_set.dataset_ids,
query=query,
retrieval=knowledge_set.retrieval,
metadata_filtering=knowledge_set.metadata_filtering,
)
response = await self._retrieve_eager(client, caller, knowledge_set, query)
except DifyKnowledgeBaseClientError as exc:
if exc.retryable:
logger.warning(
@@ -312,6 +315,46 @@ class _KnowledgeOperations:
eager_results=eager_results, eager_config_fingerprint=fingerprint
).model_dump(mode="json")
@staticmethod
def _native_parameters(knowledge_set, query):
return {
"dataset_ids": knowledge_set.dataset_ids,
"query": query,
"retrieval": knowledge_set.retrieval.model_dump(mode="json"),
"metadata_filtering": knowledge_set.metadata_filtering.model_dump(mode="json")
if knowledge_set.metadata_filtering is not None
else None,
}
async def _retrieve_eager(self, client, caller, knowledge_set, query):
async def invoke(_permit):
if _permit is not None:
raise NativePolicyStopped("native_dispatch_fence_unavailable")
response = await client.retrieve(
tenant_id=caller["tenant_id"],
user_id=caller["user_id"],
app_id=caller["app_id"],
user_from=caller["user_from"],
invoke_from=caller["invoke_from"],
dataset_ids=knowledge_set.dataset_ids,
query=query,
retrieval=knowledge_set.retrieval,
metadata_filtering=knowledge_set.metadata_filtering,
)
return response.model_dump(mode="json")
if self.deps.native_effects is None:
return DifyKnowledgeRetrieveResponse.model_validate(await invoke(None))
result = await self.deps.native_effects.stage(
boundary="eager_knowledge",
call_id=knowledge_set.id,
target={"set_id": knowledge_set.id},
parameters=self._native_parameters(knowledge_set, query),
stage="eager",
invoke=invoke,
)
return DifyKnowledgeRetrieveResponse.model_validate(result)
async def _retrieve_for_set(
self,
*,
@@ -321,6 +364,8 @@ class _KnowledgeOperations:
query: str,
retryable_observation: bool,
) -> str:
if self.deps.native_effects is not None:
raise NativePolicyStopped("native_dispatch_fence_unavailable")
try:
response = await client.retrieve(
tenant_id=caller["tenant_id"],
@@ -505,9 +550,9 @@ def _truncate_text(text: str, max_chars: int) -> str:
__all__ = [
"BLANK_QUERY_OBSERVATION",
"Config",
"State",
"Capability",
"NO_RESULTS_OBSERVATION",
"TEMPORARY_UNAVAILABLE_OBSERVATION",
"Capability",
"Config",
"State",
]
+52 -18
View File
@@ -2,12 +2,12 @@
from __future__ import annotations
from collections.abc import Sequence
import json
import logging
import re
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import ClassVar, NotRequired, Protocol, TypedDict, override, runtime_checkable
from typing import ClassVar, NotRequired, Protocol, Self, TypedDict, override, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator
from pydantic_ai import RunContext
@@ -15,7 +15,6 @@ from pydantic_ai.capabilities import AbstractCapability, CapabilityOrdering
from pydantic_ai.capabilities.abstract import WrapRunHandler
from pydantic_ai.run import AgentRunResult
from pydantic_ai.toolsets import FunctionToolset
from typing_extensions import Self
from dify_agent.adapters.shell.protocols import (
CompleteShellCommandResult,
@@ -27,13 +26,14 @@ from dify_agent.agent_stub.protocol import AGENT_STUB_AUTH_JWE_ENV_VAR
from dify_agent.agent_stub.shell_env import build_shell_agent_stub_env
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from dify_agent.layers.runtime.layer import Capability as RuntimeCapability
from dify_agent.runtime.context import Deps
from dify_agent.layers.shell.configs import DifyShellLayerConfig
from dify_agent.layers.shell.output_text import normalized_output_text, utf8_prefix, utf8_suffix
from dify_agent.runtime.command_runner import execute_complete_with_commands
from dify_agent.runtime.context import Deps
from dify_agent.runtime.native_continuation import NativeApprovalSuspended, NativePolicyStopped
from dify_agent.runtime.native_effects import NativeToolBoundary
from dify_agent.runtime_backend import RuntimeLease
logger = logging.getLogger(__name__)
@@ -222,28 +222,34 @@ class Capability(AbstractCapability[Deps]):
def get_toolset(self) -> FunctionToolset[Deps]:
toolset = FunctionToolset[Deps](id=self.name, sequential=True)
@toolset.tool(name="shell_run")
async def native_arguments(arguments):
return arguments
def metadata(boundary):
return {"native_effect": NativeToolBoundary(boundary, {"shell": self.name}, native_arguments)}
@toolset.tool(name="shell_run", metadata=metadata("shell_run"))
async def shell_run(
ctx: RunContext[Deps], script: str, timeout: float = DEFAULT_TIMEOUT_SECONDS
) -> ShellRunToolResult:
"""Start a shell job and return its output and status."""
return await ctx.deps.resources.shells[self.name]._tool_run(script, timeout)
@toolset.tool(name="shell_wait")
@toolset.tool(name="shell_wait", metadata=metadata("shell_wait"))
async def shell_wait(
ctx: RunContext[Deps], job_id: str, timeout: float = DEFAULT_TIMEOUT_SECONDS
) -> ShellRunToolResult:
"""Wait for output or completion from an existing shell job."""
return await ctx.deps.resources.shells[self.name]._tool_wait(job_id, timeout)
@toolset.tool(name="shell_input")
@toolset.tool(name="shell_input", metadata=metadata("shell_input"))
async def shell_input(
ctx: RunContext[Deps], job_id: str, text: str, timeout: float = DEFAULT_TIMEOUT_SECONDS
) -> ShellRunToolResult:
"""Send stdin to a shell job and wait for its next output."""
return await ctx.deps.resources.shells[self.name]._tool_input(job_id, text, timeout)
@toolset.tool(name="shell_interrupt")
@toolset.tool(name="shell_interrupt", metadata=metadata("shell_interrupt"))
async def shell_interrupt(
ctx: RunContext[Deps], job_id: str, grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS
) -> ShellInterruptToolResult:
@@ -261,10 +267,26 @@ class Capability(AbstractCapability[Deps]):
if not state.initialized:
script = _workspace_bootstrap_script(session.config)
if script:
result = await session._run_internal_script_complete(script, cwd=session._require_workspace_cwd())
if result.exit_code != 0 or not result.output_complete:
raise RuntimeError(
f"Failed to bootstrap shell workspace: {result.status} exit_code={result.exit_code}"
async def bootstrap(_permit):
result = await session._run_internal_script_complete(
script, cwd=session._require_workspace_cwd()
)
if result.exit_code != 0 or not result.output_complete:
raise RuntimeError(
f"Failed to bootstrap shell workspace: {result.status} exit_code={result.exit_code}"
)
if ctx.deps.native_effects is None:
await bootstrap(None)
else:
await ctx.deps.native_effects.stage(
boundary="bootstrap",
call_id=self.name,
target={"shell": self.name, "runtime": session.config.runtime},
parameters={"script": script},
stage="bootstrap",
invoke=bootstrap,
)
state.initialized = True
session.save_state(state)
@@ -350,6 +372,8 @@ class ShellSession:
),
self._redact_output(observation.text, sensitive_values=(agent_stub_token,)),
)
except (NativeApprovalSuspended, NativePolicyStopped):
raise
except (RuntimeError, ValueError) as exc:
return _tool_error_from_exception(exc)
except Exception as exc:
@@ -384,6 +408,8 @@ class ShellSession:
),
redacted_output,
)
except (NativeApprovalSuspended, NativePolicyStopped):
raise
except (RuntimeError, ValueError) as exc:
return _tool_error_from_exception(exc, job_id=job_id)
except Exception as exc:
@@ -418,6 +444,8 @@ class ShellSession:
),
redacted_output,
)
except (NativeApprovalSuspended, NativePolicyStopped):
raise
except (RuntimeError, ValueError) as exc:
return _tool_error_from_exception(exc, job_id=job_id)
except Exception as exc:
@@ -442,6 +470,8 @@ class ShellSession:
# best-effort metadata enrichment and must not turn the interrupt
# into a failed tool result.
output_path = (await self._require_resource().commands.tail(job_id)).output_path
except (NativeApprovalSuspended, NativePolicyStopped):
raise
except (RuntimeError, ValueError) as exc:
logger.warning(
"Failed to fetch output path for interrupted shell job %s: %s",
@@ -463,6 +493,8 @@ class ShellSession:
),
"Job was interrupted.",
)
except (NativeApprovalSuspended, NativePolicyStopped):
raise
except (RuntimeError, ValueError) as exc:
return _tool_error_from_exception(exc, job_id=job_id)
except Exception as exc:
@@ -548,6 +580,8 @@ class ShellSession:
self.save_state(state)
async def _delete_tracked_jobs_best_effort(self, job_ids: Sequence[str]) -> None:
if not job_ids:
return
commands = self._require_resource().commands
for job_id in _deduplicate_preserving_order(job_ids):
try:
@@ -762,12 +796,12 @@ def _tagged_shell_observation(metadata: dict[str, object], output: str) -> str:
__all__ = [
"Config",
"State",
"Capability",
"ShellSession",
"CompleteRemoteCommandResult",
"DEFAULT_TERMINATE_GRACE_SECONDS",
"DEFAULT_TIMEOUT_SECONDS",
"Capability",
"CompleteRemoteCommandResult",
"Config",
"ShellSession",
"State",
"render_prompt_observation_from_result",
]
+3 -2
View File
@@ -12,13 +12,13 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, TypedDict
import httpx
from pydantic import JsonValue
from dify_agent.agent_stub.shell_env import ShellAgentStubTokenFactory
from dify_agent.runtime_backend import RuntimeBackendProfile, RuntimeLease
from pydantic import JsonValue
if TYPE_CHECKING:
from dify_agent.layers.shell.layer import ShellSession
from dify_agent.runtime.native_effects import NativeEffectSession
class LayerData(TypedDict):
@@ -54,3 +54,4 @@ class Deps:
services: Services
run_id: str
resources: RunResources = field(default_factory=RunResources)
native_effects: NativeEffectSession | None = None
@@ -0,0 +1,336 @@
"""Actual invocation gates; API authority and durable results stay with their owners.
Tool deferral belongs to the installed SDK pre-execution capability. Stage
boundaries raise structured control before I/O. No approval flag from the model
or SDK is treated as authority, and transport errors are never retried here.
"""
import json
from collections.abc import Awaitable, Callable
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol, override
from dify_agent.protocol.native_testing import (
AgentApprovalWait,
AgentDispatchPermit,
AgentEffectDecision,
AgentEffectIntent,
AgentEffectReceipt,
AgentNativeTestRef,
AgentOperationKey,
AgentPolicyStop,
AgentRecordedResult,
FiniteJSON,
)
from dify_agent.runtime.context import Deps
from dify_agent.runtime.native_continuation import (
AgentPendingCall,
AgentResultStore,
NativeApprovalSuspended,
NativeContinuation,
NativePolicyStopped,
Stage,
defer_native_call,
)
from pydantic import JsonValue, TypeAdapter
from pydantic_ai import RunContext
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.capabilities.abstract import CapabilityOrdering
from pydantic_ai.messages import ToolCallPart
from pydantic_ai.tools import ToolDefinition
def canonical_effect_json(value: JsonValue) -> bytes:
"""Match the intent digest's finite, sorted, compact UTF-8 JSON contract."""
value = TypeAdapter(FiniteJSON).validate_python(value)
return json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
class AgentPolicyClient(Protocol):
async def before_call(self, *, ref: AgentNativeTestRef, intent: AgentEffectIntent) -> AgentEffectDecision: ...
async def record_outcome(
self,
*,
permit: AgentDispatchPermit,
outcome: Literal["completed", "failed", "unknown", "not_dispatched"],
result_ref: str | None,
) -> AgentEffectReceipt: ...
class NativeEffectDispatcher:
def __init__(self, *, policy: AgentPolicyClient):
self.policy = policy
async def decide(
self, *, ref: AgentNativeTestRef, intent: AgentEffectIntent, call_id: str, tool_name: str, stage: Stage
) -> AgentDispatchPermit | AgentRecordedResult:
if intent.key.invocation_id != ref.invocation_id:
raise NativePolicyStopped("native_effect_owner_mismatch")
decision = await self.policy.before_call(ref=ref, intent=intent)
if isinstance(decision, AgentApprovalWait):
raise NativeApprovalSuspended(
AgentPendingCall(
call_id=call_id,
tool_name=tool_name,
intent=intent,
stage=stage,
pending_operation_id=decision.pending_operation_id,
wait_reason=decision.wait_reason,
)
)
if isinstance(decision, AgentPolicyStop):
raise NativePolicyStopped(decision.reason_code)
return decision
async def dispatch(
self,
*,
ref: AgentNativeTestRef,
intent: AgentEffectIntent,
invoke: Callable[[AgentDispatchPermit], Awaitable[JsonValue]],
result_store: AgentResultStore,
call_id: str,
tool_name: str,
stage: Stage,
) -> JsonValue:
decision = await self.decide(ref=ref, intent=intent, call_id=call_id, tool_name=tool_name, stage=stage)
if isinstance(decision, AgentRecordedResult):
return await result_store.load(decision.result_ref)
return await self.invoke_permitted(
ref=ref, permit=decision, intent=intent, invoke=invoke, result_store=result_store
)
async def invoke_permitted(
self,
*,
ref: AgentNativeTestRef,
permit: AgentDispatchPermit,
intent: AgentEffectIntent,
invoke: Callable[[AgentDispatchPermit], Awaitable[JsonValue]],
result_store: AgentResultStore,
) -> JsonValue:
try:
result = await invoke(permit)
result_ref = await result_store.store(ref=ref, key=intent.key, result=result)
except BaseException:
# Once an invocation starts, cancellation or a lost response cannot prove
# that it did not happen. Recording unknown also closes replay admission.
await self.policy.record_outcome(permit=permit, outcome="unknown", result_ref=None)
raise
await self.policy.record_outcome(permit=permit, outcome="completed", result_ref=result_ref)
return result
def opaque_invocation_disclosure(*, kind: str, target: str, capability_scope: str) -> dict[str, str]:
"""Private disclosure metadata; executable arguments stay in protected state."""
return {
"kind": kind,
"target": target,
"authorization_granularity": "single_bounded_invocation",
"internal_effects": "opaque",
"capability_scope": capability_scope,
}
class IntentPreparer(Protocol):
"""Trusted composition resolves current identity/readiness without execution.
The returned intent must retain the full final target/parameters. Missing
configuration is handled by its owner before reserving an account effect.
"""
async def __call__(
self,
*,
ref: AgentNativeTestRef,
key: AgentOperationKey,
boundary: str,
target: JsonValue,
parameters: JsonValue,
) -> AgentEffectIntent: ...
class ShellLineageValidator(Protocol):
"""A4 authenticates durable shell scope/jobs before reserving any operation."""
async def __call__(
self, *, ref: AgentNativeTestRef, boundary: str, target: JsonValue, parameters: JsonValue
) -> None: ...
@dataclass(frozen=True)
class NativeToolBoundary:
boundary: str
target: JsonValue | Callable[[dict[str, Any]], JsonValue]
prepare_arguments: Callable[[dict[str, Any]], Awaitable[JsonValue]]
@dataclass(frozen=True)
class NativeDispatchEnvelope:
ref: AgentNativeTestRef
intent: AgentEffectIntent
permit: AgentDispatchPermit
def wire(self) -> dict[str, JsonValue]:
return {
"ref": self.ref.model_dump(mode="json"),
"intent": self.intent.model_dump(mode="json"),
"permit": self.permit.model_dump(mode="json"),
}
_dispatch: ContextVar[NativeDispatchEnvelope | None] = ContextVar("native_agent_dispatch", default=None)
def current_native_dispatch() -> NativeDispatchEnvelope | None:
return _dispatch.get()
@dataclass
class NativeEffectSession:
continuation: NativeContinuation
dispatcher: NativeEffectDispatcher
result_store: AgentResultStore
prepare_intent: IntentPreparer
validate_shell_lineage: ShellLineageValidator | None = None
_prepared: dict[str, tuple[AgentEffectIntent, AgentDispatchPermit | AgentRecordedResult]] = field(
default_factory=dict, repr=False
)
async def intent(
self, *, boundary: str, call_id: str, target: JsonValue, parameters: JsonValue
) -> AgentEffectIntent:
target = TypeAdapter(FiniteJSON).validate_python(target)
parameters = TypeAdapter(FiniteJSON).validate_python(parameters)
if boundary in {
"shell_run",
"shell_wait",
"shell_input",
"shell_interrupt",
"bootstrap",
"config_pull",
"file_upload",
}:
if self.validate_shell_lineage is None:
raise NativePolicyStopped("native_shell_lineage_unavailable")
await self.validate_shell_lineage(
ref=self.continuation.ref, boundary=boundary, target=target, parameters=parameters
)
key = AgentOperationKey(
invocation_id=self.continuation.ref.invocation_id, occurrence_path=(boundary, call_id), cursor=0
)
intent = await self.prepare_intent(
ref=self.continuation.ref, key=key, boundary=boundary, target=target, parameters=parameters
)
if (
intent.key != key
or canonical_effect_json(intent.target) != canonical_effect_json(target)
or canonical_effect_json(intent.parameters) != canonical_effect_json(parameters)
):
raise NativePolicyStopped("native_effect_arguments_changed")
return intent
async def stage(
self,
*,
boundary: str,
call_id: str,
target: JsonValue,
parameters: JsonValue,
stage: Stage,
invoke: Callable[[AgentDispatchPermit], Awaitable[JsonValue]],
) -> JsonValue:
intent = await self.intent(boundary=boundary, call_id=call_id, target=target, parameters=parameters)
async def invoke_bound(permit: AgentDispatchPermit) -> JsonValue:
token = _dispatch.set(NativeDispatchEnvelope(self.continuation.ref, intent, permit))
try:
return await invoke(permit)
finally:
_dispatch.reset(token)
return await self.dispatcher.dispatch(
ref=self.continuation.ref,
intent=intent,
invoke=invoke_bound,
result_store=self.result_store,
call_id=call_id,
tool_name=boundary,
stage=stage,
)
class NativeEffectsCapability(AbstractCapability[Deps]):
"""SDK-owned pre-tool deferral and execution around the original handler."""
def __init__(self, session: NativeEffectSession):
self.session = session
self.id = "native_effects"
@override
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(position="outermost")
@override
async def wrap_run(self, ctx: RunContext[Deps], *, handler):
if ctx.deps.native_effects is not None and ctx.deps.native_effects is not self.session:
raise NativePolicyStopped("native_effect_owner_mismatch")
ctx.deps.native_effects = self.session
return await handler()
@override
async def before_tool_execute(self, ctx: RunContext[Deps], *, call: ToolCallPart, tool_def: ToolDefinition, args):
if tool_def.kind == "output":
return args
boundary = (tool_def.metadata or {}).get("native_effect")
if not isinstance(boundary, NativeToolBoundary) or not isinstance(args, dict):
raise NativePolicyStopped("native_tool_boundary_unavailable")
parameters = await boundary.prepare_arguments(args)
target = boundary.target(args) if callable(boundary.target) else boundary.target
intent = await self.session.intent(
boundary=boundary.boundary, call_id=call.tool_call_id, target=target, parameters=parameters
)
try:
decision = await self.session.dispatcher.decide(
ref=self.session.continuation.ref,
intent=intent,
call_id=call.tool_call_id,
tool_name=call.tool_name,
stage="tool",
)
except NativeApprovalSuspended as wait:
defer_native_call(wait.pending)
raise AssertionError("native deferral returned")
self.session._prepared[call.tool_call_id] = (intent, decision)
return args
@override
async def wrap_tool_execute(
self, ctx: RunContext[Deps], *, call: ToolCallPart, tool_def: ToolDefinition, args, handler
):
if tool_def.kind == "output":
return await handler(args)
prepared = self.session._prepared.pop(call.tool_call_id, None)
if prepared is None:
raise NativePolicyStopped("native_tool_permit_missing")
intent, decision = prepared
if isinstance(decision, AgentRecordedResult):
return await self.session.result_store.load(decision.result_ref)
async def invoke(permit):
token = _dispatch.set(NativeDispatchEnvelope(self.session.continuation.ref, intent, permit))
try:
return TypeAdapter(JsonValue).validate_python(
TypeAdapter(Any).dump_python(await handler(args), mode="json")
)
finally:
_dispatch.reset(token)
return await self.session.dispatcher.invoke_permitted(
ref=self.session.continuation.ref,
permit=decision,
intent=intent,
invoke=invoke,
result_store=self.session.result_store,
)
+47 -29
View File
@@ -10,49 +10,32 @@ scopes. Workspace and Binding retirement remain API-owned.
import asyncio
from collections.abc import AsyncIterable, Callable, Mapping
from dataclasses import dataclass
from typing import Protocol, cast, runtime_checkable
from types import ModuleType
from typing import Protocol, cast, runtime_checkable
from graphon.model_runtime.entities.llm_entities import LLMUsage
from pydantic import JsonValue, TypeAdapter
from pydantic_ai import DeferredToolRequests, DeferredToolResults, capture_run_messages
from pydantic_ai.exceptions import ModelHTTPError, UsageLimitExceeded
from pydantic_ai.messages import AgentStreamEvent, PartDeltaEvent, PartStartEvent, TextPart, TextPartDelta
from pydantic_ai.usage import RunUsage, UsageLimits
from dify_agent.layers import history as history_module
from dify_agent.layers.dify_plugin import llm_layer as llm_module
from dify_agent.layers.knowledge import layer as knowledge_module
from dify_agent.layers.knowledge.client import DifyKnowledgeBaseClientError
from dify_agent.layers.output import output_layer as output_module
from dify_agent.layers.prompt import layer as prompt_module
from dify_agent.layers.user_prompt import layer as user_prompt_module
from dify_agent.protocol.native_testing import (
NATIVE_TEST_CAPABILITIES,
NativeTestCapabilities,
NativeTestProtocolUnsupported,
require_native_test_capabilities,
)
from dify_agent.runtime.native_continuation import (
AgentCheckpoint,
NativeContinuation,
NativeApprovalSuspended,
NativePolicyStopped,
)
from dify_agent.protocol.snapshot import SessionSnapshot
from dify_agent.runtime.context import Services
from dify_agent.runtime.modules import REGISTRY, load_modules, create_modules, snapshot_modules
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.toolsets import AbstractToolset
from dify_agent.layers import history as history_module
from dify_agent.layers.prompt import layer as prompt_module
from dify_agent.layers.dify_plugin import llm_layer as llm_module
from dify_agent.layers.output import output_layer as output_module
from dify_agent.layers.user_prompt import layer as user_prompt_module
from dify_agent.layers.knowledge import layer as knowledge_module
from dify_agent.layers.knowledge.client import DifyKnowledgeBaseClientError
from dify_agent.protocol.schemas import (
DIFY_AGENT_MODEL_LAYER_ID,
AgentRunUsage,
CreateRunRequest,
DIFY_AGENT_MODEL_LAYER_ID,
RunFailureType,
)
from dify_agent.protocol.snapshot import SessionSnapshot
from dify_agent.runtime.agent_factory import create_agent, normalize_user_input
from dify_agent.runtime.compaction import build_compaction_capability
from dify_agent.runtime_backend import BindingLostError
from dify_agent.runtime.context import Services
from dify_agent.runtime.event_coalescer import (
DEFAULT_TEXT_DELTA_FLUSH_INTERVAL_SECONDS,
DEFAULT_TEXT_DELTA_MAX_CHARS,
@@ -65,9 +48,24 @@ from dify_agent.runtime.event_sink import (
emit_run_started,
emit_run_succeeded,
)
from dify_agent.runtime.modules import REGISTRY, create_modules, load_modules, snapshot_modules
from dify_agent.runtime.native_continuation import (
AgentCheckpoint,
NativeApprovalSuspended,
NativeContinuation,
NativePolicyStopped,
)
from dify_agent.runtime.observability import AgentObservability
from dify_agent.runtime.user_prompt_validation import EMPTY_USER_PROMPTS_ERROR, has_non_blank_user_prompt
from dify_agent.runtime_backend import BindingLostError
from graphon.model_runtime.entities.llm_entities import LLMUsage
from pydantic import JsonValue, TypeAdapter
from pydantic_ai import DeferredToolRequests, DeferredToolResults, capture_run_messages
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.exceptions import ModelHTTPError, UsageLimitExceeded
from pydantic_ai.messages import AgentStreamEvent, PartDeltaEvent, PartStartEvent, TextPart, TextPartDelta
from pydantic_ai.toolsets import AbstractToolset
from pydantic_ai.usage import RunUsage, UsageLimits
_AGENT_OUTPUT_ADAPTER = TypeAdapter(object)
_MAX_AGENT_STEPS_PER_RUN = 500
@@ -211,6 +209,15 @@ class AgentRunRunner:
require_native_test_capabilities(self.native_capabilities)
if self.native_continuation is None or self.native_continuation.ref != self.request.native_test_ref:
raise NativeTestProtocolUnsupported()
from dify_agent.runtime.native_effects import NativeEffectsCapability
gates = [
capability
for capability in self.native_continuation.capabilities
if isinstance(capability, NativeEffectsCapability)
]
if len(gates) != 1 or gates[0].session.continuation is not self.native_continuation:
raise NativeTestProtocolUnsupported()
if self.is_cancelled():
return
if self.native_continuation is None or self.native_continuation.generation == 0:
@@ -258,6 +265,17 @@ class AgentRunRunner:
async def _run_agent(self) -> RunSuccessOutcome | AgentCheckpoint:
try:
deps = load_modules(self.request, self.services, self.run_id, registry=self.registry)
if self.native_continuation is not None:
from dify_agent.runtime.native_effects import NativeEffectsCapability
gates = [
capability
for capability in self.native_continuation.capabilities
if isinstance(capability, NativeEffectsCapability)
]
if len(gates) != 1 or gates[0].session.continuation is not self.native_continuation:
raise NativeTestProtocolUnsupported()
deps.native_effects = gates[0].session
modules = create_modules(deps, registry=self.registry)
model_module = modules[DIFY_AGENT_MODEL_LAYER_ID]
if not isinstance(model_module, llm_module.Capability):
@@ -5,23 +5,22 @@ from types import ModuleType
import httpx
import pytest
from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, ToolApproved
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart, ToolReturnPart
from pydantic_ai.models.function import FunctionModel
from dify_agent.protocol.native_testing import AgentEffectIntent, AgentNativeTestRef, AgentOperationKey
from dify_agent.protocol.schemas import CreateRunRequest, RUN_EVENT_ADAPTER
from dify_agent.protocol.schemas import RUN_EVENT_ADAPTER, CreateRunRequest
from dify_agent.runtime.context import Deps, Services
from dify_agent.runtime.native_continuation import (
AgentCheckpoint,
AgentPendingCall,
NativeContinuation,
NativeApprovalSuspended,
NativeContinuation,
NativePolicyStopped,
defer_native_call,
validate_deferred_results,
)
from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, ToolApproved
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart, ToolReturnPart
from pydantic_ai.models.function import FunctionModel
REF = AgentNativeTestRef(
policy_version="builder-native-test-v1",
@@ -137,9 +136,9 @@ def test_denied_or_changed_native_arguments_never_become_model_feedback():
def test_direct_runner_rejects_marked_test_before_module_loading():
from dify_agent.runtime.runner import AgentRunRunner
from dify_agent.runtime.event_sink import InMemoryRunEventSink
from dify_agent.protocol.native_testing import NativeTestProtocolUnsupported
from dify_agent.runtime.event_sink import InMemoryRunEventSink
from dify_agent.runtime.runner import AgentRunRunner
async def exercise():
async with httpx.AsyncClient() as client:
@@ -161,8 +160,8 @@ def test_direct_runner_rejects_marked_test_before_module_loading():
def test_scheduler_rejects_native_before_persistence():
from dify_agent.runtime.run_scheduler import RunScheduler
from dify_agent.protocol.native_testing import NativeTestProtocolUnsupported
from dify_agent.runtime.run_scheduler import RunScheduler
class NoCallsStore:
async def create_run(self):
@@ -185,14 +184,50 @@ def test_scheduler_rejects_native_before_persistence():
@pytest.mark.parametrize("stage", ["tool", "model"])
def test_runner_commits_wait_instead_of_success_and_persistence_failure_is_not_resumable(monkeypatch, stage):
from dify_agent.layers.dify_plugin import llm_layer
from dify_agent.protocol.native_testing import NativeTestCapabilities
from dify_agent.runtime.event_sink import InMemoryRunEventSink
from dify_agent.runtime.modules import REGISTRY
from dify_agent.runtime.native_effects import (
NativeEffectDispatcher,
NativeEffectsCapability,
NativeEffectSession,
NativeToolBoundary,
)
from dify_agent.runtime.runner import AgentRunRunner
from pydantic import BaseModel
from pydantic_ai.models.function import DeltaToolCall
from pydantic_ai.toolsets import FunctionToolset
from dify_agent.layers.dify_plugin import llm_layer
from dify_agent.runtime.event_sink import InMemoryRunEventSink
from dify_agent.runtime.modules import REGISTRY
from dify_agent.runtime.runner import AgentRunRunner
from dify_agent.protocol.native_testing import NativeTestCapabilities
async def arguments(args):
return args
def effect_gate(state, *, suspend_model=False):
class Policy:
async def before_call(self, **kwargs):
from dify_agent.protocol.native_testing import AgentApprovalWait
return AgentApprovalWait(pending_operation_id="pending-1")
async def prepare(**kwargs):
return pending().intent.model_copy(
update={"key": kwargs["key"], "target": kwargs["target"], "parameters": kwargs["parameters"]}
)
class Gate(NativeEffectsCapability):
async def before_model_request(self, ctx, request_context):
if suspend_model:
raise NativeApprovalSuspended(pending("configuration", "model"))
return request_context
return Gate(
NativeEffectSession(
continuation=state,
dispatcher=NativeEffectDispatcher(policy=Policy()),
result_store=None,
prepare_intent=prepare,
)
)
class Config(BaseModel):
pass
@@ -200,16 +235,11 @@ def test_runner_commits_wait_instead_of_success_and_persistence_failure_is_not_r
class Tools(FunctionToolset):
def __init__(self, name):
super().__init__()
self.add_function(lambda values: "unexpected effect", name="write")
class Gate(AbstractCapability):
async def before_model_request(self, ctx, request_context):
if stage == "model":
raise NativeApprovalSuspended(pending("configuration", "model"))
return request_context
async def before_tool_execute(self, ctx, *, call, tool_def, args):
defer_native_call(pending())
self.add_function(
lambda values: "unexpected effect",
name="write",
metadata={"native_effect": NativeToolBoundary("tool", {"provider": "test"}, arguments)},
)
async def stream(messages, info):
yield {
@@ -257,7 +287,7 @@ def test_runner_commits_wait_instead_of_success_and_persistence_failure_is_not_r
return await super().persist_waiting(**kwargs)
state = continuation()
state.capabilities = [Gate()]
state.capabilities = [effect_gate(state, suspend_model=stage == "model")]
sink = Sink()
runner = AgentRunRunner(
sink=sink,
@@ -302,6 +332,7 @@ def test_runner_commits_wait_instead_of_success_and_persistence_failure_is_not_r
resumed_state.generation = 1
resumed_state.history = cp.history
resumed_state.session_snapshot = cp.session_snapshot
resumed_state.capabilities = [effect_gate(resumed_state)]
resumed_sink = InMemoryRunEventSink()
resumed = AgentRunRunner(
sink=resumed_sink,
@@ -320,7 +351,7 @@ def test_runner_commits_wait_instead_of_success_and_persistence_failure_is_not_r
def test_scheduler_restores_only_matching_pinned_snapshot_before_scheduling():
from dify_agent.protocol.native_testing import ContinueRunRequest, ContinuationClaim, NativeTestCapabilities
from dify_agent.protocol.native_testing import ContinuationClaim, ContinueRunRequest, NativeTestCapabilities
from dify_agent.runtime.native_continuation import NativeContinuationConflict, PreparedContinuation
from dify_agent.runtime.run_scheduler import RunScheduler
from dify_agent.server.schemas import RunRecord
@@ -374,7 +405,7 @@ def test_scheduler_restores_only_matching_pinned_snapshot_before_scheduling():
@pytest.mark.parametrize("finishing_wait", [False, True])
def test_scheduler_resume_keeps_run_and_claim_after_lost_acknowledgement(finishing_wait):
"""Scheduler wiring only: real Redis atomicity is covered in CI store tests."""
from dify_agent.protocol.native_testing import ContinueRunRequest, ContinuationClaim, NativeTestCapabilities
from dify_agent.protocol.native_testing import ContinuationClaim, ContinueRunRequest, NativeTestCapabilities
from dify_agent.runtime.native_continuation import PreparedContinuation
from dify_agent.runtime.run_scheduler import RunScheduler
from dify_agent.server.schemas import RunRecord
@@ -467,7 +498,7 @@ def test_scheduler_resume_keeps_run_and_claim_after_lost_acknowledgement(finishi
def test_api_owner_denial_stops_waiting_run_instead_of_returning_model_feedback():
from dify_agent.protocol.native_testing import ContinueRunRequest, ContinuationClaim, NativeTestCapabilities
from dify_agent.protocol.native_testing import ContinuationClaim, ContinueRunRequest, NativeTestCapabilities
from dify_agent.runtime.run_scheduler import RunScheduler
cp = continuation().capture_wait(NativeApprovalSuspended(pending()))
@@ -537,7 +568,7 @@ def test_continuation_cannot_relabel_pending_approval_as_external_result():
def test_duplicate_before_start_cas_does_not_block_unrelated_admission_or_shutdown():
"""Local scheduling race only; Redis CAS atomicity remains a separate CI test."""
from dify_agent.protocol.native_testing import ContinueRunRequest, ContinuationClaim, NativeTestCapabilities
from dify_agent.protocol.native_testing import ContinuationClaim, ContinueRunRequest, NativeTestCapabilities
from dify_agent.runtime.event_sink import RunFinalizationResult
from dify_agent.runtime.native_continuation import PreparedContinuation
from dify_agent.runtime.run_scheduler import RunScheduler
@@ -0,0 +1,919 @@
"""Effect dispatch and real SDK deferral; providers remain controlled fixtures."""
import asyncio
import importlib
import importlib.util
import pytest
from dify_agent.protocol.native_testing import (
AgentApprovalWait,
AgentDispatchPermit,
AgentEffectIntent,
AgentNativeTestRef,
AgentOperationKey,
AgentPolicyStop,
AgentRecordedResult,
)
from dify_agent.runtime.native_continuation import NativeApprovalSuspended, NativePolicyStopped
REF = AgentNativeTestRef(
policy_version="builder-native-test-v1", request_id="request", invocation_id="invocation", context_digest="a" * 64
)
INTENT = AgentEffectIntent(
key=AgentOperationKey(invocation_id="invocation", occurrence_path=("tool", "call"), cursor=0),
kind="tool",
resource_ref="resource",
implementation_revision="revision",
account_ref=None,
target={"provider": "test/action"},
parameters={"values": [True, 3, {"reference": "file"}]},
input_digest="b" * 64,
capability_digest="c" * 64,
)
PERMIT = AgentDispatchPermit(
permit_id="permit", attempt_id="attempt", operation_id="operation", expires_at="2030-01-01T00:00:00Z"
)
def implementation():
assert importlib.util.find_spec("dify_agent.runtime.native_effects") is not None, (
"Actual native effect dispatcher is missing"
)
return importlib.import_module("dify_agent.runtime.native_effects")
class Policy:
def __init__(self, decision):
self.decision = decision
self.intents = []
self.outcomes = []
async def before_call(self, *, ref, intent):
self.intents.append((ref, intent))
return self.decision
async def record_outcome(self, *, permit, outcome, result_ref):
self.outcomes.append((permit, outcome, result_ref))
class Results:
def __init__(self):
self.values = {"recorded": {"ok": True}}
async def store(self, *, ref, key, result):
self.values["saved"] = result
return "saved"
async def load(self, result_ref):
return self.values[result_ref]
@pytest.mark.parametrize("reason", ["approval", "configuration", "interaction"])
def test_waiting_preserves_typed_payload_and_never_invokes(reason):
async def exercise():
policy = Policy(AgentApprovalWait(pending_operation_id="pending", wait_reason=reason))
calls = []
async def invoke(permit):
calls.append(permit)
return {"ok": True}
dispatcher = implementation().NativeEffectDispatcher(policy=policy)
with pytest.raises(NativeApprovalSuspended) as caught:
await dispatcher.dispatch(
ref=REF,
intent=INTENT,
invoke=invoke,
result_store=Results(),
call_id="call",
tool_name="test",
stage="eager",
)
assert caught.value.pending.intent == INTENT
assert caught.value.wait_reason == reason
assert calls == []
assert policy.outcomes == []
asyncio.run(exercise())
def test_recorded_result_and_denial_never_invoke():
async def exercise():
async def invoke(permit):
pytest.fail("recorded/denied effect was invoked")
for decision in [
AgentRecordedResult(operation_id="operation", result_ref="recorded"),
AgentPolicyStop(reason_code="denied"),
]:
dispatcher = implementation().NativeEffectDispatcher(policy=Policy(decision))
kwargs = {
"ref": REF,
"intent": INTENT,
"invoke": invoke,
"result_store": Results(),
"call_id": "call",
"tool_name": "test",
"stage": "eager",
}
if isinstance(decision, AgentPolicyStop):
with pytest.raises(NativePolicyStopped, match="denied"):
await dispatcher.dispatch(**kwargs)
else:
assert await dispatcher.dispatch(**kwargs) == {"ok": True}
asyncio.run(exercise())
def test_success_stores_protected_result_before_receipt():
async def exercise():
policy, results = Policy(PERMIT), Results()
async def invoke(permit):
assert permit == PERMIT
return {"values": [True, 3]}
value = (
await implementation()
.NativeEffectDispatcher(policy=policy)
.dispatch(
ref=REF,
intent=INTENT,
invoke=invoke,
result_store=results,
call_id="call",
tool_name="test",
stage="eager",
)
)
assert value == results.values["saved"] == {"values": [True, 3]}
assert policy.outcomes == [(PERMIT, "completed", "saved")]
asyncio.run(exercise())
def test_ambiguous_io_closes_as_unknown_without_retry():
async def exercise():
policy = Policy(PERMIT)
calls = []
async def invoke(permit):
calls.append(permit)
raise TimeoutError("lost response")
with pytest.raises(TimeoutError):
await (
implementation()
.NativeEffectDispatcher(policy=policy)
.dispatch(
ref=REF,
intent=INTENT,
invoke=invoke,
result_store=Results(),
call_id="call",
tool_name="test",
stage="eager",
)
)
assert calls == [PERMIT]
assert policy.outcomes == [(PERMIT, "unknown", None)]
asyncio.run(exercise())
def test_opaque_call_discloses_uncertainty():
assert implementation().opaque_invocation_disclosure(
kind="plugin", target="provider/action", capability_scope="mailbox:test"
) == {
"kind": "plugin",
"target": "provider/action",
"authorization_granularity": "single_bounded_invocation",
"internal_effects": "opaque",
"capability_scope": "mailbox:test",
}
@pytest.mark.parametrize("reason", ["approval", "configuration", "interaction"])
def test_real_sdk_pre_tool_boundary_checkpoints_wait_before_handler(reason):
async def exercise():
import httpx
from dify_agent.runtime.context import Deps, Services
from dify_agent.runtime.native_continuation import NativeContinuation
from pydantic_ai import Agent, DeferredToolRequests
from pydantic_ai.messages import ModelResponse, ToolCallPart
from pydantic_ai.models.function import FunctionModel
module = implementation()
assert hasattr(module, "NativeEffectsCapability"), "Real SDK effect capability is missing"
policy = Policy(AgentApprovalWait(pending_operation_id="pending", wait_reason=reason))
continuation = NativeContinuation(
run_id="run", ref=REF, generation=0, config_ref="config", config_digest="d" * 64
)
async def prepare(**kwargs):
return INTENT.model_copy(
update={"key": kwargs["key"], "target": kwargs["target"], "parameters": kwargs["parameters"]}
)
session = module.NativeEffectSession(
continuation=continuation,
dispatcher=module.NativeEffectDispatcher(policy=policy),
result_store=Results(),
prepare_intent=prepare,
)
capability = module.NativeEffectsCapability(session)
calls = []
async with httpx.AsyncClient() as client:
deps = Deps(layers={}, services=Services(client, client), run_id="run")
agent = Agent(
FunctionModel(
lambda messages, info: ModelResponse(
parts=[ToolCallPart("write", {"values": [True, 3]}, tool_call_id="call")]
)
),
output_type=[str, DeferredToolRequests],
capabilities=[capability],
)
async def arguments(args):
return {"hidden": "bound", **args}
@agent.tool_plain(
metadata={
"native_effect": module.NativeToolBoundary(
boundary="tool", target={"provider": "test/action"}, prepare_arguments=arguments
)
}
)
def write(values: list):
calls.append(values)
return "done"
result = await agent.run("do it", deps=deps)
checkpoint = continuation.capture(result=result, deps=deps)
assert calls == []
assert checkpoint.pending_calls[0].wait_reason == reason
assert checkpoint.pending_calls[0].intent.parameters == {"hidden": "bound", "values": [True, 3]}
assert len(policy.intents) == 1
asyncio.run(exercise())
@pytest.mark.parametrize("family", ["direct_plugin", "core_tool"])
@pytest.mark.parametrize("file_kind", ["string", "file"])
@pytest.mark.parametrize("permit_available", [False, True])
def test_actual_tool_layer_defers_with_final_arguments_before_transport(family, file_kind, permit_available):
async def exercise():
import httpx
from dify_agent.layers.dify_core_tools.layer import Toolset as CoreTools
from dify_agent.layers.dify_plugin.tools_layer import Toolset as PluginTools
from dify_agent.runtime.context import Deps, Services
from dify_agent.runtime.native_continuation import NativeContinuation
from pydantic_ai import Agent, DeferredToolRequests
from pydantic_ai.messages import ModelResponse, ToolCallPart
from pydantic_ai.models.function import FunctionModel
module = implementation()
transport_calls = []
def transport(request):
transport_calls.append(request)
return httpx.Response(500)
policy = Policy(PERMIT if permit_available else AgentApprovalWait(pending_operation_id="pending"))
state = NativeContinuation(run_id="run", ref=REF, generation=0, config_ref="config", config_digest="d" * 64)
async def prepare(**kwargs):
return INTENT.model_copy(
update={"key": kwargs["key"], "target": kwargs["target"], "parameters": kwargs["parameters"]}
)
session = module.NativeEffectSession(
continuation=state,
dispatcher=module.NativeEffectDispatcher(policy=policy),
result_store=Results(),
prepare_intent=prepare,
)
tool = {
"tool_name": "write",
"runtime_parameters": {"hidden": "bound"},
"parameters": [
{"name": "enabled", "type": "boolean", "form": "llm", "default": True},
{"name": "file", "type": file_kind, "form": "llm"},
],
"parameters_json_schema": {"type": "object", "properties": {"file": {"type": "string"}}},
}
tool.update(
{"plugin_id": "test/plugin", "provider": "provider", "credential_type": "unauthorized"}
if family == "direct_plugin"
else {"provider_type": "api", "provider_id": "provider"}
)
async with httpx.AsyncClient(transport=httpx.MockTransport(transport)) as client:
deps = Deps(
layers={
"execution_context": {
"config": {
"agent_mode": "workflow_run",
"tenant_id": "tenant",
"user_id": "user",
"user_from": "account",
"app_id": "app",
"invoke_from": "debugger",
},
"state": {},
},
"tools": {"config": {"tools": [tool]}, "state": {}},
},
services=Services(client, client),
run_id="run",
)
agent = Agent(
FunctionModel(
lambda messages, info: ModelResponse(
parts=[ToolCallPart("write", {"file": "/workspace/input.csv"}, tool_call_id="call")]
)
),
toolsets=[PluginTools("tools") if family == "direct_plugin" else CoreTools("tools")],
capabilities=[module.NativeEffectsCapability(session)],
output_type=[str, DeferredToolRequests],
)
if file_kind == "file":
with pytest.raises(NativePolicyStopped, match="native_file_dependency_unavailable"):
await agent.run("write", deps=deps)
assert transport_calls == []
assert policy.intents == []
return
if permit_available:
with pytest.raises(NativePolicyStopped, match="native_dispatch_fence_unavailable"):
await agent.run("write", deps=deps)
assert transport_calls == []
assert len(policy.intents) == 1
return
result = await agent.run("write", deps=deps)
checkpoint = state.capture(result=result, deps=deps)
assert transport_calls == []
assert checkpoint.pending_calls[0].intent.parameters == {
"hidden": "bound",
"enabled": True,
"file": "/workspace/input.csv",
}
asyncio.run(exercise())
def test_sdk_permit_is_spent_once_and_result_can_replay_without_handler():
async def exercise():
import httpx
from dify_agent.runtime.context import Deps, Services
from dify_agent.runtime.native_continuation import NativeContinuation
from pydantic_ai import Agent, DeferredToolRequests
from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart, ToolReturnPart
from pydantic_ai.models.function import FunctionModel
module = implementation()
calls = []
policy, results = Policy(PERMIT), Results()
async def prepare(**kwargs):
return INTENT.model_copy(
update={"key": kwargs["key"], "target": kwargs["target"], "parameters": kwargs["parameters"]}
)
async def arguments(args):
return args
def model(messages, info):
return (
ModelResponse(parts=[TextPart("done")])
if any(isinstance(p, ToolReturnPart) for m in messages for p in m.parts)
else ModelResponse(parts=[ToolCallPart("write", {"enabled": True}, tool_call_id="call")])
)
async with httpx.AsyncClient() as client:
for replay in [False, True]:
state = NativeContinuation(
run_id="run", ref=REF, generation=0, config_ref="config", config_digest="d" * 64
)
session = module.NativeEffectSession(
continuation=state,
dispatcher=module.NativeEffectDispatcher(policy=policy),
result_store=results,
prepare_intent=prepare,
)
if replay:
policy.decision = AgentRecordedResult(operation_id="operation", result_ref="saved")
agent = Agent(
FunctionModel(model),
output_type=[str, DeferredToolRequests],
capabilities=[module.NativeEffectsCapability(session)],
)
@agent.tool_plain(
metadata={"native_effect": module.NativeToolBoundary("tool", {"provider": "test"}, arguments)}
)
def write(enabled: bool):
calls.append(enabled)
return {"done": enabled}
result = await agent.run("write", deps=Deps(layers={}, services=Services(client, client), run_id="run"))
assert result.output == "done"
assert calls == [True]
assert len(policy.intents) == 2
assert policy.outcomes == [(PERMIT, "completed", "saved")]
asyncio.run(exercise())
def waiting_session():
from dify_agent.runtime.native_continuation import NativeContinuation
module = implementation()
policy = Policy(AgentApprovalWait(pending_operation_id="pending"))
async def prepare(**kwargs):
return INTENT.model_copy(
update={"key": kwargs["key"], "target": kwargs["target"], "parameters": kwargs["parameters"]}
)
async def fixture_lineage(**kwargs):
return None
return module.NativeEffectSession(
continuation=NativeContinuation(
run_id="run", ref=REF, generation=0, config_ref="config", config_digest="d" * 64
),
dispatcher=module.NativeEffectDispatcher(policy=policy),
result_store=Results(),
prepare_intent=prepare,
validate_shell_lineage=fixture_lineage,
)
def test_eager_knowledge_waits_before_http_and_cache_write():
async def exercise():
import httpx
from dify_agent.layers.knowledge.layer import _KnowledgeOperations
from tests.local.dify_agent.layers.knowledge.test_layer import config, deps_for
calls = []
def transport(request):
calls.append(request)
return httpx.Response(200, json={"results": [], "usage": {}})
async with httpx.AsyncClient(transport=httpx.MockTransport(transport)) as client:
deps = deps_for(client, config(eager=True))
deps.native_effects = waiting_session()
with pytest.raises(NativeApprovalSuspended):
await _KnowledgeOperations("knowledge", deps)._refresh_eager_results_if_needed()
assert calls == []
assert deps.layers["knowledge"]["state"]["eager_results"] == []
asyncio.run(exercise())
def test_config_pull_waits_before_shell_io():
async def exercise():
from types import SimpleNamespace
from dify_agent.adapters.shell.protocols import CompleteShellCommandResult
from dify_agent.layers.config.layer import Config, State, _ConfigContext
calls = []
async def run(script, **kwargs):
calls.append(script)
return CompleteShellCommandResult("pull", "exited", True, 0, "{}", True, None, 2)
shell = SimpleNamespace(
deps=SimpleNamespace(native_effects=waiting_session()), run_remote_script=run, name="shell"
)
context = _ConfigContext(Config(), State(), shell)
with pytest.raises(NativeApprovalSuspended):
await context._run_mentioned_pull(script="dify-agent config files pull --json file", target_kind="file")
assert calls == []
asyncio.run(exercise())
@pytest.mark.parametrize(
"name,arguments",
[
("shell_run", {"script": "printf fixture"}),
("shell_input", {"job_id": "job", "text": "answer\n"}),
("shell_wait", {"job_id": "job"}),
("shell_interrupt", {"job_id": "job"}),
],
)
def test_shell_native_tool_waits_before_session_access(name, arguments):
async def exercise():
import httpx
from dify_agent.layers.shell.layer import Capability
from dify_agent.runtime.context import Deps, Services
from pydantic_ai import Agent, DeferredToolRequests
from pydantic_ai.messages import ModelResponse, ToolCallPart
from pydantic_ai.models.function import FunctionModel
session = waiting_session()
async with httpx.AsyncClient() as client:
deps = Deps(layers={}, services=Services(client, client), run_id="run")
agent = Agent(
FunctionModel(
lambda messages, info: ModelResponse(parts=[ToolCallPart(name, arguments, tool_call_id="call")])
),
toolsets=[Capability("shell").get_toolset()],
capabilities=[implementation().NativeEffectsCapability(session)],
output_type=[str, DeferredToolRequests],
)
result = await agent.run("do it", deps=deps)
checkpoint = session.continuation.capture(result=result, deps=deps)
assert checkpoint.pending_calls[0].tool_name == name
assert checkpoint.pending_calls[0].intent.parameters.items() >= arguments.items()
asyncio.run(exercise())
def test_bootstrap_waits_before_runtime_resource_access():
async def exercise():
from types import SimpleNamespace
import httpx
from dify_agent.layers.shell.layer import Capability, State
from dify_agent.runtime.context import Deps, Services
async with httpx.AsyncClient() as client:
deps = Deps(
layers={
"shell": {
"config": {"cli_tools": [{"install_commands": ["printf fixture"]}]},
"state": State().model_dump(mode="json"),
}
},
services=Services(client, client),
run_id="run",
native_effects=waiting_session(),
)
async def handler():
pytest.fail("run entered before bootstrap approval")
with pytest.raises(NativeApprovalSuspended):
await Capability("shell").wrap_run(SimpleNamespace(deps=deps), handler=handler)
assert deps.layers["shell"]["state"]["initialized"] is False
asyncio.run(exercise())
def test_actual_model_client_waits_before_http():
async def exercise():
import httpx
from dify_agent.adapters.llm.provider import DifyApiLLMClient
from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig
assert "native_effects" in DifyApiLLMClient.__dataclass_fields__, "Model transport has no native gate"
calls = []
def transport(request):
calls.append(request)
return httpx.Response(500)
async with httpx.AsyncClient(transport=httpx.MockTransport(transport)) as http:
client = DifyApiLLMClient(
plugin_id="plugin",
inner_api_url="http://api",
inner_api_key="",
execution_context=DifyExecutionContextLayerConfig(
tenant_id="tenant",
user_id="user",
user_from="account",
app_id="app",
agent_mode="workflow_run",
invoke_from="debugger",
),
agent_run_id="run",
http_client=http,
native_effects=waiting_session(),
)
with pytest.raises(NativeApprovalSuspended):
_ = [
chunk
async for chunk in client.iter_llm_result_chunks(
provider="provider",
model="model",
prompt_messages=[],
model_parameters={"temperature": 0.1},
tools=None,
stop=None,
stream=True,
)
]
assert calls == []
asyncio.run(exercise())
def test_marked_runner_requires_real_effect_owner_before_loading(monkeypatch):
async def exercise():
import dify_agent.runtime.runner as runner_module
import httpx
from dify_agent.protocol.native_testing import NativeTestCapabilities, NativeTestProtocolUnsupported
from dify_agent.protocol.schemas import CreateRunRequest
from dify_agent.runtime.context import Services
from dify_agent.runtime.event_sink import InMemoryRunEventSink
from dify_agent.runtime.native_continuation import NativeContinuation
calls = []
def load(*args, **kwargs):
calls.append("loaded")
raise AssertionError("modules loaded without actual policy owner")
monkeypatch.setattr(runner_module, "load_modules", load)
async with httpx.AsyncClient() as client:
runner = runner_module.AgentRunRunner(
sink=InMemoryRunEventSink(),
run_id="run",
services=Services(client, client),
request=CreateRunRequest(
composition={"layers": []},
execution_mode="builder_test",
native_test_ref=REF,
native_test_protocol_version=1,
),
native_continuation=NativeContinuation(
run_id="run", ref=REF, generation=0, config_ref="config", config_digest="d" * 64
),
native_capabilities=NativeTestCapabilities(
protocol_version=1,
features={"pending-call-v1", "policy-gate-v1", "test-scope-v1", "child-continuation-v1"},
),
)
with pytest.raises(NativeTestProtocolUnsupported):
await runner.run()
assert calls == []
asyncio.run(exercise())
@pytest.mark.parametrize("claim_allowed", [True, False])
def test_actual_direct_plugin_claim_precedes_one_daemon_request(claim_allowed):
async def exercise():
import json
import httpx
from dify_agent.adapters.native_testing.client import AgentPolicyTransportUnavailable, HttpAgentPolicyClient
from dify_agent.layers.dify_plugin.tools_layer import Toolset
from dify_agent.runtime.context import Deps, Services
from pydantic_ai import Agent
from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart, ToolReturnPart
from pydantic_ai.models.function import FunctionModel
from tests.local.dify_agent.layers.dify_plugin.test_layers import identity
events = []
def transport(request):
path = request.url.path.rsplit("/", 1)[-1]
payload = json.loads(request.content)
events.append(path)
if path == "before-call":
return httpx.Response(200, json=PERMIT.model_dump(mode="json"))
if path == "materialize":
assert payload["permit"] == PERMIT.model_dump(mode="json")
return httpx.Response(
200,
json={"credential_type": "api-key", "credentials": {"token": "fixture"}, "runtime_parameters": {}},
)
if path == "claim-dispatch":
return httpx.Response(200, json={"claimed": True}) if claim_allowed else httpx.Response(409)
if path == "outcome":
return httpx.Response(
200,
json={
"operation_id": "operation",
"attempt_id": "attempt",
"outcome": payload["outcome"],
"result_ref": payload["result_ref"],
},
)
assert path == "invoke" and events[-2] == "claim-dispatch"
assert payload["data"]["credentials"] == {"token": "fixture"}
assert payload["data"]["tool_parameters"] == {"enabled": True}
return httpx.Response(
200, text='data: {"code":0,"message":"ok","data":{"type":"text","message":{"text":"done"}}}\n'
)
def model(messages, info):
return (
ModelResponse(parts=[TextPart("done")])
if any(isinstance(p, ToolReturnPart) for m in messages for p in m.parts)
else ModelResponse(parts=[ToolCallPart("write", {"enabled": True}, tool_call_id="call")])
)
async with httpx.AsyncClient(transport=httpx.MockTransport(transport)) as client:
session = waiting_session()
session.dispatcher.policy = HttpAgentPolicyClient(
base_url="http://api", api_key="fixture", http_client=client, ref=REF
)
deps = Deps(
layers={
"execution_context": {"config": identity().model_dump(mode="json"), "state": {}},
"tools": {
"config": {
"tools": [
{
"plugin_id": "test/plugin",
"provider": "provider",
"tool_name": "write",
"credential_type": "unauthorized",
"parameters_json_schema": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
},
}
]
},
"state": {},
},
},
services=Services(client, client),
run_id="run",
)
agent = Agent(
FunctionModel(model),
toolsets=[Toolset("tools")],
capabilities=[implementation().NativeEffectsCapability(session)],
)
if claim_allowed:
await agent.run("write", deps=deps)
else:
with pytest.raises(AgentPolicyTransportUnavailable):
await agent.run("write", deps=deps)
assert events == (
["before-call", "materialize", "claim-dispatch", "invoke", "outcome"]
if claim_allowed
else ["before-call", "materialize", "claim-dispatch", "outcome"]
)
asyncio.run(exercise())
def test_private_policy_http_preserves_discriminants_and_typed_arguments():
async def exercise():
import json
import httpx
assert importlib.util.find_spec("dify_agent.adapters.native_testing") is not None, (
"Private policy adapter is missing"
)
from dify_agent.adapters.native_testing.client import HttpAgentPolicyClient
requests = []
def transport(request):
requests.append(json.loads(request.content))
return httpx.Response(
200, json={"kind": "waiting", "pending_operation_id": "pending", "wait_reason": "configuration"}
)
async with httpx.AsyncClient(transport=httpx.MockTransport(transport)) as http:
client = HttpAgentPolicyClient(base_url="http://api", api_key="service", http_client=http)
decision = await client.before_call(ref=REF, intent=INTENT)
assert decision == AgentApprovalWait(pending_operation_id="pending", wait_reason="configuration")
assert requests == [{"ref": REF.model_dump(mode="json"), "intent": INTENT.model_dump(mode="json")}]
asyncio.run(exercise())
def test_missing_shell_lineage_stops_before_effect_reservation():
async def exercise():
session = waiting_session()
session.validate_shell_lineage = None
with pytest.raises(NativePolicyStopped, match="native_shell_lineage_unavailable"):
await session.intent(
boundary="shell_run", call_id="call", target={"shell": "shell"}, parameters={"script": "printf fixture"}
)
assert session.dispatcher.policy.intents == []
asyncio.run(exercise())
_TYPED_JSON_CASES = [
pytest.param({"nested": {"flag": True}}, {"nested": {"flag": 1}}, False, id="nested-true-integer"),
pytest.param({"items": [{"flag": False}]}, {"items": [{"flag": 0}]}, False, id="array-false-integer"),
pytest.param({"items": [1]}, {"items": [1.0]}, False, id="integer-float"),
pytest.param({"items": [-0.0]}, {"items": [0.0]}, False, id="signed-zero"),
pytest.param(
{"z": [True, 1.0], "a": {"b": 2, "a": 1}}, {"a": {"a": 1, "b": 2}, "z": [True, 1.0]}, True, id="key-order"
),
]
@pytest.mark.parametrize("field", ["target", "parameters"])
@pytest.mark.parametrize(("approved", "actual", "matches"), _TYPED_JSON_CASES)
def test_session_typed_json_guard_precedes_authorization_and_dispatch(field, approved, actual, matches):
async def exercise():
session = waiting_session()
session.dispatcher.policy.decision = PERMIT
calls = []
async def prepare(**kwargs):
return INTENT.model_copy(
update={
"key": kwargs["key"],
"target": kwargs["target"],
"parameters": kwargs["parameters"],
field: approved,
}
)
async def invoke(_permit):
calls.append("dispatch")
return {"done": True}
session.prepare_intent = prepare
kwargs = {"target": INTENT.target, "parameters": INTENT.parameters, field: actual}
if matches:
assert await session.stage(boundary="tool", call_id="call", stage="tool", invoke=invoke, **kwargs) == {
"done": True
}
assert calls == ["dispatch"]
assert len(session.dispatcher.policy.intents) == 1
else:
with pytest.raises(NativePolicyStopped, match="native_effect_arguments_changed"):
await session.stage(boundary="tool", call_id="call", stage="tool", invoke=invoke, **kwargs)
assert calls == []
assert session.dispatcher.policy.intents == []
asyncio.run(exercise())
@pytest.mark.parametrize(("approved", "actual", "matches"), _TYPED_JSON_CASES)
def test_direct_plugin_typed_json_guard_precedes_materialize_claim_and_dispatch(approved, actual, matches):
asyncio.run(_exercise_direct_plugin_json_guard(approved, actual, matches))
def test_direct_plugin_target_guard_precedes_materialize_claim_and_dispatch():
asyncio.run(_exercise_direct_plugin_json_guard({}, {}, False, changed_target=True))
async def _exercise_direct_plugin_json_guard(approved, actual, matches, *, changed_target=False):
from types import SimpleNamespace
import httpx
from dify_agent.adapters.native_testing.client import HttpAgentPolicyClient
from dify_agent.layers.dify_plugin.configs import DifyPluginToolConfig
from dify_agent.layers.dify_plugin.tool_client import DifyPluginDaemonToolClient
from dify_agent.layers.dify_plugin.tools_layer import _build_pydantic_ai_tool
module = implementation()
events = []
def transport(request):
path = request.url.path.rsplit("/", 1)[-1]
events.append(path)
if path == "materialize":
return httpx.Response(
200, json={"credential_type": "unauthorized", "credentials": {}, "runtime_parameters": {}}
)
if path == "claim-dispatch":
return httpx.Response(200, json={"claimed": True})
assert path == "invoke"
return httpx.Response(
200, text='data: {"code":0,"message":"ok","data":{"type":"text","message":{"text":"done"}}}\n'
)
async with httpx.AsyncClient(transport=httpx.MockTransport(transport)) as client:
session = waiting_session()
session.dispatcher.policy = HttpAgentPolicyClient(
base_url="http://api", api_key="fixture", http_client=client, ref=REF
)
config = DifyPluginToolConfig(
plugin_id="test/plugin", provider="provider", tool_name="write", credential_type="unauthorized"
)
tool = _build_pydantic_ai_tool(
client=DifyPluginDaemonToolClient("http://daemon", "fixture", "tenant", "test/plugin", "actor", client),
tool_config=config,
effective_parameters=[],
file_context=None,
)
# Reversed target key order is equivalent; a different actual tool is not.
target = {
"tool_name": "other" if changed_target else "write",
"provider": "provider",
"plugin_id": "test/plugin",
}
effect = INTENT.model_copy(update={"target": target, "parameters": approved})
token = module._dispatch.set(module.NativeDispatchEnvelope(REF, effect, PERMIT))
try:
ctx = SimpleNamespace(deps=SimpleNamespace(native_effects=session))
if matches:
assert await tool.function(ctx, **actual) == "done"
assert events == ["materialize", "claim-dispatch", "invoke"]
else:
with pytest.raises(NativePolicyStopped, match="native_effect_arguments_changed"):
await tool.function(ctx, **actual)
assert events == []
finally:
module._dispatch.reset(token)