mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
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:
@@ -1 +1,5 @@
|
||||
"""Agent backend inner API controllers."""
|
||||
|
||||
from . import native_testing as _native_testing
|
||||
|
||||
__all__ = ["_native_testing"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user