feat(dify-agent): add plugin tools layer

This commit is contained in:
盐粒 Yanli
2026-05-25 21:56:24 +08:00
parent 4d6f8eba2a
commit 74ffb2fbec
24 changed files with 3218 additions and 399 deletions

774
.context/impl/tool_layer.md Normal file
View File

@ -0,0 +1,774 @@
# Dify Agent plugin tools layer implementation notes
## 0. Update summary relative to the previous version of this file
The previous version documented an implementation where `dify-agent` fetched
plugin tool provider declarations, fetched runtime parameters, merged the two,
and built the model-facing JSON schema at run time. That has changed.
The current implementation moves clean declaration/schema preparation to the API
side and keeps `dify-agent` focused on invocation:
- API-side `Tool` now owns effective parameter merging through
`Tool.get_merged_runtime_parameters(...)`.
- API-side `Tool` now owns LLM-facing JSON schema generation through
`Tool.get_llm_parameters_json_schema(...)`.
- `BaseAgentRunner` now calls `Tool.get_llm_parameters_json_schema()` directly
when preparing/updating `PromptMessageTool` for normal agent tools.
- No existing `api/` caller was found that constructs `dify.plugin`,
`dify.plugin.llm`, or `dify.plugin.tools` run-composition layers, so no
API-side composition rewiring was needed for the plugin-id split.
- `dify-agent` `DifyPluginToolConfig` now carries API-prepared:
- `parameters`
- `parameters_json_schema`
- `dify-agent` no longer fetches tool provider declarations or runtime
parameters while building tools.
- `dify-agent` no longer contains provider-discovery/runtime-parameter DTOs or
client methods in `tool_client.py`.
- `DifyPluginDaemonToolClient` is now an invoke-only daemon boundary.
- `dify-agent` still prepares invocation payloads from the prepared parameter
declarations: it validates required hidden/manual inputs, applies defaults,
casts values into daemon-facing shapes, invokes the daemon, merges blob
chunks, and maps expected daemon/tool errors into agent-facing observations.
- The plugin-id split invariant remains unchanged:
- `dify.plugin` owns shared tenant/user daemon context only.
- `dify.plugin.llm` owns its LLM `plugin_id`.
- each `DifyPluginToolConfig` owns its tool `plugin_id`.
## 1. Goal
The Dify Agent tool layer lets a `dify-agent` run expose Dify plugin tools to a
Pydantic AI agent. The current design deliberately separates **preparation** from
**execution**:
- Dify API prepares the effective tool declaration and LLM-facing JSON schema.
- Dify Agent receives that clean prepared config and performs only runtime
invocation work.
This avoids duplicating Dify API's original agent-node declaration merge and
schema-building semantics inside `dify-agent` while preserving the daemon tool
invocation contract.
The public layer split is:
| Layer | Type id | Responsibility |
| --- | --- | --- |
| `dify.plugin` | `"dify.plugin"` | Shared plugin-daemon tenant/user context plus server-injected daemon settings. |
| `dify.plugin.llm` | `"dify.plugin.llm"` | One plugin-backed LLM selection, including its own `plugin_id`. |
| `dify.plugin.tools` | `"dify.plugin.tools"` | One or more prepared plugin-backed tools, each with its own `plugin_id`. |
## 2. API-side preparation contract
### 2.1 `Tool.get_merged_runtime_parameters(...)`
File:
```text
api/core/tools/__base/tool.py
```
Spec:
- Start from the tool entity's declared parameters.
- Fetch runtime parameters with `get_runtime_parameters(...)`.
- Runtime parameters override declared parameters with the same `name`.
- Runtime parameters with new names are appended.
- All returned parameters are deep-copied and detached from cached tool
declarations.
Invariant:
- Callers can safely mutate the returned parameter list while preparing schemas
or downstream config.
- The merge is owned by API-side tool logic, not by `dify-agent`.
### 2.2 `Tool.get_llm_parameters_json_schema(...)`
File:
```text
api/core/tools/__base/tool.py
```
Spec:
- Build the model-visible JSON schema from the effective parameters returned by
`get_merged_runtime_parameters(...)`.
- Include only parameters with `form == LLM`.
- Exclude file-like inputs that should not be directly supplied by the model:
- `system-files`
- `file`
- `files`
- Use `parameter.input_schema` when present.
- Otherwise derive a schema from `parameter.type.as_normal_type()`.
- Preserve `llm_description` as the JSON schema `description`.
- Add enum values for select options.
- Add required LLM parameters to the schema `required` list.
Invariant:
- Hidden/manual parameters remain available for invocation preparation but are
omitted from the model-facing schema.
- This helper is the API-side source of truth for normal tool schema generation.
### 2.3 `BaseAgentRunner` call sites
File:
```text
api/core/agent/base_agent_runner.py
```
Normal agent tool conversion now uses:
```python
tool_entity.get_llm_parameters_json_schema()
```
and prompt-tool updates use:
```python
prompt_tool.parameters = tool.get_llm_parameters_json_schema()
```
Invariant:
- `BaseAgentRunner` no longer carries duplicated normal-tool schema-building
logic.
- Schema branch behavior belongs in `Tool`, while the runner only wires the
prepared schema into `PromptMessageTool`.
Note:
- Dataset retriever tools still have their own existing prompt-tool conversion
path. That path is outside this plugin-tools preparation split.
## 3. Public `dify-agent` config spec
File:
```text
dify-agent/src/dify_agent/layers/dify_plugin/configs.py
```
### 3.1 `DifyPluginLayerConfig`
```python
class DifyPluginLayerConfig(LayerConfig):
tenant_id: str
user_id: str | None = None
```
Spec:
- Represents only shared tenant/user context for plugin-daemon calls.
- Does not contain `plugin_id`.
- Does not contain daemon URL or daemon API key.
Invariants:
- Daemon URL/API key are server-side runtime settings and must not be accepted
from public run payloads.
- `extra="forbid"` rejects obsolete or unknown public fields.
- The layer can be shared by multiple LLM/tool business layers targeting
different plugin ids.
### 3.2 `DifyPluginLLMLayerConfig`
```python
class DifyPluginLLMLayerConfig(LayerConfig):
plugin_id: str
model_provider: str
model: str
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
model_settings: ModelSettings | None = None
```
Spec:
- Selects a plugin-backed LLM provider/model.
- Owns the LLM plugin id.
- Carries scalar credentials and optional Pydantic AI model settings.
Invariants:
- `plugin_id` identifies daemon/plugin transport for the LLM plugin.
- `model_provider` is request-level business model identity, for example
`"openai"`.
- Credentials are scalar values only: `str | int | float | bool | None`.
- Old `provider` config is rejected.
### 3.3 Prepared tool parameter DTOs
`dify-agent` exposes client-safe DTOs for the prepared declarations it receives
from API-side preparation:
```python
class DifyPluginToolOption(BaseModel):
value: str
class DifyPluginToolParameterType(StrEnum):
STRING = "string"
NUMBER = "number"
BOOLEAN = "boolean"
SELECT = "select"
SECRET_INPUT = "secret-input"
FILE = "file"
FILES = "files"
APP_SELECTOR = "app-selector"
MODEL_SELECTOR = "model-selector"
ANY = "any"
DYNAMIC_SELECT = "dynamic-select"
CHECKBOX = "checkbox"
SYSTEM_FILES = "system-files"
ARRAY = "array"
OBJECT = "object"
class DifyPluginToolParameterForm(StrEnum):
SCHEMA = "schema"
FORM = "form"
LLM = "llm"
class DifyPluginToolParameter(BaseModel):
name: str
type: DifyPluginToolParameterType
form: DifyPluginToolParameterForm
required: bool = False
default: DifyPluginToolValue = None
llm_description: str | None = None
input_schema: dict[str, JsonValue] | None = None
options: list[DifyPluginToolOption] = Field(default_factory=list)
```
Spec:
- These DTOs describe the API-prepared effective parameter declarations that the
agent runtime needs for hidden/manual validation, default application, and
daemon-facing type coercion.
Invariant:
- These DTOs are not used by `dify-agent` to rebuild the model-facing schema;
the model-facing schema is supplied directly as `parameters_json_schema`.
### 3.4 `DifyPluginToolConfig`
```python
class DifyPluginToolConfig(LayerConfig):
plugin_id: str
provider: str
tool_name: str
credential_type: DifyPluginToolCredentialType
name: str | None = None
description: str | None = None
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
runtime_parameters: dict[str, DifyPluginToolValue] = Field(default_factory=dict)
parameters: list[DifyPluginToolParameter] = Field(default_factory=list)
parameters_json_schema: dict[str, JsonValue] = Field(
default_factory=lambda: {"type": "object", "properties": {}, "required": []}
)
strict: bool | None = None
```
Spec:
- Describes one prepared plugin tool to expose to the agent.
- `plugin_id` is the plugin that provides this tool.
- `provider` is the provider id inside the plugin.
- `tool_name` is the daemon-declared tool name used for invocation.
- `credential_type` is the daemon credential transport mode.
- `name` optionally overrides the agent-visible tool name.
- `description` optionally overrides the agent-visible description.
- `runtime_parameters` supplies hidden/manual invocation inputs.
- `parameters` supplies API-prepared effective declarations for invocation-time
validation/defaults/type coercion.
- `parameters_json_schema` supplies API-prepared model-visible JSON schema.
- `strict` is forwarded to Pydantic AI tool definition semantics.
Credential invariant:
- `credential_type` is explicit caller-supplied daemon transport mode, not a
value inferred from provider metadata.
- It must match the supplied credentials, for example `"api-key"`, `"oauth2"`,
or `"unauthorized"`.
- A wrong value can make daemon invocation fail at runtime even when local config
validation succeeds.
Prepared-config invariant:
- `dify-agent` trusts `parameters_json_schema` as the model-visible schema.
- `dify-agent` does not fetch provider declarations or runtime parameters to
repair or regenerate the schema at run time.
### 3.5 `DifyPluginToolsLayerConfig`
```python
class DifyPluginToolsLayerConfig(LayerConfig):
tools: list[DifyPluginToolConfig] = Field(default_factory=list)
```
Spec:
- Carries the list of plugin tools contributed by one `dify.plugin.tools` layer.
- Empty tool lists are valid.
Invariants:
- Individual tools may refer to different plugin ids.
- Duplicate tool-name validation happens after all static and dynamic tools are
aggregated by the runner.
## 4. Client-safe import boundary
`dify_agent.layers.dify_plugin` exports only client-safe DTOs and stable type ids:
- `DIFY_PLUGIN_LAYER_TYPE_ID`
- `DIFY_PLUGIN_LLM_LAYER_TYPE_ID`
- `DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID`
- `DifyPluginCredentialValue`
- `DifyPluginLLMLayerConfig`
- `DifyPluginLayerConfig`
- `DifyPluginToolCredentialType`
- `DifyPluginToolConfig`
- `DifyPluginToolOption`
- `DifyPluginToolParameter`
- `DifyPluginToolParameterForm`
- `DifyPluginToolParameterType`
- `DifyPluginToolsLayerConfig`
- `DifyPluginToolValue`
It intentionally does not export implementation layers, daemon clients, runtime
objects, or server-only modules. Client code can build run requests without
pulling in server dependencies.
## 5. Runtime plugin context layer
`DifyPluginLayer` carries shared plugin-daemon identity:
```python
@dataclass(slots=True)
class DifyPluginLayer(PlainLayer[NoLayerDeps, DifyPluginLayerConfig, EmptyRuntimeState]):
config: DifyPluginLayerConfig
daemon_url: str
daemon_api_key: str
```
Construction spec:
- `from_config(...)` rejects plain construction because daemon settings must be
injected by server provider factories.
- `from_config_with_settings(...)` constructs the layer from public config plus
server-only daemon URL/API key.
Factory methods:
```python
def create_daemon_provider(*, plugin_id: str, http_client: httpx.AsyncClient) -> DifyPluginDaemonProvider
def create_tool_client(*, plugin_id: str, http_client: httpx.AsyncClient) -> DifyPluginDaemonToolClient
```
Invariants:
- The caller supplies the concrete `plugin_id`.
- The passed HTTP client must be open.
- The layer never opens, caches, closes, serializes, or snapshots live HTTP
clients.
## 6. LLM layer
`DifyPluginLLMLayer` depends directly on `DifyPluginLayer`:
```python
class DifyPluginLLMDeps(LayerDeps):
plugin: DifyPluginLayer
```
`get_model(...)` asks the plugin layer to create a daemon provider using the LLM
layer's own `plugin_id`, then builds a `DifyLLMAdapterModel` from configured
model provider, model, credentials, and settings.
Invariants:
- Daemon transport identity is derived from shared plugin context plus LLM
`plugin_id`.
- Business model provider identity remains request-level model data.
- The shared HTTP client comes from the runner.
## 7. Tools layer flow in `dify-agent`
File:
```text
dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py
```
`DifyPluginToolsLayer` depends directly on `DifyPluginLayer`:
```python
class DifyPluginToolsDeps(LayerDeps):
plugin: DifyPluginLayer
```
`get_tools(http_client=...)` resolves prepared plugin tool configs into Pydantic
AI `Tool` objects. For each tool config it:
1. Creates or reuses a `DifyPluginDaemonToolClient` for `tool_config.plugin_id`.
2. Deep-copies `tool_config.parameters` into effective invocation parameters.
3. Validates required hidden/manual parameters.
4. Builds a Pydantic AI tool whose model schema is a deep copy of
`tool_config.parameters_json_schema`.
Invariants:
- No provider metadata is fetched.
- No runtime-parameter endpoint is called.
- No declaration/schema merge happens in `dify-agent`.
- Per-tool `plugin_id` chooses the tool daemon transport identity.
## 8. Hidden/manual parameter validation
`_validate_required_hidden_parameters(...)` checks prepared parameters before
tool construction.
Spec:
- For parameters where `form != LLM`, if the parameter is required, has no
default, and is not present in `runtime_parameters`, construction fails with a
validation error.
Invariant:
- Required hidden/manual inputs must be provided by config or by prepared
defaults before the agent can expose the tool.
## 9. Invocation argument preparation
`_prepare_tool_arguments(...)` combines prepared config and model tool args.
Argument precedence:
1. Start from config-supplied `runtime_parameters` for hidden/manual inputs.
2. Model-supplied tool arguments override same-named entries.
3. If no value was supplied, use the prepared parameter default.
4. If a required parameter still has no value, raise a validation error.
Spec:
- Declared parameters are type-cast into daemon-facing wire shapes.
- Extra merged keys not present in `parameters` pass through unchanged.
Invariant:
- The prepared parameter list is still required even though schema generation
moved to the API side, because the runtime must validate hidden/manual inputs,
apply defaults, and normalize invocation payload values.
## 10. Invocation type coercion
`_cast_tool_parameter_value(...)` normalizes values before daemon invocation.
Rules:
| Parameter type | Runtime coercion |
| --- | --- |
| `string`, `secret-input`, `select`, `checkbox`, `dynamic-select` | `None` -> empty string; non-string -> `str(value)` |
| `boolean` | common truthy/falsey strings are parsed; otherwise bool-like coercion |
| `number` | numeric values pass through; numeric strings become int/float |
| `files`, `system-files` | non-list values are wrapped in a list |
| `file` | list must contain exactly one item; otherwise error |
| `model-selector`, `app-selector` | must be a dict |
| `any` | must be JSON-like if not `None` |
| `array` | list passes through; string tries JSON parse; otherwise wraps in list |
| `object` | dict passes through; string tries JSON parse; invalid strings become `{}` |
Invariant:
- Unexpected local validation/coercion errors are not swallowed by a blanket
catch; only expected daemon/tool validation paths become agent observations.
## 11. Pydantic AI tool adapter
`_build_pydantic_ai_tool(...)` creates a Pydantic AI `Tool` with:
- an invocation closure that prepares daemon arguments, invokes the plugin tool,
and converts daemon stream messages to observation text;
- a prepare closure that sets `parameters_json_schema` from the API-prepared
`tool_config.parameters_json_schema`.
Invariants:
- The tool name is `tool_config.name or tool_config.tool_name`.
- The tool description is `tool_config.description or tool_name`.
- The model-facing schema is not rebuilt from parameter declarations.
- Expected `DifyPluginToolClientError` and local `ValueError` are converted into
agent-facing text.
- Unexpected local errors propagate.
## 12. Invoke-only daemon tool client
File:
```text
dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py
```
`DifyPluginDaemonToolClient` now exposes only invocation:
```python
async def invoke(
*,
provider: str,
tool_name: str,
credential_type: DifyPluginToolCredentialType,
credentials: dict[str, object],
tool_parameters: Mapping[str, object],
) -> list[DifyPluginToolInvokeMessage]
```
Daemon endpoint:
```text
POST /plugin/{tenant_id}/dispatch/tool/invoke
```
Headers:
```text
X-Api-Key: daemon api key
X-Plugin-ID: per-tool plugin id
Content-Type: application/json
```
Payload shape:
```python
{
"data": {
"provider": provider,
"tool": tool_name,
"credentials": credentials,
"credential_type": credential_type,
"tool_parameters": dict(tool_parameters),
},
"user_id": user_id, # only when configured on shared plugin context
}
```
Invariants:
- `tenant_id` comes from shared plugin context and appears in the path.
- `user_id` comes from shared plugin context and is forwarded top-level when
present.
- `X-Plugin-ID` comes from the individual tool config's `plugin_id`.
- Provider/tool/credentials/credential type/parameters are per invocation.
## 13. Shared plugin-daemon transport helpers
`dify_agent.plugin_daemon_transport` contains daemon-transport behavior shared by
LLM and tools clients:
```python
def to_plugin_daemon_jsonable(value: object) -> object
def decode_plugin_daemon_error_payload(raw_message: str) -> PluginDaemonErrorPayload | None
def unwrap_plugin_daemon_error(*, error_type: str, message: str) -> PluginDaemonErrorPayload
```
Spec:
- Convert Pydantic models and nested collections to JSON-safe daemon payloads.
- Decode daemon JSON error strings shaped like `{"error_type": ..., "message": ...}`.
- Recursively unwrap nested `PluginInvokeError` payloads.
Invariant:
- LLM and tools daemon adapters must not duplicate this protocol logic.
## 14. Tool stream messages and blob chunks
`DifyPluginToolInvokeMessage` models the daemon stream message subset needed for
agent observations: text, JSON, image, links, variables, logs, file/blob
messages, and blob chunks.
`merge_blob_chunks(...)` merges streamed `blob_chunk` messages into final `blob`
messages before higher-level observation conversion.
Invariants:
- Chunks are grouped by id.
- Each file is capped at 30MB.
- Each chunk is capped at 8KB.
- Completed chunks become a single `BLOB` message.
- Higher-level observation conversion does not see raw chunk sequence details.
## 15. Observation conversion and error mapping
`_convert_tool_response_to_text(...)` maps daemon messages into text for the
agent:
- text messages append their text;
- link messages become a user-checkable link instruction;
- image messages become a user-checkable image instruction;
- JSON messages are serialized unless suppressed;
- variable messages are ignored;
- unknown messages fall back to `str(message)`;
- JSON fragments are deduplicated against existing text.
`_tool_error_text(...)` maps expected daemon invocation errors into agent-facing
text:
- credential/authorization errors -> `Please check your tool provider credentials`;
- tool/provider not found -> `there is not a tool named {tool_name}`;
- validation/bad-request errors -> `tool parameters validation error: ...`;
- other daemon errors -> `tool invoke error: ...`.
Invariant:
- Known daemon/tool rejections are softened into observations.
- Unexpected local bugs are not caught by the tool adapter and should fail
loudly.
## 16. Runner integration
The runner resolves tools with `_resolve_run_tools(...)`:
1. Start with static compositor tools from `run.tools`.
2. Traverse run slots and call `get_tools(...)` on every `DifyPluginToolsLayer`.
3. Validate aggregate tool-name uniqueness.
4. Pass the final list to Pydantic AI agent construction.
Invariant:
- Duplicate tool names are rejected across all sources: static tools, one tools
layer, or multiple tools layers.
- Validation happens before Pydantic AI agent construction so conflicts are
reported as run validation errors.
## 17. Provider factory and lifecycle
`create_default_layer_providers(...)` includes providers for:
- prompt layers;
- history layer;
- output layer;
- `DifyPluginLayer` through a server-settings factory;
- `DifyPluginLLMLayer`;
- `DifyPluginToolsLayer`.
Lifecycle invariant:
- FastAPI/server runtime owns the shared plugin daemon HTTP client.
- Runner passes the shared client to LLM/tools layers.
- Layers and snapshots remain state-only and never own live resources.
## 18. Usage example
```python
from dify_agent.layers.dify_plugin import (
DifyPluginLayerConfig,
DifyPluginLLMLayerConfig,
DifyPluginToolConfig,
DifyPluginToolParameter,
DifyPluginToolsLayerConfig,
)
from dify_agent.protocol.schemas import RunComposition, RunLayerSpec
composition = RunComposition(
layers=[
RunLayerSpec(
name="plugin",
type="dify.plugin",
config=DifyPluginLayerConfig(tenant_id="tenant-1", user_id="user-1"),
),
RunLayerSpec(
name="llm",
type="dify.plugin.llm",
deps={"plugin": "plugin"},
config=DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="gpt-4o-mini",
credentials={"api_key": "replace-with-model-key"},
),
),
RunLayerSpec(
name="tools",
type="dify.plugin.tools",
deps={"plugin": "plugin"},
config=DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/search",
provider="search",
tool_name="web_search",
credential_type="api-key",
credentials={"api_key": "replace-with-tool-key"},
runtime_parameters={"site": "docs.dify.ai"},
parameters=[
DifyPluginToolParameter(
name="query",
type="string",
form="llm",
required=True,
llm_description="Search query",
)
],
parameters_json_schema={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query",
}
},
"required": ["query"],
},
)
]
),
),
]
)
```
In production, API-side preparation should fill `parameters` and
`parameters_json_schema` from the effective Dify tool declaration before the
composition is submitted to `dify-agent`.
## 19. Test coverage
The local tests now cover:
- API-side runtime-parameter merge semantics in `Tool.get_merged_runtime_parameters(...)`;
- API-side LLM JSON schema generation in `Tool.get_llm_parameters_json_schema(...)`;
- `BaseAgentRunner` using the API-side schema helper instead of inline schema logic;
- client-safe exports and DTO validation;
- explicit tool `credential_type` requirement;
- prepared `parameters` and `parameters_json_schema` in public request payloads;
- plugin layer shared HTTP client behavior and closed-client rejection;
- LLM layer model construction from direct plugin dependency;
- prepared plugin tools being converted to Pydantic AI tools;
- required hidden/manual parameter validation;
- defaults being applied during invocation;
- daemon-facing type coercion for non-string prepared parameter types;
- per-tool `plugin_id` driving `X-Plugin-ID` for multiple tools;
- shared `user_id` being forwarded in tool invocation payloads;
- agent-friendly daemon/tool error text;
- nested `PluginInvokeError` unwrapping;
- blob chunk merging before observation conversion;
- unexpected local/transport failures propagating instead of being swallowed;
- dynamic plugin tools being passed to the runner's agent;
- duplicate tool names across dynamic layers and static/dynamic tools;
- import boundary safety.
## 20. Non-goals and boundaries
- The agent does not import provider SDKs directly; all LLM/tool execution goes
through the plugin daemon.
- The agent does not infer `credential_type` from provider metadata.
- The agent does not store provider credential schema or OAuth schema in its tool
client DTOs.
- The agent does not fetch provider declarations or runtime parameters for tools
at execution time.
- The agent does not rebuild model-facing tool JSON schemas from declarations.
- The agent does not persist daemon clients or HTTP clients in session snapshots.
- Local tests mock daemon contracts and do not prove real daemon integration.

View File

@ -22,9 +22,6 @@ from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_manager import ModelInstance
from core.prompt.utils.extract_thread_messages import extract_thread_messages
from core.tools.__base.tool import Tool
from core.tools.entities.tool_entities import (
ToolParameter,
)
from core.tools.tool_manager import ToolManager
from core.tools.utils.dataset_retriever_tool import DatasetRetrieverTool
from extensions.ext_database import db
@ -150,44 +147,9 @@ class BaseAgentRunner(AppRunner):
message_tool = PromptMessageTool(
name=tool.tool_name,
description=tool_entity.entity.description.llm,
parameters={
"type": "object",
"properties": {},
"required": [],
},
parameters=tool_entity.get_llm_parameters_json_schema(),
)
parameters = tool_entity.get_merged_runtime_parameters()
for parameter in parameters:
if parameter.form != ToolParameter.ToolParameterForm.LLM:
continue
parameter_type = parameter.type.as_normal_type()
if parameter.type in {
ToolParameter.ToolParameterType.SYSTEM_FILES,
ToolParameter.ToolParameterType.FILE,
ToolParameter.ToolParameterType.FILES,
}:
continue
enum = []
if parameter.type == ToolParameter.ToolParameterType.SELECT:
enum = [option.value for option in parameter.options] if parameter.options else []
message_tool.parameters["properties"][parameter.name] = (
{
"type": parameter_type,
"description": parameter.llm_description or "",
}
if parameter.input_schema is None
else parameter.input_schema
)
if len(enum) > 0:
message_tool.parameters["properties"][parameter.name]["enum"] = enum
if parameter.required:
message_tool.parameters["required"].append(parameter.name)
return message_tool, tool_entity
def _convert_dataset_retriever_tool_to_prompt_message_tool(self, tool: DatasetRetrieverTool) -> PromptMessageTool:
@ -252,40 +214,7 @@ class BaseAgentRunner(AppRunner):
"""
update prompt message tool
"""
# try to get tool runtime parameters
tool_runtime_parameters = tool.get_runtime_parameters()
for parameter in tool_runtime_parameters:
if parameter.form != ToolParameter.ToolParameterForm.LLM:
continue
parameter_type = parameter.type.as_normal_type()
if parameter.type in {
ToolParameter.ToolParameterType.SYSTEM_FILES,
ToolParameter.ToolParameterType.FILE,
ToolParameter.ToolParameterType.FILES,
}:
continue
enum = []
if parameter.type == ToolParameter.ToolParameterType.SELECT:
enum = [option.value for option in parameter.options] if parameter.options else []
prompt_tool.parameters["properties"][parameter.name] = (
{
"type": parameter_type,
"description": parameter.llm_description or "",
}
if parameter.input_schema is None
else parameter.input_schema
)
if len(enum) > 0:
prompt_tool.parameters["properties"][parameter.name]["enum"] = enum
if parameter.required:
if parameter.name not in prompt_tool.parameters["required"]:
prompt_tool.parameters["required"].append(parameter.name)
prompt_tool.parameters = tool.get_llm_parameters_json_schema()
return prompt_tool
def create_agent_thought(

View File

@ -126,34 +126,89 @@ class Tool(ABC):
message_id: str | None = None,
) -> list[ToolParameter]:
"""
get merged runtime parameters
Get the effective parameter declarations for this tool.
Runtime parameters override declared parameters by name and append new
parameters, but the returned list is always detached from the tool's
cached declarations so callers can safely mutate it while building
downstream schemas.
:return: merged runtime parameters
"""
parameters = self.entity.parameters
parameters = parameters.copy()
user_parameters = self.get_runtime_parameters() or []
user_parameters = user_parameters.copy()
parameters = [deepcopy(parameter) for parameter in self.entity.parameters or []]
user_parameters = [
deepcopy(parameter)
for parameter in self.get_runtime_parameters(
conversation_id=conversation_id,
app_id=app_id,
message_id=message_id,
)
or []
]
parameter_indexes = {parameter.name: index for index, parameter in enumerate(parameters)}
# override parameters
for parameter in user_parameters:
# check if parameter in tool parameters
for tool_parameter in parameters:
if tool_parameter.name == parameter.name:
# override parameter
tool_parameter.type = parameter.type
tool_parameter.form = parameter.form
tool_parameter.required = parameter.required
tool_parameter.default = parameter.default
tool_parameter.options = parameter.options
tool_parameter.llm_description = parameter.llm_description
break
else:
# add new parameter
existing_index = parameter_indexes.get(parameter.name)
if existing_index is None:
parameter_indexes[parameter.name] = len(parameters)
parameters.append(parameter)
continue
parameters[existing_index] = parameter
return parameters
def get_llm_parameters_json_schema(
self,
conversation_id: str | None = None,
app_id: str | None = None,
message_id: str | None = None,
) -> dict[str, Any]:
"""Build the model-visible JSON schema from effective tool parameters.
Hidden/manual parameters stay available for invocation preparation on the
API side, but are intentionally omitted from the LLM-facing schema.
"""
schema: dict[str, Any] = {
"type": "object",
"properties": {},
"required": [],
}
for parameter in self.get_merged_runtime_parameters(
conversation_id=conversation_id,
app_id=app_id,
message_id=message_id,
):
if parameter.form != ToolParameter.ToolParameterForm.LLM:
continue
if parameter.type in {
ToolParameter.ToolParameterType.SYSTEM_FILES,
ToolParameter.ToolParameterType.FILE,
ToolParameter.ToolParameterType.FILES,
}:
continue
parameter_schema = (
{
"type": parameter.type.as_normal_type(),
"description": parameter.llm_description or "",
}
if parameter.input_schema is None
else deepcopy(parameter.input_schema)
)
parameter_schema.setdefault("description", parameter.llm_description or "")
if parameter.type == ToolParameter.ToolParameterType.SELECT and parameter.options:
parameter_schema["enum"] = [option.value for option in parameter.options]
schema["properties"][parameter.name] = parameter_schema
if parameter.required:
schema["required"].append(parameter.name)
return schema
def create_image_message(
self,
image: str,

View File

@ -61,79 +61,20 @@ class TestRepack:
class TestUpdatePromptTool:
def build_param(self, mocker: MockerFixture, **kwargs):
p = mocker.MagicMock()
p.form = kwargs.get("form")
mock_type = mocker.MagicMock()
mock_type.as_normal_type.return_value = "string"
p.type = mock_type
p.name = kwargs.get("name", "p1")
p.llm_description = "desc"
p.input_schema = kwargs.get("input_schema")
p.options = kwargs.get("options")
p.required = kwargs.get("required", False)
return p
def test_skip_non_llm(self, runner, mocker: MockerFixture):
def test_replaces_prompt_tool_parameters_with_tool_schema(self, runner, mocker: MockerFixture):
tool = mocker.MagicMock()
param = self.build_param(mocker, form="NOT_LLM")
tool.get_runtime_parameters.return_value = [param]
schema = {
"type": "object",
"properties": {"p1": {"type": "string", "description": "desc"}},
"required": ["p1"],
}
tool.get_llm_parameters_json_schema.return_value = schema
prompt_tool = mocker.MagicMock()
prompt_tool.parameters = {"properties": {}, "required": []}
result = runner.update_prompt_message_tool(tool, prompt_tool)
assert result.parameters["properties"] == {}
def test_enum_and_required(self, runner, mocker: MockerFixture):
option = mocker.MagicMock(value="opt1")
param = self.build_param(
mocker,
form=module.ToolParameter.ToolParameterForm.LLM,
options=[option],
required=True,
)
tool = mocker.MagicMock()
tool.get_runtime_parameters.return_value = [param]
prompt_tool = mocker.MagicMock()
prompt_tool.parameters = {"properties": {}, "required": []}
result = runner.update_prompt_message_tool(tool, prompt_tool)
assert "p1" in result.parameters["required"]
def test_skip_file_type_param(self, runner, mocker: MockerFixture):
tool = mocker.MagicMock()
param = self.build_param(mocker, form=module.ToolParameter.ToolParameterForm.LLM)
param.type = module.ToolParameter.ToolParameterType.FILE
tool.get_runtime_parameters.return_value = [param]
prompt_tool = mocker.MagicMock()
prompt_tool.parameters = {"properties": {}, "required": []}
result = runner.update_prompt_message_tool(tool, prompt_tool)
assert result.parameters["properties"] == {}
def test_duplicate_required_not_duplicated(self, runner, mocker: MockerFixture):
tool = mocker.MagicMock()
param = self.build_param(
mocker,
form=module.ToolParameter.ToolParameterForm.LLM,
required=True,
)
tool.get_runtime_parameters.return_value = [param]
prompt_tool = mocker.MagicMock()
prompt_tool.parameters = {"properties": {}, "required": ["p1"]}
result = runner.update_prompt_message_tool(tool, prompt_tool)
assert result.parameters["required"].count("p1") == 1
assert result.parameters == schema
# ==========================================================
@ -383,57 +324,21 @@ class TestConvertToolToPromptMessageTool:
def test_basic_conversion(self, runner, mocker: MockerFixture):
tool = mocker.MagicMock(tool_name="tool1")
runtime_param = mocker.MagicMock()
runtime_param.form = module.ToolParameter.ToolParameterForm.LLM
runtime_param.name = "param1"
runtime_param.llm_description = "desc"
runtime_param.required = True
runtime_param.input_schema = None
runtime_param.options = None
mock_type = mocker.MagicMock()
mock_type.as_normal_type.return_value = "string"
runtime_param.type = mock_type
tool_entity = mocker.MagicMock()
tool_entity.entity.description.llm = "desc"
tool_entity.get_merged_runtime_parameters.return_value = [runtime_param]
schema = {
"type": "object",
"properties": {"param1": {"type": "string", "description": "desc"}},
"required": ["param1"],
}
tool_entity.get_llm_parameters_json_schema.return_value = schema
mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity)
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
prompt_tool, entity = runner._convert_tool_to_prompt_message_tool(tool)
assert entity == tool_entity
def test_full_conversion_multiple_params(self, runner, mocker: MockerFixture):
tool = mocker.MagicMock(tool_name="tool1")
# LLM param with input_schema override
param1 = mocker.MagicMock()
param1.form = module.ToolParameter.ToolParameterForm.LLM
param1.name = "p1"
param1.llm_description = "desc"
param1.required = True
param1.input_schema = {"type": "integer"}
param1.options = None
param1.type = mocker.MagicMock()
# SYSTEM_FILES param should be skipped
param2 = mocker.MagicMock()
param2.form = module.ToolParameter.ToolParameterForm.LLM
param2.name = "file_param"
param2.type = module.ToolParameter.ToolParameterType.SYSTEM_FILES
tool_entity = mocker.MagicMock()
tool_entity.entity.description.llm = "desc"
tool_entity.get_merged_runtime_parameters.return_value = [param1, param2]
mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity)
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
prompt_tool, entity = runner._convert_tool_to_prompt_message_tool(tool)
assert entity == tool_entity
assert prompt_tool.parameters == schema
# ==========================================================
@ -465,29 +370,6 @@ class TestInitPromptToolsExtended:
class TestAdditionalCoverage:
def test_update_prompt_with_input_schema(self, runner, mocker: MockerFixture):
tool = mocker.MagicMock()
param = mocker.MagicMock()
param.form = module.ToolParameter.ToolParameterForm.LLM
param.name = "p1"
param.required = False
param.llm_description = "desc"
param.options = None
param.input_schema = {"type": "number"}
mock_type = mocker.MagicMock()
mock_type.as_normal_type.return_value = "string"
param.type = mock_type
tool.get_runtime_parameters.return_value = [param]
prompt_tool = mocker.MagicMock()
prompt_tool.parameters = {"properties": {}, "required": []}
result = runner.update_prompt_message_tool(tool, prompt_tool)
assert result.parameters["properties"]["p1"]["type"] == "number"
def test_save_agent_thought_existing_labels(self, runner, mock_db_session, mocker: MockerFixture):
agent = mocker.MagicMock()
agent.tool = "tool1"
@ -571,34 +453,6 @@ class TestAdditionalCoverage:
result = runner.organize_agent_history([])
assert isinstance(result, list)
# ================= Additional Surgical Coverage =================
def test_convert_tool_select_enum_branch(self, runner, mocker: MockerFixture):
tool = mocker.MagicMock(tool_name="tool1")
param = mocker.MagicMock()
param.form = module.ToolParameter.ToolParameterForm.LLM
param.name = "select_param"
param.required = True
param.llm_description = "desc"
param.input_schema = None
option1 = mocker.MagicMock(value="A")
option2 = mocker.MagicMock(value="B")
param.options = [option1, option2]
param.type = module.ToolParameter.ToolParameterType.SELECT
tool_entity = mocker.MagicMock()
tool_entity.entity.description.llm = "desc"
tool_entity.get_merged_runtime_parameters.return_value = [param]
mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity)
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
prompt_tool, _ = runner._convert_tool_to_prompt_message_tool(tool)
assert prompt_tool is not None
class TestConvertDatasetRetrieverTool:
def test_required_param_added(self, runner, mocker: MockerFixture):
ds_tool = mocker.MagicMock()
@ -663,24 +517,6 @@ class TestBaseAgentRunnerInit:
class TestBaseAgentRunnerCoverage:
def test_convert_tool_skips_non_llm_param(self, runner, mocker: MockerFixture):
tool = mocker.MagicMock(tool_name="tool1")
param = mocker.MagicMock()
param.form = "NOT_LLM"
param.type = mocker.MagicMock()
tool_entity = mocker.MagicMock()
tool_entity.entity.description.llm = "desc"
tool_entity.get_merged_runtime_parameters.return_value = [param]
mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=tool_entity)
mocker.patch.object(module, "PromptMessageTool", side_effect=lambda **kw: MagicMock(**kw))
prompt_tool, _ = runner._convert_tool_to_prompt_message_tool(tool)
assert prompt_tool.parameters["properties"] == {}
def test_init_prompt_tools_adds_dataset_tools(self, runner, mocker: MockerFixture):
dataset_tool = mocker.MagicMock()
dataset_tool.entity.identity.name = "ds"
@ -693,30 +529,6 @@ class TestBaseAgentRunnerCoverage:
assert tools["ds"] == dataset_tool
assert len(prompt_tools) == 1
def test_update_prompt_message_tool_select_enum(self, runner, mocker: MockerFixture):
tool = mocker.MagicMock()
option1 = mocker.MagicMock(value="A")
option2 = mocker.MagicMock(value="B")
param = mocker.MagicMock()
param.form = module.ToolParameter.ToolParameterForm.LLM
param.name = "select_param"
param.required = False
param.llm_description = "desc"
param.input_schema = None
param.options = [option1, option2]
param.type = module.ToolParameter.ToolParameterType.SELECT
tool.get_runtime_parameters.return_value = [param]
prompt_tool = mocker.MagicMock()
prompt_tool.parameters = {"properties": {}, "required": []}
result = runner.update_prompt_message_tool(tool, prompt_tool)
assert result.parameters["properties"]["select_param"]["enum"] == ["A", "B"]
def test_save_agent_thought_json_dumps_fallbacks(self, runner, mock_db_session, mocker: MockerFixture):
agent = mocker.MagicMock()
agent.tool = "tool1"

View File

@ -8,7 +8,7 @@ from core.app.entities.app_invoke_entities import InvokeFrom
from core.tools.__base.tool import Tool
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolEntity, ToolIdentity, ToolInvokeMessage, ToolProviderType
from core.tools.entities.tool_entities import ToolEntity, ToolIdentity, ToolInvokeMessage, ToolParameter, ToolProviderType
class DummyCastType:
@ -25,6 +25,7 @@ class DummyParameter:
default: Any = None
options: list[Any] | None = None
llm_description: str | None = None
input_schema: dict[str, Any] | None = None
class DummyTool(Tool):
@ -149,13 +150,27 @@ def test_fork_tool_runtime_returns_new_tool_with_copied_entity():
def test_get_runtime_parameters_and_merge_runtime_parameters():
tool = _build_tool()
original = DummyParameter(name="temperature", type=DummyCastType(), form="schema", required=True, default="0.7")
original = DummyParameter(
name="temperature",
type=DummyCastType(),
form="schema",
required=True,
default="0.7",
input_schema={"type": "string"},
)
tool.entity.parameters = cast(Any, [original])
default_runtime_parameters = tool.get_runtime_parameters()
assert default_runtime_parameters == [original]
override = DummyParameter(name="temperature", type=DummyCastType(), form="llm", required=False, default="0.5")
override = DummyParameter(
name="temperature",
type=DummyCastType(),
form="llm",
required=False,
default="0.5",
input_schema={"type": "object"},
)
appended = DummyParameter(name="new_param", type=DummyCastType(), form="form", required=False, default="x")
tool.runtime_parameter_overrides = [override, appended]
@ -165,7 +180,93 @@ def test_get_runtime_parameters_and_merge_runtime_parameters():
assert merged[0].form == "llm"
assert merged[0].required is False
assert merged[0].default == "0.5"
assert merged[0].input_schema == {"type": "object"}
assert merged[1].name == "new_param"
assert merged[0] is not original
assert merged[1] is not appended
assert original.form == "schema"
assert original.required is True
assert original.default == "0.7"
assert original.input_schema == {"type": "string"}
def test_get_llm_parameters_json_schema_uses_effective_runtime_parameters():
tool = _build_tool()
query_parameter = ToolParameter.get_simple_instance(
name="query",
llm_description="Declared query",
typ=ToolParameter.ToolParameterType.STRING,
required=True,
)
region_parameter = ToolParameter.get_simple_instance(
name="region",
llm_description="Search region",
typ=ToolParameter.ToolParameterType.SELECT,
required=False,
options=["global", "cn"],
)
hidden_parameter = ToolParameter.get_simple_instance(
name="api_key",
llm_description="Hidden api key",
typ=ToolParameter.ToolParameterType.STRING,
required=True,
)
hidden_parameter.form = ToolParameter.ToolParameterForm.FORM
file_parameter = ToolParameter.get_simple_instance(
name="attachment",
llm_description="Attachment",
typ=ToolParameter.ToolParameterType.FILE,
required=False,
)
payload_parameter = ToolParameter(
name="payload",
label=I18nObject(en_US="payload", zh_Hans="payload"),
placeholder=None,
human_description=I18nObject(en_US="payload", zh_Hans="payload"),
type=ToolParameter.ToolParameterType.OBJECT,
form=ToolParameter.ToolParameterForm.LLM,
llm_description="Payload",
required=False,
input_schema={
"type": "object",
"properties": {"nested": {"type": "string"}},
},
)
tool.entity.parameters = [query_parameter, region_parameter, hidden_parameter, file_parameter, payload_parameter]
query_override = ToolParameter.get_simple_instance(
name="query",
llm_description="Runtime query",
typ=ToolParameter.ToolParameterType.STRING,
required=True,
)
tool.runtime_parameter_overrides = [query_override]
schema = tool.get_llm_parameters_json_schema()
assert schema == {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Runtime query"},
"region": {
"type": "string",
"description": "Search region",
"enum": ["global", "cn"],
},
"payload": {
"type": "object",
"properties": {"nested": {"type": "string"}},
"description": "Payload",
},
},
"required": ["query"],
}
schema["properties"]["payload"]["properties"]["nested"]["type"] = "number"
assert payload_parameter.input_schema == {
"type": "object",
"properties": {"nested": {"type": "string"}},
}
def test_message_factory_helpers():

View File

@ -52,18 +52,62 @@ async def main() -> None:
RunLayerSpec(
name="plugin",
type=DIFY_PLUGIN_LAYER_TYPE_ID,
config=DifyPluginLayerConfig(tenant_id=TENANT_ID, plugin_id=PLUGIN_ID),
config=DifyPluginLayerConfig(tenant_id=TENANT_ID),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
deps={"plugin": "plugin"},
config=DifyPluginLLMLayerConfig(
plugin_id=PLUGIN_ID,
model_provider=PLUGIN_PROVIDER,
model=MODEL_NAME,
credentials=MODEL_CREDENTIALS,
),
),
# Minimal plugin-tools example. API callers should pass
# prepared parameters + JSON schema instead of relying on
# dify-agent to fetch and merge daemon declarations.
# from dify_agent.layers.dify_plugin import (
# DifyPluginToolConfig,
# DifyPluginToolParameter,
# DifyPluginToolParameterForm,
# DifyPluginToolParameterType,
# DifyPluginToolsLayerConfig,
# )
# RunLayerSpec(
# name="tools",
# type="dify.plugin.tools",
# deps={"plugin": "plugin"},
# config=DifyPluginToolsLayerConfig(
# tools=[
# DifyPluginToolConfig(
# plugin_id="langgenius/search",
# provider="search",
# tool_name="web_search",
# credential_type="api-key",
# credentials={"api_key": "replace-with-tool-key"},
# runtime_parameters={"site": "docs.dify.ai"},
# parameters=[
# DifyPluginToolParameter(
# name="query",
# type=DifyPluginToolParameterType.STRING,
# form=DifyPluginToolParameterForm.LLM,
# required=True,
# llm_description="Search query",
# ),
# ],
# parameters_json_schema={
# "type": "object",
# "properties": {
# "query": {"type": "string", "description": "Search query"}
# },
# "required": ["query"],
# },
# )
# ]
# ),
# ),
],
),
)

View File

@ -45,18 +45,62 @@ def main() -> None:
RunLayerSpec(
name="plugin",
type=DIFY_PLUGIN_LAYER_TYPE_ID,
config=DifyPluginLayerConfig(tenant_id=TENANT_ID, plugin_id=PLUGIN_ID),
config=DifyPluginLayerConfig(tenant_id=TENANT_ID),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
deps={"plugin": "plugin"},
config=DifyPluginLLMLayerConfig(
plugin_id=PLUGIN_ID,
model_provider=PLUGIN_PROVIDER,
model=MODEL_NAME,
credentials=MODEL_CREDENTIALS,
),
),
# Minimal plugin-tools example. API callers should pass
# prepared parameters + JSON schema instead of relying on
# dify-agent to fetch and merge daemon declarations.
# from dify_agent.layers.dify_plugin import (
# DifyPluginToolConfig,
# DifyPluginToolParameter,
# DifyPluginToolParameterForm,
# DifyPluginToolParameterType,
# DifyPluginToolsLayerConfig,
# )
# RunLayerSpec(
# name="tools",
# type="dify.plugin.tools",
# deps={"plugin": "plugin"},
# config=DifyPluginToolsLayerConfig(
# tools=[
# DifyPluginToolConfig(
# plugin_id="langgenius/search",
# provider="search",
# tool_name="web_search",
# credential_type="api-key",
# credentials={"api_key": "replace-with-tool-key"},
# runtime_parameters={"site": "docs.dify.ai"},
# parameters=[
# DifyPluginToolParameter(
# name="query",
# type=DifyPluginToolParameterType.STRING,
# form=DifyPluginToolParameterForm.LLM,
# required=True,
# llm_description="Search query",
# ),
# ],
# parameters_json_schema={
# "type": "object",
# "properties": {
# "query": {"type": "string", "description": "Search query"}
# },
# "required": ["query"],
# },
# )
# ]
# ),
# ),
],
),
)

View File

@ -8,7 +8,6 @@ this provider.
from __future__ import annotations
import json
from collections.abc import AsyncIterator, Callable, Mapping
from dataclasses import dataclass, field
from typing import NoReturn
@ -22,6 +21,12 @@ from typing_extensions import override
from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, UnexpectedModelBehavior, UserError
from pydantic_ai.providers import Provider
from dify_agent.plugin_daemon_transport import (
decode_plugin_daemon_error_payload,
to_plugin_daemon_jsonable,
unwrap_plugin_daemon_error,
)
_DEFAULT_DAEMON_TIMEOUT: float | httpx.Timeout | None = 600.0
@ -83,7 +88,7 @@ class DifyPluginDaemonLLMClient:
request_data: Mapping[str, object],
response_model: type[T],
) -> AsyncIterator[T]:
payload: dict[str, object] = {"data": _to_jsonable(request_data)}
payload: dict[str, object] = {"data": to_plugin_daemon_jsonable(request_data)}
if self.user_id is not None:
payload["user_id"] = self.user_id
@ -97,14 +102,18 @@ class DifyPluginDaemonLLMClient:
async with self.http_client.stream("POST", url, headers=headers, json=payload) as response:
if response.is_error:
body = (await response.aread()).decode("utf-8", errors="replace")
error = _decode_plugin_daemon_error_payload(body)
error = decode_plugin_daemon_error_payload(body)
if error is not None:
_raise_plugin_daemon_error(
model_name=model_name,
resolved_error = unwrap_plugin_daemon_error(
error_type=error["error_type"],
message=error["message"],
)
_raise_plugin_daemon_error(
model_name=model_name,
error_type=resolved_error["error_type"],
message=resolved_error["message"],
status_code=response.status_code,
body=error,
body=resolved_error,
)
raise ModelHTTPError(response.status_code, model_name, body or None)
@ -117,13 +126,17 @@ class DifyPluginDaemonLLMClient:
wrapped = PluginDaemonBasicResponse.model_validate_json(line)
if wrapped.code != 0:
error = _decode_plugin_daemon_error_payload(wrapped.message)
error = decode_plugin_daemon_error_payload(wrapped.message)
if error is not None:
_raise_plugin_daemon_error(
model_name=model_name,
resolved_error = unwrap_plugin_daemon_error(
error_type=error["error_type"],
message=error["message"],
body=error,
)
_raise_plugin_daemon_error(
model_name=model_name,
error_type=resolved_error["error_type"],
message=resolved_error["message"],
body=resolved_error,
)
raise ModelAPIError(
model_name,
@ -199,32 +212,6 @@ class DifyPluginDaemonProvider(Provider[DifyPluginDaemonLLMClient]):
return self._client
def _to_jsonable(value: object) -> object:
if isinstance(value, BaseModel):
return value.model_dump(mode="json")
if isinstance(value, dict):
return {key: _to_jsonable(item) for key, item in value.items()}
if isinstance(value, list | tuple):
return [_to_jsonable(item) for item in value]
return value
def _decode_plugin_daemon_error_payload(raw_message: str) -> dict[str, str] | None:
try:
parsed = json.loads(raw_message)
except json.JSONDecodeError:
return None
if not isinstance(parsed, dict):
return None
error_type = parsed.get("error_type")
message = parsed.get("message")
if not isinstance(error_type, str) or not isinstance(message, str):
return None
return {"error_type": error_type, "message": message}
def _raise_plugin_daemon_error(
*,
model_name: str,
@ -236,17 +223,6 @@ def _raise_plugin_daemon_error(
http_error_body = body or {"error_type": error_type, "message": message}
match error_type:
case "PluginInvokeError":
nested_error = _decode_plugin_daemon_error_payload(message)
if nested_error is not None:
_raise_plugin_daemon_error(
model_name=model_name,
error_type=nested_error["error_type"],
message=nested_error["message"],
status_code=status_code,
body=nested_error,
)
raise ModelAPIError(model_name, message)
case "PluginDaemonUnauthorizedError" | "InvokeAuthorizationError":
raise ModelHTTPError(status_code or 401, model_name, http_error_body)
case "PluginPermissionDeniedError":

View File

@ -7,15 +7,33 @@ dependencies. Keep this package root import-safe for client-only installs.
from dify_agent.layers.dify_plugin.configs import (
DIFY_PLUGIN_LAYER_TYPE_ID,
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
DifyPluginCredentialValue,
DifyPluginLLMLayerConfig,
DifyPluginLayerConfig,
DifyPluginToolCredentialType,
DifyPluginToolConfig,
DifyPluginToolOption,
DifyPluginToolParameter,
DifyPluginToolParameterForm,
DifyPluginToolParameterType,
DifyPluginToolsLayerConfig,
DifyPluginToolValue,
)
__all__ = [
"DIFY_PLUGIN_LAYER_TYPE_ID",
"DIFY_PLUGIN_LLM_LAYER_TYPE_ID",
"DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID",
"DifyPluginCredentialValue",
"DifyPluginLLMLayerConfig",
"DifyPluginLayerConfig",
"DifyPluginToolCredentialType",
"DifyPluginToolConfig",
"DifyPluginToolOption",
"DifyPluginToolParameter",
"DifyPluginToolParameterForm",
"DifyPluginToolParameterType",
"DifyPluginToolsLayerConfig",
"DifyPluginToolValue",
]

View File

@ -5,34 +5,106 @@ aliases plus stable layer type identifiers. Runtime objects such as HTTP
clients, server settings, and adapter implementations live in sibling
implementation modules so clients can build run requests without importing
server-only dependencies.
The shared ``dify.plugin`` layer now carries only tenant/user daemon context.
Concrete plugin ids belong to the business layers that actually invoke daemon
features, namely the LLM and tools layers. Tool configs also carry the API-side
prepared parameter declarations and model-visible JSON schema so the agent
runtime does not have to re-fetch and re-merge tool declarations at execution
time.
"""
from typing import ClassVar, Final, TypeAlias
from enum import StrEnum
from typing import ClassVar, Final, Literal, TypeAlias
from pydantic import ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
from pydantic_ai.settings import ModelSettings
from agenton.layers import LayerConfig
DifyPluginCredentialValue: TypeAlias = str | int | float | bool | None
DifyPluginToolCredentialType: TypeAlias = Literal["api-key", "oauth2", "unauthorized"]
DifyPluginToolValue: TypeAlias = JsonValue
DIFY_PLUGIN_LAYER_TYPE_ID: Final[str] = "dify.plugin"
DIFY_PLUGIN_LLM_LAYER_TYPE_ID: Final[str] = "dify.plugin.llm"
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID: Final[str] = "dify.plugin.tools"
class DifyPluginToolOption(BaseModel):
"""Selectable tool option value exposed to the model."""
value: str
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@field_validator("value", mode="before")
@classmethod
def stringify_value(cls, value: object) -> str:
return value if isinstance(value, str) else str(value)
class DifyPluginToolParameterType(StrEnum):
STRING = "string"
NUMBER = "number"
BOOLEAN = "boolean"
SELECT = "select"
SECRET_INPUT = "secret-input"
FILE = "file"
FILES = "files"
APP_SELECTOR = "app-selector"
MODEL_SELECTOR = "model-selector"
ANY = "any"
DYNAMIC_SELECT = "dynamic-select"
CHECKBOX = "checkbox"
SYSTEM_FILES = "system-files"
ARRAY = "array"
OBJECT = "object"
def as_normal_type(self) -> str:
if self in {
DifyPluginToolParameterType.SECRET_INPUT,
DifyPluginToolParameterType.SELECT,
DifyPluginToolParameterType.CHECKBOX,
}:
return "string"
return self.value
class DifyPluginToolParameterForm(StrEnum):
SCHEMA = "schema"
FORM = "form"
LLM = "llm"
class DifyPluginToolParameter(BaseModel):
"""Prepared tool parameter declaration supplied by the API side."""
name: str
type: DifyPluginToolParameterType
form: DifyPluginToolParameterForm
required: bool = False
default: DifyPluginToolValue = None
llm_description: str | None = None
input_schema: dict[str, JsonValue] | None = None
options: list[DifyPluginToolOption] = Field(default_factory=list)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
class DifyPluginLayerConfig(LayerConfig):
"""Public config for the plugin daemon tenant/plugin context layer."""
"""Public config for the shared plugin daemon tenant/user context layer."""
tenant_id: str
plugin_id: str
user_id: str | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
class DifyPluginLLMLayerConfig(LayerConfig):
"""Public config for selecting a business provider/model from a plugin."""
"""Public config for selecting a plugin-backed business provider/model."""
plugin_id: str
model_provider: str
model: str
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
@ -41,10 +113,63 @@ class DifyPluginLLMLayerConfig(LayerConfig):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
class DifyPluginToolConfig(LayerConfig):
"""Public config for exposing one plugin tool to the agent model.
``credential_type`` is an explicit caller-supplied daemon transport choice,
not an auto-discovered property. It must match the actual credential mode of
``credentials`` for the configured plugin tool, for example ``"api-key"``
versus ``"oauth2"``. A wrong value can make invocation fail at runtime even
when the config itself validates successfully.
``runtime_parameters`` mirrors Dify's agent-node hidden/manual tool inputs:
those values are merged into the actual daemon invocation but omitted from
the tool schema shown to the model.
``parameters`` and ``parameters_json_schema`` are API-side prepared tool
declaration artifacts. They let the agent runtime validate hidden/default
inputs and expose the correct LLM-facing schema without re-fetching or
re-merging daemon declarations at run time.
"""
plugin_id: str
provider: str
tool_name: str
credential_type: DifyPluginToolCredentialType
name: str | None = None
description: str | None = None
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
runtime_parameters: dict[str, DifyPluginToolValue] = Field(default_factory=dict)
parameters: list[DifyPluginToolParameter] = Field(default_factory=list)
parameters_json_schema: dict[str, JsonValue] = Field(
default_factory=lambda: {"type": "object", "properties": {}, "required": []}
)
strict: bool | None = None
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
class DifyPluginToolsLayerConfig(LayerConfig):
"""Public config for the Dify plugin tools layer."""
tools: list[DifyPluginToolConfig] = Field(default_factory=list)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
__all__ = [
"DIFY_PLUGIN_LAYER_TYPE_ID",
"DIFY_PLUGIN_LLM_LAYER_TYPE_ID",
"DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID",
"DifyPluginCredentialValue",
"DifyPluginLLMLayerConfig",
"DifyPluginLayerConfig",
"DifyPluginToolCredentialType",
"DifyPluginToolConfig",
"DifyPluginToolOption",
"DifyPluginToolParameter",
"DifyPluginToolParameterForm",
"DifyPluginToolParameterType",
"DifyPluginToolsLayerConfig",
"DifyPluginToolValue",
]

View File

@ -1,12 +1,13 @@
"""Dify plugin LLM model layer.
This layer owns model capability resolution for Dify plugin-backed LLMs. It
depends on ``DifyPluginLayer`` for daemon identity through Agenton's direct
dependency binding and returns a Pydantic AI model adapter configured from the
public LLM layer DTO. Runtime code supplies the FastAPI lifespan-owned shared
HTTP client to ``get_model``; the layer does not own or discover live resources.
The daemon provider carries plugin transport identity, while the DTO's
``model_provider`` is passed to the adapter as request-level model identity.
depends on ``DifyPluginLayer`` for shared daemon settings through Agenton's
direct dependency binding and returns a Pydantic AI model adapter configured
from the public LLM layer DTO. Runtime code supplies the FastAPI
lifespan-owned shared HTTP client to ``get_model``; the layer does not own or
discover live resources. The daemon provider carries plugin transport identity,
while the DTO's ``model_provider`` is passed to the adapter as request-level
model identity.
"""
from dataclasses import dataclass
@ -42,7 +43,7 @@ class DifyPluginLLMLayer(PlainLayer[DifyPluginLLMDeps, DifyPluginLLMLayerConfig]
def get_model(self, *, http_client: httpx.AsyncClient) -> DifyLLMAdapterModel:
"""Return the configured model using the directly bound plugin dependency."""
provider = self.deps.plugin.create_daemon_provider(http_client=http_client)
provider = self.deps.plugin.create_daemon_provider(plugin_id=self.config.plugin_id, http_client=http_client)
return DifyLLMAdapterModel(
model=self.config.model,
daemon_provider=provider,

View File

@ -1,13 +1,13 @@
"""Runtime Dify plugin context layer.
The public config identifies tenant/plugin/user context only. Plugin daemon URL
and API key are server-side settings injected by the provider factory. The layer
is intentionally config/settings-only under Agenton's state-only core: it does
not open, cache, close, or snapshot HTTP clients, and its lifecycle hooks remain
the inherited no-op hooks. Runtime code passes the FastAPI lifespan-owned shared
``httpx.AsyncClient`` into ``create_daemon_provider`` for each model adapter.
Business model-provider names belong to the LLM layer/model request, not this
daemon context layer.
The public config identifies shared tenant/user daemon context only. Plugin
daemon URL and API key are server-side settings injected by the provider
factory. Concrete plugin ids belong to the LLM and tools layers that actually
invoke the daemon. The layer is intentionally config/settings-only under
Agenton's state-only core: it does not open, cache, close, or snapshot HTTP
clients, and its lifecycle hooks remain the inherited no-op hooks. Runtime code
passes the FastAPI lifespan-owned shared ``httpx.AsyncClient`` into
``create_daemon_provider`` or ``create_tool_client`` for each invocation.
"""
from dataclasses import dataclass
@ -18,6 +18,7 @@ from typing_extensions import Self, override
from agenton.layers import EmptyRuntimeState, NoLayerDeps, PlainLayer
from dify_agent.adapters.llm import DifyPluginDaemonProvider
from dify_agent.layers.dify_plugin.configs import DIFY_PLUGIN_LAYER_TYPE_ID, DifyPluginLayerConfig
from dify_agent.layers.dify_plugin.tool_client import DifyPluginDaemonToolClient
@dataclass(slots=True)
@ -48,7 +49,7 @@ class DifyPluginLayer(PlainLayer[NoLayerDeps, DifyPluginLayerConfig, EmptyRuntim
"""Create a plugin layer from public config plus server-only daemon settings."""
return cls(config=config, daemon_url=daemon_url, daemon_api_key=daemon_api_key)
def create_daemon_provider(self, *, http_client: httpx.AsyncClient) -> DifyPluginDaemonProvider:
def create_daemon_provider(self, *, plugin_id: str, http_client: httpx.AsyncClient) -> DifyPluginDaemonProvider:
"""Return a daemon provider backed by the shared plugin daemon client.
Raises:
@ -58,7 +59,24 @@ class DifyPluginLayer(PlainLayer[NoLayerDeps, DifyPluginLayerConfig, EmptyRuntim
raise RuntimeError("DifyPluginLayer.create_daemon_provider() requires an open shared HTTP client.")
return DifyPluginDaemonProvider(
tenant_id=self.config.tenant_id,
plugin_id=self.config.plugin_id,
plugin_id=plugin_id,
plugin_daemon_url=self.daemon_url,
plugin_daemon_api_key=self.daemon_api_key,
user_id=self.config.user_id,
http_client=http_client,
)
def create_tool_client(self, *, plugin_id: str, http_client: httpx.AsyncClient) -> DifyPluginDaemonToolClient:
"""Return a plugin-daemon tool client backed by the shared HTTP client.
Raises:
RuntimeError: if ``http_client`` has already been closed.
"""
if http_client.is_closed:
raise RuntimeError("DifyPluginLayer.create_tool_client() requires an open shared HTTP client.")
return DifyPluginDaemonToolClient(
tenant_id=self.config.tenant_id,
plugin_id=plugin_id,
plugin_daemon_url=self.daemon_url,
plugin_daemon_api_key=self.daemon_api_key,
user_id=self.config.user_id,

View File

@ -0,0 +1,328 @@
"""Async plugin-daemon client for Dify plugin tool invocation.
The agent runtime talks to the plugin daemon rather than importing provider SDKs
directly. The tools layer now consumes API-prepared declarations from config, so
this module only keeps the invoke-time boundary:
- POST ``/plugin/{tenant_id}/dispatch/tool/invoke``
- request headers ``X-Api-Key``, ``X-Plugin-ID``, and ``Content-Type``
- top-level ``user_id`` forwarding when shared plugin context includes one
- stream decoding and blob-chunk merging for agent observations
The shared plugin layer still owns tenant/user daemon context, while each tool's
own ``plugin_id`` determines the transport identity placed in ``X-Plugin-ID``.
"""
from __future__ import annotations
import base64
from collections.abc import AsyncIterator, Mapping
from dataclasses import dataclass, field
from enum import StrEnum
import httpx
from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator
from dify_agent.layers.dify_plugin.configs import DifyPluginToolCredentialType
from dify_agent.plugin_daemon_transport import (
decode_plugin_daemon_error_payload,
to_plugin_daemon_jsonable,
unwrap_plugin_daemon_error,
)
class PluginDaemonBasicResponse(BaseModel):
"""Common plugin-daemon stream and JSON wrapper."""
code: int
message: str
data: object | None = None
@dataclass(slots=True)
class FileChunk:
"""Buffer for accumulating streamed blob chunks."""
total_length: int
bytes_written: int = field(default=0, init=False)
data: bytearray = field(init=False)
def __post_init__(self) -> None:
self.data = bytearray(self.total_length)
class DifyPluginToolInvokeMessage(BaseModel):
"""Subset of Dify tool stream messages needed for agent observations."""
class TextMessage(BaseModel):
text: str
class JsonMessage(BaseModel):
json_object: dict[str, object] | list[object]
suppress_output: bool = False
class BlobMessage(BaseModel):
blob: bytes
class BlobChunkMessage(BaseModel):
id: str
sequence: int
total_length: int
blob: bytes
end: bool
class FileMessage(BaseModel):
file_marker: str = "file_marker"
@model_validator(mode="before")
@classmethod
def validate_file_marker(cls, values: object) -> object:
if isinstance(values, dict) and "file_marker" not in values:
raise ValueError("Invalid FileMessage: missing file_marker")
return values
class VariableMessage(BaseModel):
variable_name: str
variable_value: object
stream: bool = False
class LogMessage(BaseModel):
id: str
label: str
parent_id: str | None = None
error: str | None = None
status: str
data: Mapping[str, object] = Field(default_factory=dict)
metadata: Mapping[str, object] = Field(default_factory=dict)
class MessageType(StrEnum):
TEXT = "text"
IMAGE = "image"
LINK = "link"
BLOB = "blob"
JSON = "json"
IMAGE_LINK = "image_link"
BINARY_LINK = "binary_link"
VARIABLE = "variable"
FILE = "file"
LOG = "log"
BLOB_CHUNK = "blob_chunk"
type: MessageType = MessageType.TEXT
message: TextMessage | JsonMessage | BlobChunkMessage | BlobMessage | LogMessage | FileMessage | VariableMessage | None
meta: dict[str, object] | None = None
@field_validator("message", mode="before")
@classmethod
def decode_message(cls, value: object, info: ValidationInfo) -> object:
if isinstance(value, dict) and "blob" in value:
try:
value = {**value, "blob": base64.b64decode(value["blob"])}
except Exception:
return value
msg_type = info.data.get("type") if isinstance(info.data, dict) else None
if msg_type == cls.MessageType.JSON and isinstance(value, dict) and "json_object" not in value:
return {"json_object": value}
if msg_type == cls.MessageType.FILE and isinstance(value, dict):
return {"file_marker": value.get("file_marker", "file_marker")}
return value
class DifyPluginToolClientError(Exception):
"""Raised when the plugin daemon rejects a tool-layer request."""
error_type: str | None
status_code: int | None
def __init__(self, message: str, *, error_type: str | None = None, status_code: int | None = None) -> None:
super().__init__(message)
self.error_type = error_type
self.status_code = status_code
@dataclass(slots=True)
class DifyPluginDaemonToolClient:
"""HTTP wrapper for the invoke-only plugin-daemon tool boundary.
Callers provide business-level provider/tool/credential data per invocation,
while this client supplies daemon transport identity from shared runtime
context: tenant path segment, daemon API key, plugin-specific ``X-Plugin-ID``
header, and optional top-level ``user_id``.
"""
plugin_daemon_url: str
plugin_daemon_api_key: str
tenant_id: str
plugin_id: str
user_id: str | None
http_client: httpx.AsyncClient = field(repr=False)
def __post_init__(self) -> None:
self.plugin_daemon_url = self.plugin_daemon_url.rstrip("/")
async def invoke(
self,
*,
provider: str,
tool_name: str,
credential_type: DifyPluginToolCredentialType,
credentials: dict[str, object],
tool_parameters: Mapping[str, object],
) -> list[DifyPluginToolInvokeMessage]:
"""Invoke a plugin tool and collect its observation stream."""
raw_messages = [
item
async for item in self._iter_stream_response(
path=f"plugin/{self.tenant_id}/dispatch/tool/invoke",
request_data={
"provider": provider,
"tool": tool_name,
"credentials": credentials,
"credential_type": credential_type,
"tool_parameters": dict(tool_parameters),
},
response_model=DifyPluginToolInvokeMessage,
)
]
return merge_blob_chunks(raw_messages)
async def _iter_stream_response[T: BaseModel](
self,
*,
path: str,
request_data: Mapping[str, object],
response_model: type[T],
) -> AsyncIterator[T]:
"""Send one daemon stream request and yield typed items.
The daemon expects the actual invoke payload nested under ``data``. When
the shared plugin context included ``user_id``, it is forwarded as a
top-level peer to ``data`` so daemon-side auditing and credential logic
can attribute the request to the end user.
"""
payload: dict[str, object] = {"data": to_plugin_daemon_jsonable(dict(request_data))}
if self.user_id is not None:
payload["user_id"] = self.user_id
url = f"{self.plugin_daemon_url}/{path}"
async with self.http_client.stream("POST", url, headers=self._headers(), json=payload) as response:
if response.is_error:
body = (await response.aread()).decode("utf-8", errors="replace")
error = decode_plugin_daemon_error_payload(body)
if error is not None:
resolved_error = unwrap_plugin_daemon_error(
error_type=error["error_type"],
message=error["message"],
)
_raise_tool_daemon_error(
error_type=resolved_error["error_type"],
message=resolved_error["message"],
status_code=response.status_code,
)
raise DifyPluginToolClientError(body or "Plugin daemon stream request failed.", status_code=response.status_code)
async for raw_line in response.aiter_lines():
line = raw_line.strip()
if not line:
continue
if line.startswith("data:"):
line = line[5:].strip()
wrapped = PluginDaemonBasicResponse.model_validate_json(line)
if wrapped.code != 0:
error = decode_plugin_daemon_error_payload(wrapped.message)
if error is not None:
resolved_error = unwrap_plugin_daemon_error(
error_type=error["error_type"],
message=error["message"],
)
_raise_tool_daemon_error(
error_type=resolved_error["error_type"],
message=resolved_error["message"],
)
raise DifyPluginToolClientError(wrapped.message or "Plugin daemon returned an error stream item.")
if wrapped.data is None:
raise DifyPluginToolClientError("Plugin daemon returned an empty stream item.")
yield response_model.model_validate(wrapped.data)
def _headers(self) -> dict[str, str]:
"""Build required plugin-daemon transport headers for tool invocation."""
return {
"X-Api-Key": self.plugin_daemon_api_key,
"X-Plugin-ID": self.plugin_id,
"Content-Type": "application/json",
}
def merge_blob_chunks(
response: list[DifyPluginToolInvokeMessage],
*,
max_file_size: int = 30 * 1024 * 1024,
max_chunk_size: int = 8192,
) -> list[DifyPluginToolInvokeMessage]:
"""Merge streamed blob chunks into complete blob messages.
This mirrors Dify API's plugin-daemon chunk-merging behavior before the
higher-level observation conversion logic sees tool stream messages.
"""
files: dict[str, FileChunk] = {}
merged_messages: list[DifyPluginToolInvokeMessage] = []
for resp in response:
if resp.type is DifyPluginToolInvokeMessage.MessageType.BLOB_CHUNK:
if not isinstance(resp.message, DifyPluginToolInvokeMessage.BlobChunkMessage):
raise TypeError("Blob chunk responses must carry BlobChunkMessage payloads.")
chunk_id = resp.message.id
total_length = resp.message.total_length
blob_data = resp.message.blob
is_end = resp.message.end
if chunk_id not in files:
files[chunk_id] = FileChunk(total_length)
if files[chunk_id].bytes_written + len(blob_data) > max_file_size:
del files[chunk_id]
raise ValueError(f"File is too large which reached the limit of {max_file_size / 1024 / 1024}MB")
if len(blob_data) > max_chunk_size:
raise ValueError(f"File chunk is too large which reached the limit of {max_chunk_size / 1024}KB")
files[chunk_id].data[files[chunk_id].bytes_written : files[chunk_id].bytes_written + len(blob_data)] = (
blob_data
)
files[chunk_id].bytes_written += len(blob_data)
if is_end:
merged_messages.append(
DifyPluginToolInvokeMessage(
type=DifyPluginToolInvokeMessage.MessageType.BLOB,
message=DifyPluginToolInvokeMessage.BlobMessage(
blob=bytes(files[chunk_id].data[: files[chunk_id].bytes_written])
),
meta=resp.meta,
)
)
del files[chunk_id]
else:
merged_messages.append(resp)
return merged_messages
def _raise_tool_daemon_error(
*,
error_type: str,
message: str,
status_code: int | None = None,
) -> None:
raise DifyPluginToolClientError(message, error_type=error_type, status_code=status_code)
__all__ = [
"DifyPluginDaemonToolClient",
"DifyPluginToolClientError",
"DifyPluginToolCredentialType",
"DifyPluginToolInvokeMessage",
"merge_blob_chunks",
]

View File

@ -0,0 +1,331 @@
"""Dify plugin tools layer for agent-accessible plugin tools.
This layer consumes API-prepared plugin tool declarations. The API side is
responsible for resolving daemon declarations, applying runtime-parameter
overrides, and producing the clean LLM-facing JSON schema. At run time the layer
only validates hidden/manual inputs, prepares invocation arguments, and maps
daemon responses into agent-friendly observations.
Like the LLM layer, this layer never owns live HTTP clients. The runtime passes
the FastAPI lifespan-owned shared client into ``get_tools`` so the layer can
build Pydantic AI tool adapters on demand.
"""
from __future__ import annotations
from copy import deepcopy
import json
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
import httpx
from pydantic_ai import RunContext, Tool
from pydantic_ai.tools import ToolDefinition
from typing_extensions import Self, override
from agenton.layers import LayerDeps, PlainLayer
from dify_agent.layers.dify_plugin.configs import (
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
DifyPluginToolConfig,
DifyPluginToolParameter,
DifyPluginToolParameterForm,
DifyPluginToolParameterType,
DifyPluginToolsLayerConfig,
)
from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer
from dify_agent.layers.dify_plugin.tool_client import (
DifyPluginDaemonToolClient,
DifyPluginToolClientError,
DifyPluginToolInvokeMessage,
)
class DifyPluginToolsDeps(LayerDeps):
"""Dependencies required by ``DifyPluginToolsLayer``."""
plugin: DifyPluginLayer # pyright: ignore[reportUninitializedInstanceVariable]
@dataclass(slots=True)
class DifyPluginToolsLayer(PlainLayer[DifyPluginToolsDeps, DifyPluginToolsLayerConfig]):
"""Layer that resolves Dify plugin tools into Pydantic AI tools."""
type_id = DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID
config: DifyPluginToolsLayerConfig
@classmethod
@override
def from_config(cls, config: DifyPluginToolsLayerConfig) -> Self:
"""Create the tools layer from validated public config."""
return cls(config=DifyPluginToolsLayerConfig.model_validate(config))
async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
"""Build Pydantic AI tool adapters from prepared plugin tool config."""
tool_clients: dict[str, DifyPluginDaemonToolClient] = {}
tools: list[Tool[object]] = []
for tool_config in self.config.tools:
client = tool_clients.get(tool_config.plugin_id)
if client is None:
client = self.deps.plugin.create_tool_client(plugin_id=tool_config.plugin_id, http_client=http_client)
tool_clients[tool_config.plugin_id] = client
effective_parameters = [parameter.model_copy(deep=True) for parameter in tool_config.parameters]
_validate_required_hidden_parameters(tool_config, effective_parameters)
tools.append(
_build_pydantic_ai_tool(
client=client,
tool_config=tool_config,
effective_parameters=effective_parameters,
)
)
return tools
def _validate_required_hidden_parameters(
tool_config: DifyPluginToolConfig,
effective_parameters: Sequence[DifyPluginToolParameter],
) -> None:
missing_names = [
parameter.name
for parameter in effective_parameters
if parameter.form is not DifyPluginToolParameterForm.LLM
and parameter.required
and parameter.default is None
and parameter.name not in tool_config.runtime_parameters
]
if missing_names:
names = ", ".join(sorted(missing_names))
raise ValueError(
f"Tool '{tool_config.tool_name}' requires non-LLM runtime_parameters for: {names}."
)
def _build_pydantic_ai_tool(
*,
client: DifyPluginDaemonToolClient,
tool_config: DifyPluginToolConfig,
effective_parameters: Sequence[DifyPluginToolParameter],
) -> Tool[object]:
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)
async def invoke_tool(_ctx: RunContext[object], **tool_arguments: object) -> str:
try:
merged_arguments = _prepare_tool_arguments(effective_parameters, tool_config, tool_arguments)
messages = await client.invoke(
provider=tool_config.provider,
tool_name=tool_config.tool_name,
credential_type=tool_config.credential_type,
credentials=dict(tool_config.credentials),
tool_parameters=merged_arguments,
)
return _convert_tool_response_to_text(messages)
except DifyPluginToolClientError as exc:
return _tool_error_text(tool_name=tool_name, error=exc)
except ValueError as exc:
return f"tool parameters validation error: {exc}, please check your tool parameters"
async def prepare_tool_definition(_ctx: RunContext[object], tool_def: ToolDefinition) -> ToolDefinition:
return ToolDefinition(
name=tool_def.name,
description=tool_def.description,
parameters_json_schema=tool_schema,
strict=tool_config.strict,
sequential=tool_def.sequential,
metadata=tool_def.metadata,
timeout=tool_def.timeout,
defer_loading=tool_def.defer_loading,
kind=tool_def.kind,
return_schema=tool_def.return_schema,
include_return_schema=tool_def.include_return_schema,
)
return Tool(
invoke_tool,
takes_ctx=True,
name=tool_name,
description=tool_description,
prepare=prepare_tool_definition,
strict=tool_config.strict,
)
def _prepare_tool_arguments(
effective_parameters: Sequence[DifyPluginToolParameter],
tool_config: DifyPluginToolConfig,
tool_arguments: Mapping[str, object],
) -> dict[str, object]:
"""Build the daemon invocation payload from prepared config + model args.
Argument precedence intentionally mirrors the old Dify tool runtime contract:
1. start from config-supplied ``runtime_parameters`` for hidden/manual inputs;
2. let model-supplied tool arguments override same-named entries;
3. if neither provided a value, fall back to the prepared parameter default;
4. if a required parameter still has no value, raise validation error.
Only parameters declared in ``effective_parameters`` are type-cast here;
extra merged keys are passed through unchanged for forward compatibility with
prepared config that may contain additional daemon inputs.
"""
merged_arguments: dict[str, object] = dict(tool_config.runtime_parameters)
merged_arguments.update(tool_arguments)
prepared_arguments: dict[str, object] = {}
for parameter in effective_parameters:
if parameter.name in merged_arguments:
value = merged_arguments[parameter.name]
elif parameter.default is not None:
value = parameter.default
elif parameter.required:
raise ValueError(f"tool parameter {parameter.name} not found in tool config")
else:
continue
prepared_arguments[parameter.name] = _cast_tool_parameter_value(parameter.type, value)
for key, value in merged_arguments.items():
prepared_arguments.setdefault(key, value)
return prepared_arguments
def _cast_tool_parameter_value(parameter_type: DifyPluginToolParameterType, value: object) -> object:
"""Cast prepared tool argument values into daemon-facing wire shapes.
The API side prepares declaration metadata, but the actual invocation payload
still needs to match Dify plugin-daemon expectations. This helper keeps the
runtime-side coercion rules for common scalar, collection, file, and selector
parameter types so model-supplied JSON values and config-supplied hidden
inputs are normalized before transport.
"""
match parameter_type:
case (
DifyPluginToolParameterType.STRING
| DifyPluginToolParameterType.SECRET_INPUT
| DifyPluginToolParameterType.SELECT
| DifyPluginToolParameterType.CHECKBOX
| DifyPluginToolParameterType.DYNAMIC_SELECT
):
return "" if value is None else value if isinstance(value, str) else str(value)
case DifyPluginToolParameterType.BOOLEAN:
if value is None:
return False
if isinstance(value, str):
lowered = value.lower()
if lowered in {"true", "yes", "y", "1"}:
return True
if lowered in {"false", "no", "n", "0"}:
return False
return value if isinstance(value, bool) else bool(value)
case DifyPluginToolParameterType.NUMBER:
if isinstance(value, int | float):
return value
if isinstance(value, str) and value:
return float(value) if "." in value else int(value)
return value
case DifyPluginToolParameterType.SYSTEM_FILES | DifyPluginToolParameterType.FILES:
return value if isinstance(value, list) else [value]
case DifyPluginToolParameterType.FILE:
if isinstance(value, list):
if len(value) != 1:
raise ValueError("This parameter only accepts one file but got multiple files while invoking.")
return value[0]
return value
case DifyPluginToolParameterType.MODEL_SELECTOR | DifyPluginToolParameterType.APP_SELECTOR:
if not isinstance(value, dict):
raise ValueError("The selector must be a dictionary.")
return value
case DifyPluginToolParameterType.ANY:
if value is not None and not isinstance(value, dict | list | str | int | float | bool):
raise ValueError("The var selector must be a string, dictionary, list or number.")
return value
case DifyPluginToolParameterType.ARRAY:
if isinstance(value, list):
return value
if isinstance(value, str):
try:
parsed_value = json.loads(value)
except json.JSONDecodeError:
return [value]
if isinstance(parsed_value, list):
return parsed_value
return [value]
case DifyPluginToolParameterType.OBJECT:
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed_value = json.loads(value)
except json.JSONDecodeError:
return {}
if isinstance(parsed_value, dict):
return parsed_value
return {}
raise AssertionError(f"Unsupported tool parameter type: {parameter_type}")
def _tool_error_text(*, tool_name: str, error: DifyPluginToolClientError) -> str:
"""Map expected daemon/tool failures into agent-visible observation text.
Only known plugin-daemon rejection categories should be softened into tool
observations. Unexpected local bugs are intentionally not handled here and
should propagate so tests and callers notice the regression.
"""
error_type = error.error_type or ""
if any(token in error_type for token in ("Credential", "Authorization", "Unauthorized")):
return "Please check your tool provider credentials"
if any(token in error_type for token in ("ToolNotFound", "ProviderNotFound")):
return f"there is not a tool named {tool_name}"
if error.status_code == 400 or any(token in error_type for token in ("BadRequest", "Validate", "Validation")):
return f"tool parameters validation error: {error}, please check your tool parameters"
return f"tool invoke error: {error}"
def _convert_tool_response_to_text(tool_response: Sequence[DifyPluginToolInvokeMessage]) -> str:
"""Convert daemon stream messages into the plain-text tool observation.
This preserves the user-facing semantics Dify's agent tool runtime relies on:
text is appended directly, links/images become user-check instructions, JSON
output is included unless explicitly suppressed, variable messages stay
internal, and everything else falls back to ``str(message)``. JSON fragments
are deduplicated against existing text so mixed text/JSON streams do not
repeat the same content unnecessarily.
"""
parts: list[str] = []
json_parts: list[str] = []
for response in tool_response:
if response.type is DifyPluginToolInvokeMessage.MessageType.TEXT:
text_message = response.message
if isinstance(text_message, DifyPluginToolInvokeMessage.TextMessage):
parts.append(text_message.text)
elif response.type is DifyPluginToolInvokeMessage.MessageType.LINK:
link_message = response.message
if isinstance(link_message, DifyPluginToolInvokeMessage.TextMessage):
parts.append(f"result link: {link_message.text}. please tell user to check it.")
elif response.type in {
DifyPluginToolInvokeMessage.MessageType.IMAGE_LINK,
DifyPluginToolInvokeMessage.MessageType.IMAGE,
}:
parts.append(
"image has been created and sent to user already, "
"you do not need to create it, just tell the user to check it now."
)
elif response.type is DifyPluginToolInvokeMessage.MessageType.JSON:
json_message = response.message
if isinstance(json_message, DifyPluginToolInvokeMessage.JsonMessage) and not json_message.suppress_output:
json_parts.append(json.dumps(json_message.json_object, ensure_ascii=False, default=str))
elif response.type is DifyPluginToolInvokeMessage.MessageType.VARIABLE:
continue
else:
parts.append(str(response.message))
if json_parts:
existing_parts = set(parts)
parts.extend(part for part in json_parts if part not in existing_parts)
return "".join(parts)
__all__ = ["DifyPluginToolsDeps", "DifyPluginToolsLayer"]

View File

@ -0,0 +1,72 @@
"""Shared plugin-daemon transport helpers.
These helpers define the common request-payload and nested-error semantics used
by Dify Agent's LLM and tools daemon clients so the two transport adapters do
not drift when the daemon protocol evolves.
"""
from __future__ import annotations
import json
from typing import TypedDict
from pydantic import BaseModel
class PluginDaemonErrorPayload(TypedDict):
"""Decoded plugin-daemon error payload."""
error_type: str
message: str
def to_plugin_daemon_jsonable(value: object) -> object:
"""Convert nested request data into JSON-safe daemon payload values."""
if isinstance(value, BaseModel):
return value.model_dump(mode="json")
if isinstance(value, dict):
return {key: to_plugin_daemon_jsonable(item) for key, item in value.items()}
if isinstance(value, list | tuple):
return [to_plugin_daemon_jsonable(item) for item in value]
return value
def decode_plugin_daemon_error_payload(raw_message: str) -> PluginDaemonErrorPayload | None:
"""Decode one plugin-daemon JSON error payload if present."""
try:
parsed = json.loads(raw_message)
except json.JSONDecodeError:
return None
if not isinstance(parsed, dict):
return None
error_type = parsed.get("error_type")
message = parsed.get("message")
if not isinstance(error_type, str) or not isinstance(message, str):
return None
return {"error_type": error_type, "message": message}
def unwrap_plugin_daemon_error(
*,
error_type: str,
message: str,
) -> PluginDaemonErrorPayload:
"""Unwrap nested ``PluginInvokeError`` payloads to their effective error."""
if error_type == "PluginInvokeError":
nested_error = decode_plugin_daemon_error_payload(message)
if nested_error is not None:
return unwrap_plugin_daemon_error(
error_type=nested_error["error_type"],
message=nested_error["message"],
)
return {"error_type": error_type, "message": message}
__all__ = [
"PluginDaemonErrorPayload",
"decode_plugin_daemon_error_payload",
"to_plugin_daemon_jsonable",
"unwrap_plugin_daemon_error",
]

View File

@ -2,12 +2,17 @@
Only explicitly allowed provider type ids are constructible here. The default
provider set contains prompt layers, the optional pydantic-ai history layer, the
state-free Dify structured output layer, plus Dify plugin LLM layers. Public
DTOs provide tenant/plugin/model data, while server-only plugin daemon settings
are injected through the provider factory for ``DifyPluginLayer``. The resulting
``Compositor`` remains Agenton state-only: live resources such as the plugin
daemon HTTP client are supplied later by the runtime and never enter providers,
layers, or session snapshots.
state-free Dify structured output layer, and the Dify plugin layer family:
- ``dify.plugin`` for shared tenant/user daemon context,
- ``dify.plugin.llm`` for plugin-backed model selection, and
- ``dify.plugin.tools`` for prepared plugin tool exposure.
Public DTOs provide tenant/plugin/model/tool data, while server-only plugin
daemon settings are injected through the provider factory for
``DifyPluginLayer``. The resulting ``Compositor`` remains Agenton state-only:
live resources such as the plugin daemon HTTP client are supplied later by the
runtime and never enter providers, layers, or session snapshots.
"""
from collections.abc import Mapping, Sequence
@ -23,6 +28,7 @@ from agenton_collections.transformers.pydantic_ai import PYDANTIC_AI_TRANSFORMER
from dify_agent.layers.dify_plugin.configs import DifyPluginLayerConfig
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
from dify_agent.layers.output.output_layer import DifyOutputLayer
@ -48,6 +54,7 @@ def create_default_layer_providers(
),
),
LayerProvider.from_layer_type(DifyPluginLLMLayer),
LayerProvider.from_layer_type(DifyPluginToolsLayer),
)

View File

@ -21,14 +21,17 @@ snapshot; there are no separate output or snapshot events to correlate.
"""
from collections.abc import AsyncIterable
from typing import cast
from collections import Counter
from typing import Any, cast
import httpx
from pydantic import JsonValue, TypeAdapter
from pydantic_ai.messages import AgentStreamEvent
from agenton.compositor import CompositorSessionSnapshot, LayerProviderInput
from agenton.layers.types import PydanticAITool
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
from dify_agent.protocol.schemas import DIFY_AGENT_MODEL_LAYER_ID, CreateRunRequest, normalize_composition
from dify_agent.runtime.agent_factory import create_agent, normalize_user_input
from dify_agent.runtime.agenton_validation import is_agenton_enter_validation_runtime_error
@ -149,12 +152,13 @@ class AgentRunRunner:
)
llm_layer = run.get_layer(DIFY_AGENT_MODEL_LAYER_ID, DifyPluginLLMLayer)
model = llm_layer.get_model(http_client=self.plugin_daemon_http_client)
tools = await _resolve_run_tools(run, http_client=self.plugin_daemon_http_client)
except (KeyError, TypeError, RuntimeError, ValueError) as exc:
raise AgentRunValidationError(str(exc)) from exc
agent = create_agent(
model,
tools=run.tools,
tools=tools,
output_type=output_contract.output_type,
)
result = await agent.run(
@ -180,4 +184,27 @@ def _serialize_agent_output(output: object) -> JsonValue:
return cast(JsonValue, _AGENT_OUTPUT_ADAPTER.dump_python(output, mode="json"))
async def _resolve_run_tools(
run: Any,
*,
http_client: httpx.AsyncClient,
) -> list[PydanticAITool[object]]:
"""Return the static compositor tools plus any Dify plugin runtime tools."""
resolved_tools = list(cast(list[PydanticAITool[object]], run.tools))
for slot in run.slots.values():
layer = slot.layer
if isinstance(layer, DifyPluginToolsLayer):
resolved_tools.extend(await layer.get_tools(http_client=http_client))
_validate_unique_tool_names(resolved_tools)
return resolved_tools
def _validate_unique_tool_names(tools: list[PydanticAITool[object]]) -> None:
"""Reject duplicate tool names across static and dynamic tool sources."""
duplicate_names = sorted(name for name, count in Counter(tool.name for tool in tools).items() if count > 1)
if duplicate_names:
names = ", ".join(duplicate_names)
raise ValueError(f"Agent run requires unique tool names across all layers, got duplicates: {names}.")
__all__ = ["AgentRunRunner", "AgentRunValidationError"]

View File

@ -6,6 +6,13 @@ from dify_agent.layers.dify_plugin import (
DifyPluginCredentialValue,
DifyPluginLLMLayerConfig,
DifyPluginLayerConfig,
DifyPluginToolCredentialType,
DifyPluginToolConfig,
DifyPluginToolParameter,
DifyPluginToolParameterForm,
DifyPluginToolParameterType,
DifyPluginToolsLayerConfig,
DifyPluginToolValue,
)
@ -13,27 +20,35 @@ def test_dify_plugin_package_exports_client_safe_config_symbols_only() -> None:
assert dify_plugin_exports.__all__ == [
"DIFY_PLUGIN_LAYER_TYPE_ID",
"DIFY_PLUGIN_LLM_LAYER_TYPE_ID",
"DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID",
"DifyPluginCredentialValue",
"DifyPluginLLMLayerConfig",
"DifyPluginLayerConfig",
"DifyPluginToolCredentialType",
"DifyPluginToolConfig",
"DifyPluginToolOption",
"DifyPluginToolParameter",
"DifyPluginToolParameterForm",
"DifyPluginToolParameterType",
"DifyPluginToolsLayerConfig",
"DifyPluginToolValue",
]
assert dify_plugin_exports.DIFY_PLUGIN_LAYER_TYPE_ID == "dify.plugin"
assert dify_plugin_exports.DIFY_PLUGIN_LLM_LAYER_TYPE_ID == "dify.plugin.llm"
assert dify_plugin_exports.DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID == "dify.plugin.tools"
assert not hasattr(dify_plugin_exports, "DifyPluginLayer")
assert not hasattr(dify_plugin_exports, "DifyPluginLLMLayer")
def test_dify_plugin_layer_config_forbids_runtime_settings() -> None:
config = DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="plugin-1", user_id="user-1")
config = DifyPluginLayerConfig(tenant_id="tenant-1", user_id="user-1")
assert config.tenant_id == "tenant-1"
assert config.plugin_id == "plugin-1"
assert config.user_id == "user-1"
with pytest.raises(ValidationError):
_ = DifyPluginLayerConfig.model_validate(
{
"tenant_id": "tenant-1",
"plugin_id": "plugin-1",
"daemon_url": "http://daemon",
}
)
@ -42,18 +57,21 @@ def test_dify_plugin_layer_config_forbids_runtime_settings() -> None:
def test_dify_plugin_llm_config_accepts_scalar_credentials_and_model_settings() -> None:
credential: DifyPluginCredentialValue = "secret"
config = DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="gpt-4o-mini",
credentials={"api_key": credential, "enabled": True, "retries": 2, "ratio": 0.5, "empty": None},
model_settings={"temperature": 0.2, "max_tokens": 64},
)
assert config.plugin_id == "langgenius/openai"
assert config.model_provider == "openai"
assert config.credentials == {"api_key": "secret", "enabled": True, "retries": 2, "ratio": 0.5, "empty": None}
assert config.model_settings == {"temperature": 0.2, "max_tokens": 64}
with pytest.raises(ValidationError):
_ = DifyPluginLLMLayerConfig.model_validate(
{
"plugin_id": "langgenius/openai",
"model_provider": "openai",
"model": "gpt-4o-mini",
"credentials": {"nested": {"not": "allowed"}},
@ -66,6 +84,90 @@ def test_dify_plugin_llm_config_rejects_old_provider_field() -> None:
_ = DifyPluginLLMLayerConfig.model_validate(
{
"provider": "openai",
"plugin_id": "langgenius/openai",
"model": "gpt-4o-mini",
}
)
def test_dify_plugin_tools_layer_config_accepts_prepared_parameters_and_schema() -> None:
runtime_value: DifyPluginToolValue = {"locale": "en-US", "max_results": 5}
credential_type: DifyPluginToolCredentialType = "api-key"
config = DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/tools",
provider="search",
tool_name="web_search",
credential_type=credential_type,
name="search_web",
description="Search the web.",
credentials={"api_key": "secret"},
runtime_parameters={"settings": runtime_value},
parameters=[
DifyPluginToolParameter(
name="query",
type=DifyPluginToolParameterType.STRING,
form=DifyPluginToolParameterForm.LLM,
required=True,
llm_description="Search query",
)
],
parameters_json_schema={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
},
"required": ["query"],
},
strict=True,
)
]
)
assert config.tools[0].plugin_id == "langgenius/tools"
assert config.tools[0].provider == "search"
assert config.tools[0].tool_name == "web_search"
assert config.tools[0].credential_type == "api-key"
assert config.tools[0].name == "search_web"
assert config.tools[0].runtime_parameters == {"settings": {"locale": "en-US", "max_results": 5}}
assert config.tools[0].parameters[0].name == "query"
assert config.tools[0].parameters_json_schema["required"] == ["query"]
assert config.tools[0].strict is True
def test_dify_plugin_tool_config_rejects_non_json_runtime_parameters() -> None:
with pytest.raises(ValidationError):
_ = DifyPluginToolConfig.model_validate(
{
"plugin_id": "langgenius/tools",
"provider": "search",
"tool_name": "web_search",
"credential_type": "api-key",
"runtime_parameters": {"bad": object()},
}
)
def test_dify_plugin_tool_config_rejects_non_json_schema_values() -> None:
with pytest.raises(ValidationError):
_ = DifyPluginToolConfig.model_validate(
{
"plugin_id": "langgenius/tools",
"provider": "search",
"tool_name": "web_search",
"credential_type": "api-key",
"parameters_json_schema": {"type": object()},
}
)
def test_dify_plugin_tool_config_requires_explicit_credential_type() -> None:
with pytest.raises(ValidationError):
_ = DifyPluginToolConfig.model_validate(
{
"plugin_id": "langgenius/tools",
"provider": "search",
"tool_name": "web_search",
}
)

View File

@ -1,26 +1,37 @@
import asyncio
import json
import httpx
import pytest
from pydantic import JsonValue
from agenton.compositor import Compositor, LayerNode, LayerProvider
from dify_agent.adapters.llm import DifyLLMAdapterModel
from dify_agent.layers.dify_plugin.configs import (
DIFY_PLUGIN_LAYER_TYPE_ID,
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
DifyPluginLLMLayerConfig,
DifyPluginLayerConfig,
DifyPluginToolConfig,
DifyPluginToolOption,
DifyPluginToolParameter,
DifyPluginToolParameterForm,
DifyPluginToolParameterType,
DifyPluginToolsLayerConfig,
)
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
def _plugin_config() -> DifyPluginLayerConfig:
return DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai", user_id="user-1")
return DifyPluginLayerConfig(tenant_id="tenant-1", user_id="user-1")
def _llm_config() -> DifyPluginLLMLayerConfig:
return DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="demo-model",
credentials={"api_key": "secret"},
@ -28,6 +39,42 @@ def _llm_config() -> DifyPluginLLMLayerConfig:
)
def _tools_config() -> DifyPluginToolsLayerConfig:
return DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/tools",
provider="search",
tool_name="web_search",
credential_type="api-key",
description="Search the web.",
credentials={"api_key": "secret"},
runtime_parameters={"api_version": "2026-01", "auth_scope": "workspace"},
parameters=_prepared_tool_parameters(),
parameters_json_schema=_prepared_tool_schema(),
)
]
)
def _missing_hidden_parameter_tools_config() -> DifyPluginToolsLayerConfig:
return DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/tools",
provider="search",
tool_name="web_search",
credential_type="api-key",
description="Search the web.",
credentials={"api_key": "secret"},
runtime_parameters={"api_version": "2026-01"},
parameters=_prepared_tool_parameters(),
parameters_json_schema=_prepared_tool_schema(),
)
]
)
def _plugin_layer() -> DifyPluginLayer:
return DifyPluginLayer.from_config_with_settings(
_plugin_config(),
@ -47,16 +94,130 @@ def _plugin_provider() -> LayerProvider[DifyPluginLayer]:
)
def _prepared_tool_parameters() -> list[DifyPluginToolParameter]:
return [
DifyPluginToolParameter(
name="query",
type=DifyPluginToolParameterType.STRING,
form=DifyPluginToolParameterForm.LLM,
required=True,
llm_description="Search query",
),
DifyPluginToolParameter(
name="region",
type=DifyPluginToolParameterType.SELECT,
form=DifyPluginToolParameterForm.LLM,
required=False,
llm_description="Search region",
options=[DifyPluginToolOption(value="global"), DifyPluginToolOption(value="cn")],
),
DifyPluginToolParameter(
name="api_version",
type=DifyPluginToolParameterType.STRING,
form=DifyPluginToolParameterForm.FORM,
required=True,
llm_description="Hidden API version",
),
DifyPluginToolParameter(
name="auth_scope",
type=DifyPluginToolParameterType.STRING,
form=DifyPluginToolParameterForm.FORM,
required=True,
llm_description="Hidden auth scope",
),
]
def _prepared_tool_schema() -> dict[str, JsonValue]:
return {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"region": {
"type": "string",
"description": "Search region",
"enum": ["global", "cn"],
},
},
"required": ["query"],
}
def _llm_only_parameter(*, name: str, description: str, default: JsonValue = None) -> DifyPluginToolParameter:
return DifyPluginToolParameter(
name=name,
type=DifyPluginToolParameterType.STRING,
form=DifyPluginToolParameterForm.LLM,
required=default is None,
default=default,
llm_description=description,
)
def _invoke_stream_response(
*,
error_payload: dict[str, object] | None = None,
chunked_blob: bool = False,
) -> httpx.Response:
if error_payload is not None:
return httpx.Response(400, json=error_payload)
if chunked_blob:
stream_payload = "\n".join(
[
f"data: {json.dumps({'code': 0, 'message': 'ok', 'data': {'type': 'blob_chunk', 'message': {'id': 'blob-1', 'sequence': 0, 'total_length': 11, 'blob': 'aGVsbG8g', 'end': False}}})}",
f"data: {json.dumps({'code': 0, 'message': 'ok', 'data': {'type': 'blob_chunk', 'message': {'id': 'blob-1', 'sequence': 1, 'total_length': 11, 'blob': 'd29ybGQ=', 'end': True}}})}",
"",
]
)
return httpx.Response(200, text=stream_payload)
stream_payload = "\n".join(
[
f"data: {json.dumps({'code': 0, 'message': 'ok', 'data': {'type': 'text', 'message': {'text': 'found '}}})}",
f"data: {json.dumps({'code': 0, 'message': 'ok', 'data': {'type': 'json', 'message': {'json_object': {'count': 1}}}})}",
"",
]
)
return httpx.Response(200, text=stream_payload)
def _tool_transport(
*,
invoke_error_payload: dict[str, object] | None = None,
chunked_blob: bool = False,
) -> httpx.MockTransport:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/dispatch/tool/invoke"):
payload = json.loads(request.content.decode("utf-8"))
assert payload["user_id"] == "user-1"
assert payload["data"]["provider"] == "search"
assert payload["data"]["tool"] == "web_search"
assert payload["data"]["credential_type"] == "api-key"
assert payload["data"]["tool_parameters"] == {
"query": "dify",
"region": "global",
"api_version": "2026-01",
"auth_scope": "workspace",
}
return _invoke_stream_response(error_payload=invoke_error_payload, chunked_blob=chunked_blob)
raise AssertionError(f"Unexpected request path: {request.url.path}")
return httpx.MockTransport(handler)
def test_dify_plugin_type_id_constants_match_implementation_classes() -> None:
assert DIFY_PLUGIN_LAYER_TYPE_ID == DifyPluginLayer.type_id
assert DIFY_PLUGIN_LLM_LAYER_TYPE_ID == DifyPluginLLMLayer.type_id
assert DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID == DifyPluginToolsLayer.type_id
def test_dify_plugin_layer_creates_daemon_provider_from_shared_http_client() -> None:
async def scenario() -> None:
plugin = _plugin_layer()
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client:
provider = plugin.create_daemon_provider(http_client=client)
provider = plugin.create_daemon_provider(plugin_id="langgenius/openai", http_client=client)
assert provider.name == "DifyPlugin/langgenius/openai"
assert provider.client.http_client is client
@ -78,7 +239,9 @@ def test_dify_plugin_layer_rejects_closed_shared_http_client() -> None:
await client.aclose()
with pytest.raises(RuntimeError, match="open shared HTTP client"):
_ = plugin.create_daemon_provider(http_client=client)
_ = plugin.create_daemon_provider(plugin_id="langgenius/openai", http_client=client)
with pytest.raises(RuntimeError, match="open shared HTTP client"):
_ = plugin.create_tool_client(plugin_id="langgenius/tools", http_client=client)
asyncio.run(scenario())
@ -114,13 +277,427 @@ def test_dify_plugin_llm_layer_builds_adapter_model_from_direct_dependency() ->
asyncio.run(scenario())
def test_dify_plugin_tools_layer_uses_prepared_tool_definition_and_invokes_daemon() -> None:
async def scenario() -> None:
compositor = Compositor(
[
LayerNode("plugin", _plugin_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"plugin": "plugin"}),
]
)
async with httpx.AsyncClient(transport=_tool_transport()) as client:
async with compositor.enter(configs={"plugin": _plugin_config(), "tools": _tools_config()}) as run:
tools_layer = run.get_layer("tools", DifyPluginToolsLayer)
tool = (await tools_layer.get_tools(http_client=client))[0]
tool_def = await tool.prepare_tool_def(None) # pyright: ignore[reportArgumentType]
result = await tool.function_schema.call(
{"query": "dify", "region": "global"},
None, # pyright: ignore[reportArgumentType]
)
assert tool.name == "web_search"
assert tool.description == "Search the web."
assert tool_def is not None
assert tool_def.parameters_json_schema == _prepared_tool_schema()
assert result == 'found {"count": 1}'
asyncio.run(scenario())
def test_dify_plugin_tools_layer_uses_each_tool_plugin_id_for_transport() -> None:
async def scenario() -> None:
seen_requests: list[tuple[str, str, str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/dispatch/tool/invoke"):
payload = json.loads(request.content.decode("utf-8"))
seen_requests.append(
(
request.headers["X-Plugin-ID"],
payload["user_id"],
payload["data"]["provider"],
payload["data"]["tool"],
)
)
return _invoke_stream_response()
raise AssertionError(f"Unexpected request path: {request.url.path}")
tools_config = DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/tools-a",
provider="search-a",
tool_name="web_search_a",
credential_type="api-key",
parameters=[_llm_only_parameter(name="query", description="Search query A")],
parameters_json_schema={
"type": "object",
"properties": {"query": {"type": "string", "description": "Search query A"}},
"required": ["query"],
},
),
DifyPluginToolConfig(
plugin_id="langgenius/tools-b",
provider="search-b",
tool_name="web_search_b",
credential_type="api-key",
parameters=[_llm_only_parameter(name="query", description="Search query B")],
parameters_json_schema={
"type": "object",
"properties": {"query": {"type": "string", "description": "Search query B"}},
"required": ["query"],
},
),
]
)
compositor = Compositor(
[
LayerNode("plugin", _plugin_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"plugin": "plugin"}),
]
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
async with compositor.enter(configs={"plugin": _plugin_config(), "tools": tools_config}) as run:
tools = await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client)
await tools[0].function_schema.call({"query": "first"}, None) # pyright: ignore[reportArgumentType]
await tools[1].function_schema.call({"query": "second"}, None) # pyright: ignore[reportArgumentType]
assert seen_requests == [
("langgenius/tools-a", "user-1", "search-a", "web_search_a"),
("langgenius/tools-b", "user-1", "search-b", "web_search_b"),
]
asyncio.run(scenario())
def test_dify_plugin_tools_layer_casts_prepared_parameter_values_before_invocation() -> None:
async def scenario() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/dispatch/tool/invoke"):
payload = json.loads(request.content.decode("utf-8"))
assert payload["user_id"] == "user-1"
assert payload["data"]["tool_parameters"] == {
"enabled": True,
"count": 7,
"tags": ["a", "b"],
"metadata": {"source": "docs"},
"model": {"provider": "openai", "model": "gpt-4o-mini"},
}
return _invoke_stream_response()
raise AssertionError(f"Unexpected request path: {request.url.path}")
tools_config = DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/tools",
provider="search",
tool_name="web_search",
credential_type="api-key",
parameters=[
DifyPluginToolParameter(
name="enabled",
type=DifyPluginToolParameterType.BOOLEAN,
form=DifyPluginToolParameterForm.LLM,
required=True,
llm_description="Enable search",
),
DifyPluginToolParameter(
name="count",
type=DifyPluginToolParameterType.NUMBER,
form=DifyPluginToolParameterForm.LLM,
required=True,
llm_description="Result count",
),
DifyPluginToolParameter(
name="tags",
type=DifyPluginToolParameterType.ARRAY,
form=DifyPluginToolParameterForm.LLM,
required=True,
llm_description="Tags",
input_schema={"type": "array", "items": {"type": "string"}},
),
DifyPluginToolParameter(
name="metadata",
type=DifyPluginToolParameterType.OBJECT,
form=DifyPluginToolParameterForm.LLM,
required=True,
llm_description="Metadata",
input_schema={"type": "object", "additionalProperties": True},
),
DifyPluginToolParameter(
name="model",
type=DifyPluginToolParameterType.MODEL_SELECTOR,
form=DifyPluginToolParameterForm.LLM,
required=True,
llm_description="Model selector",
input_schema={"type": "object", "additionalProperties": True},
),
],
parameters_json_schema={
"type": "object",
"properties": {
"enabled": {"type": "boolean", "description": "Enable search"},
"count": {"type": "number", "description": "Result count"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags"},
"metadata": {
"type": "object",
"additionalProperties": True,
"description": "Metadata",
},
"model": {
"type": "object",
"additionalProperties": True,
"description": "Model selector",
},
},
"required": ["enabled", "count", "tags", "metadata", "model"],
},
)
]
)
compositor = Compositor(
[
LayerNode("plugin", _plugin_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"plugin": "plugin"}),
]
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
async with compositor.enter(configs={"plugin": _plugin_config(), "tools": tools_config}) as run:
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
result = await tool.function_schema.call(
{
"enabled": "yes",
"count": "7",
"tags": '["a", "b"]',
"metadata": '{"source": "docs"}',
"model": {"provider": "openai", "model": "gpt-4o-mini"},
},
None, # pyright: ignore[reportArgumentType]
)
assert result == 'found {"count": 1}'
asyncio.run(scenario())
def test_dify_plugin_tools_layer_sends_prepared_parameter_defaults_to_daemon() -> None:
async def scenario() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/dispatch/tool/invoke"):
payload = json.loads(request.content.decode("utf-8"))
assert payload["data"]["tool_parameters"] == {
"query": "dify",
"region": "global",
}
return _invoke_stream_response()
raise AssertionError(f"Unexpected request path: {request.url.path}")
tools_config = DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/tools",
provider="search",
tool_name="web_search",
credential_type="api-key",
parameters=[
_llm_only_parameter(name="query", description="Search query"),
_llm_only_parameter(name="region", description="Search region", default="global"),
],
parameters_json_schema={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"region": {"type": "string", "description": "Search region"},
},
"required": ["query"],
},
)
]
)
compositor = Compositor(
[
LayerNode("plugin", _plugin_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"plugin": "plugin"}),
]
)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
async with compositor.enter(configs={"plugin": _plugin_config(), "tools": tools_config}) as run:
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
result = await tool.function_schema.call(
{"query": "dify"},
None, # pyright: ignore[reportArgumentType]
)
assert result == 'found {"count": 1}'
asyncio.run(scenario())
def test_dify_plugin_tools_layer_requires_hidden_runtime_parameters_in_prepared_config() -> None:
async def scenario() -> None:
compositor = Compositor(
[
LayerNode("plugin", _plugin_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"plugin": "plugin"}),
]
)
async with httpx.AsyncClient(transport=_tool_transport()) as client:
async with compositor.enter(
configs={"plugin": _plugin_config(), "tools": _missing_hidden_parameter_tools_config()}
) as run:
with pytest.raises(ValueError, match="requires non-LLM runtime_parameters for: auth_scope"):
await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client)
asyncio.run(scenario())
def test_dify_plugin_tools_layer_returns_agent_friendly_error_text() -> None:
async def scenario() -> None:
compositor = Compositor(
[
LayerNode("plugin", _plugin_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"plugin": "plugin"}),
]
)
async with httpx.AsyncClient(
transport=_tool_transport(
invoke_error_payload={
"error_type": "PluginDaemonBadRequestError",
"message": "missing query",
}
)
) as client:
async with compositor.enter(configs={"plugin": _plugin_config(), "tools": _tools_config()}) as run:
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
result = await tool.function_schema.call(
{"query": "dify", "region": "global"},
None, # pyright: ignore[reportArgumentType]
)
assert result == "tool parameters validation error: missing query, please check your tool parameters"
asyncio.run(scenario())
def test_dify_plugin_tools_layer_propagates_unexpected_transport_errors() -> None:
async def scenario() -> None:
compositor = Compositor(
[
LayerNode("plugin", _plugin_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"plugin": "plugin"}),
]
)
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/dispatch/tool/invoke"):
raise RuntimeError("unexpected transport failure")
raise AssertionError(f"Unexpected request path: {request.url.path}")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
async with compositor.enter(configs={"plugin": _plugin_config(), "tools": _tools_config()}) as run:
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
with pytest.raises(RuntimeError, match="unexpected transport failure"):
await tool.function_schema.call(
{"query": "dify", "region": "global"},
None, # pyright: ignore[reportArgumentType]
)
asyncio.run(scenario())
@pytest.mark.parametrize(
("invoke_error_payload", "expected_text"),
[
(
{
"error_type": "PluginInvokeError",
"message": json.dumps(
{
"error_type": "PluginDaemonUnauthorizedError",
"message": "invalid api key",
}
),
},
"Please check your tool provider credentials",
),
(
{
"error_type": "PluginInvokeError",
"message": json.dumps(
{
"error_type": "ToolNotFoundError",
"message": "missing plugin tool",
}
),
},
"there is not a tool named web_search",
),
],
)
def test_dify_plugin_tools_layer_maps_nested_plugin_invoke_errors_to_agent_text(
invoke_error_payload: dict[str, object],
expected_text: str,
) -> None:
async def scenario() -> None:
compositor = Compositor(
[
LayerNode("plugin", _plugin_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"plugin": "plugin"}),
]
)
async with httpx.AsyncClient(transport=_tool_transport(invoke_error_payload=invoke_error_payload)) as client:
async with compositor.enter(configs={"plugin": _plugin_config(), "tools": _tools_config()}) as run:
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
result = await tool.function_schema.call(
{"query": "dify", "region": "global"},
None, # pyright: ignore[reportArgumentType]
)
assert result == expected_text
asyncio.run(scenario())
def test_dify_plugin_tools_layer_merges_blob_chunks_before_observation_conversion() -> None:
async def scenario() -> None:
compositor = Compositor(
[
LayerNode("plugin", _plugin_provider()),
LayerNode("tools", DifyPluginToolsLayer, deps={"plugin": "plugin"}),
]
)
async with httpx.AsyncClient(transport=_tool_transport(chunked_blob=True)) as client:
async with compositor.enter(configs={"plugin": _plugin_config(), "tools": _tools_config()}) as run:
tool = (await run.get_layer("tools", DifyPluginToolsLayer).get_tools(http_client=client))[0]
result = await tool.function_schema.call(
{"query": "dify", "region": "global"},
None, # pyright: ignore[reportArgumentType]
)
assert "hello world" in result
assert "sequence=0" not in result
asyncio.run(scenario())
def test_dify_plugin_layer_lifecycle_does_not_manage_http_client() -> None:
async def scenario() -> None:
compositor = Compositor([LayerNode("plugin", _plugin_provider())])
async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _request: httpx.Response(200))) as client:
async with compositor.enter(configs={"plugin": _plugin_config()}) as run:
plugin = run.get_layer("plugin", DifyPluginLayer)
provider = plugin.create_daemon_provider(http_client=client)
provider = plugin.create_daemon_provider(plugin_id="langgenius/openai", http_client=client)
run.suspend_layer_on_exit("plugin")
assert run.session_snapshot is not None

View File

@ -6,7 +6,11 @@ from agenton.compositor import CompositorSessionSnapshot
from agenton.layers import ExitIntent
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
import dify_agent.protocol as protocol_exports
from dify_agent.layers.dify_plugin import DIFY_PLUGIN_LAYER_TYPE_ID, DIFY_PLUGIN_LLM_LAYER_TYPE_ID
from dify_agent.layers.dify_plugin import (
DIFY_PLUGIN_LAYER_TYPE_ID,
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
)
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID
from dify_agent.protocol.schemas import (
@ -28,7 +32,15 @@ from dify_agent.protocol.schemas import (
RunSucceededEventData,
normalize_composition,
)
from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig, DifyPluginLayerConfig
from dify_agent.layers.dify_plugin.configs import (
DifyPluginLLMLayerConfig,
DifyPluginLayerConfig,
DifyPluginToolConfig,
DifyPluginToolParameter,
DifyPluginToolParameterForm,
DifyPluginToolParameterType,
DifyPluginToolsLayerConfig,
)
def test_run_event_adapter_round_trips_typed_variants() -> None:
@ -89,8 +101,9 @@ def test_create_run_request_rejects_old_compositor_payload_and_model_layer_id_is
def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_graph_config() -> None:
prompt_config = PromptLayerConfig(prefix="system", user="hello")
plugin_config = DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai")
plugin_config = DifyPluginLayerConfig(tenant_id="tenant-1")
llm_config = DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="demo-model",
credentials={"api_key": "secret"},
@ -179,6 +192,110 @@ def test_create_run_request_accepts_dto_first_public_composition_and_normalizes_
}
def test_create_run_request_accepts_plugin_tools_layer_with_prepared_parameters_and_schema() -> None:
request = CreateRunRequest.model_validate(
{
"composition": {
"layers": [
{"name": "prompt", "type": PLAIN_PROMPT_LAYER_TYPE_ID, "config": {"user": "hello"}},
{"name": "plugin", "type": DIFY_PLUGIN_LAYER_TYPE_ID, "config": {"tenant_id": "tenant-1"}},
{
"name": DIFY_AGENT_MODEL_LAYER_ID,
"type": DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
"deps": {"plugin": "plugin"},
"config": {
"plugin_id": "langgenius/openai",
"model_provider": "openai",
"model": "demo-model",
"credentials": {"api_key": "secret"},
},
},
{
"name": "tools",
"type": DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
"deps": {"plugin": "plugin"},
"config": {
"tools": [
{
"plugin_id": "langgenius/search",
"provider": "search",
"tool_name": "web_search",
"credential_type": "api-key",
"runtime_parameters": {"site": "docs.dify.ai"},
"parameters": [
{
"name": "query",
"type": "string",
"form": "llm",
"required": True,
"llm_description": "Search query",
},
{
"name": "site",
"type": "string",
"form": "form",
"required": True,
"llm_description": "Hidden site",
},
],
"parameters_json_schema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"],
},
}
]
},
},
]
}
}
)
graph_config, layer_configs = normalize_composition(request.composition)
assert [layer.type for layer in graph_config.layers] == [
PLAIN_PROMPT_LAYER_TYPE_ID,
DIFY_PLUGIN_LAYER_TYPE_ID,
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
]
assert DifyPluginToolsLayerConfig.model_validate(layer_configs["tools"]) == DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/search",
provider="search",
tool_name="web_search",
credential_type="api-key",
runtime_parameters={"site": "docs.dify.ai"},
parameters=[
DifyPluginToolParameter(
name="query",
type=DifyPluginToolParameterType.STRING,
form=DifyPluginToolParameterForm.LLM,
required=True,
llm_description="Search query",
),
DifyPluginToolParameter(
name="site",
type=DifyPluginToolParameterType.STRING,
form=DifyPluginToolParameterForm.FORM,
required=True,
llm_description="Hidden site",
),
],
parameters_json_schema={
"type": "object",
"properties": {"query": {"type": "string", "description": "Search query"}},
"required": ["query"],
},
)
]
)
def test_on_exit_default_to_suspend_and_are_public() -> None:
assert protocol_exports.LayerExitSignals is LayerExitSignals
assert protocol_exports.RunComposition is RunComposition

View File

@ -1,9 +1,11 @@
import asyncio
from collections.abc import Mapping
from typing import Any
from typing import Any, cast
import httpx
import pytest
from pydantic import JsonValue
from pydantic_ai import Tool
from pydantic_ai.exceptions import UnexpectedModelBehavior
from pydantic_ai.messages import (
ModelMessage,
@ -18,12 +20,22 @@ from pydantic_ai.models import ModelRequestParameters
from pydantic_ai.models.test import TestModel
from pydantic_ai.settings import ModelSettings
from agenton.compositor import CompositorSessionSnapshot, LayerSessionSnapshot
from agenton.compositor import CompositorSessionSnapshot, LayerProvider, LayerSessionSnapshot
from agenton.layers import ExitIntent, LifecycleState
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryRuntimeState
from agenton_collections.layers.plain import PromptLayerConfig
from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig, DifyPluginLayerConfig
from agenton_collections.layers.plain import PromptLayerConfig, ToolsLayer
from dify_agent.layers.dify_plugin.configs import (
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
DifyPluginLLMLayerConfig,
DifyPluginLayerConfig,
DifyPluginToolConfig,
DifyPluginToolParameter,
DifyPluginToolParameterForm,
DifyPluginToolParameterType,
DifyPluginToolsLayerConfig,
)
from dify_agent.layers.dify_plugin.llm_layer import DifyPluginLLMLayer
from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID, DIFY_AGENT_OUTPUT_LAYER_ID
from dify_agent.protocol.schemas import (
@ -34,9 +46,14 @@ from dify_agent.protocol.schemas import (
RunSucceededEvent,
)
from dify_agent.runtime.event_sink import InMemoryRunEventSink
from dify_agent.runtime.compositor_factory import create_default_layer_providers
from dify_agent.runtime.runner import AgentRunRunner, AgentRunValidationError
class StaticToolsTestLayer(ToolsLayer):
type_id = "test.static.tools"
def _request(
user: str | list[str] = "hello",
*,
@ -60,13 +77,14 @@ def _request(
RunLayerSpec(
name=plugin_layer_name,
type="dify.plugin",
config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"),
config=DifyPluginLayerConfig(tenant_id="tenant-1"),
),
RunLayerSpec(
name=llm_layer_name,
type="dify.plugin.llm",
deps={"plugin": plugin_layer_name},
config=DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="demo-model",
credentials={"api_key": "secret"},
@ -103,6 +121,35 @@ def _recursive_output_schema() -> dict[str, object]:
}
def _prepared_plugin_tool_parameters() -> list[DifyPluginToolParameter]:
return [
DifyPluginToolParameter(
name="query",
type=DifyPluginToolParameterType.STRING,
form=DifyPluginToolParameterForm.LLM,
required=True,
llm_description="Search query",
),
DifyPluginToolParameter(
name="auth_scope",
type=DifyPluginToolParameterType.STRING,
form=DifyPluginToolParameterForm.FORM,
required=True,
llm_description="Hidden auth scope",
),
]
def _prepared_plugin_tool_schema() -> dict[str, JsonValue]:
return {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
},
"required": ["query"],
}
class SequenceOutputTestModel(TestModel):
outputs: list[str | dict[str, Any] | None]
request_count: int
@ -198,7 +245,7 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa
def fake_get_model(self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
assert self.config.model == "demo-model"
assert self.deps.plugin.config.plugin_id == "langgenius/openai"
assert self.config.plugin_id == "langgenius/openai"
seen_clients.append(http_client)
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
@ -241,6 +288,315 @@ def test_runner_emits_terminal_success_and_snapshot(monkeypatch: pytest.MonkeyPa
assert sink.statuses["run-1"] == "succeeded"
def test_runner_passes_dynamic_dify_plugin_tools_to_agent(monkeypatch: pytest.MonkeyPatch) -> None:
seen_tools: list[Tool[object]] = []
async def plugin_tool() -> str:
return "tool"
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
assert http_client.is_closed is False
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
async def fake_get_tools(self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
assert self.config.tools[0].tool_name == "web_search"
assert http_client.is_closed is False
return [Tool(plugin_tool, name="web_search")]
class FakeResult:
output: str = "done"
def new_messages(self) -> list[ModelMessage]:
return []
class FakeAgent:
async def run(self, *_args: object, **_kwargs: object) -> FakeResult:
return FakeResult()
def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> FakeAgent:
del model, output_type
seen_tools.extend(tools)
return FakeAgent()
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
monkeypatch.setattr(DifyPluginToolsLayer, "get_tools", fake_get_tools)
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent)
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(
name="prompt",
type="plain.prompt",
config=PromptLayerConfig(prefix="system", user="hello"),
),
RunLayerSpec(
name="plugin",
type="dify.plugin",
config=DifyPluginLayerConfig(tenant_id="tenant-1"),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type="dify.plugin.llm",
deps={"plugin": "plugin"},
config=DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="demo-model",
credentials={"api_key": "secret"},
),
),
RunLayerSpec(
name="tools",
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
deps={"plugin": "plugin"},
config=DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/tools",
provider="search",
tool_name="web_search",
credential_type="api-key",
parameters=_prepared_plugin_tool_parameters(),
parameters_json_schema=_prepared_plugin_tool_schema(),
)
]
),
),
]
)
)
sink = InMemoryRunEventSink()
async def scenario() -> None:
async with httpx.AsyncClient() as client:
await AgentRunRunner(
sink=sink,
request=request,
run_id="run-tools",
plugin_daemon_http_client=client,
).run()
asyncio.run(scenario())
assert [tool.name for tool in seen_tools] == ["web_search"]
terminal = sink.events["run-tools"][-1]
assert isinstance(terminal, RunSucceededEvent)
assert terminal.data.output == "done"
def test_runner_rejects_duplicate_tool_names_across_dynamic_tool_layers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
create_agent_called = False
async def duplicate_tool() -> str:
return "tool"
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
assert http_client.is_closed is False
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
assert http_client.is_closed is False
return [Tool(duplicate_tool, name="shared_tool")]
def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object:
del model, tools, output_type
nonlocal create_agent_called
create_agent_called = True
raise AssertionError("create_agent should not be called when duplicate tool names are detected")
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
monkeypatch.setattr(DifyPluginToolsLayer, "get_tools", fake_get_tools)
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent)
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(
name="prompt",
type="plain.prompt",
config=PromptLayerConfig(prefix="system", user="hello"),
),
RunLayerSpec(
name="plugin",
type="dify.plugin",
config=DifyPluginLayerConfig(tenant_id="tenant-1"),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type="dify.plugin.llm",
deps={"plugin": "plugin"},
config=DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="demo-model",
credentials={"api_key": "secret"},
),
),
RunLayerSpec(
name="tools-1",
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
deps={"plugin": "plugin"},
config=DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/tools",
provider="search",
tool_name="web_search",
credential_type="api-key",
parameters=_prepared_plugin_tool_parameters(),
parameters_json_schema=_prepared_plugin_tool_schema(),
)
]
),
),
RunLayerSpec(
name="tools-2",
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
deps={"plugin": "plugin"},
config=DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/tools",
provider="search",
tool_name="web_search_two",
credential_type="api-key",
parameters=_prepared_plugin_tool_parameters(),
parameters_json_schema=_prepared_plugin_tool_schema(),
)
]
),
),
]
)
)
sink = InMemoryRunEventSink()
async def scenario() -> None:
async with httpx.AsyncClient() as client:
with pytest.raises(
AgentRunValidationError,
match="unique tool names across all layers, got duplicates: shared_tool",
):
await AgentRunRunner(
sink=sink,
request=request,
run_id="run-duplicate-tools",
plugin_daemon_http_client=client,
).run()
asyncio.run(scenario())
assert create_agent_called is False
assert [event.type for event in sink.events["run-duplicate-tools"]] == ["run_started", "run_failed"]
assert sink.statuses["run-duplicate-tools"] == "failed"
def test_runner_rejects_duplicate_tool_names_between_static_and_dynamic_tools(
monkeypatch: pytest.MonkeyPatch,
) -> None:
create_agent_called = False
def web_search(query: str) -> str:
return query
async def dynamic_duplicate_tool() -> str:
return "tool"
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
assert http_client.is_closed is False
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
assert http_client.is_closed is False
return [Tool(dynamic_duplicate_tool, name="web_search")]
def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object:
del model, tools, output_type
nonlocal create_agent_called
create_agent_called = True
raise AssertionError("create_agent should not be called when duplicate tool names are detected")
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
monkeypatch.setattr(DifyPluginToolsLayer, "get_tools", fake_get_tools)
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent)
static_tools_provider = LayerProvider.from_factory(
layer_type=StaticToolsTestLayer,
create=lambda _config: StaticToolsTestLayer(tool_entries=(web_search,)),
)
layer_providers = (*create_default_layer_providers(), static_tools_provider)
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(
name="prompt",
type="plain.prompt",
config=PromptLayerConfig(prefix="system", user="hello"),
),
RunLayerSpec(name="static-tools", type=cast(str, StaticToolsTestLayer.type_id)),
RunLayerSpec(
name="plugin",
type="dify.plugin",
config=DifyPluginLayerConfig(tenant_id="tenant-1"),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type="dify.plugin.llm",
deps={"plugin": "plugin"},
config=DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="demo-model",
credentials={"api_key": "secret"},
),
),
RunLayerSpec(
name="tools",
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
deps={"plugin": "plugin"},
config=DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/tools",
provider="search",
tool_name="web_search",
credential_type="api-key",
parameters=_prepared_plugin_tool_parameters(),
parameters_json_schema=_prepared_plugin_tool_schema(),
)
]
),
),
]
)
)
sink = InMemoryRunEventSink()
async def scenario() -> None:
async with httpx.AsyncClient() as client:
with pytest.raises(
AgentRunValidationError,
match="unique tool names across all layers, got duplicates: web_search",
):
await AgentRunRunner(
sink=sink,
request=request,
run_id="run-static-dynamic-duplicate-tools",
plugin_daemon_http_client=client,
layer_providers=layer_providers,
).run()
asyncio.run(scenario())
assert create_agent_called is False
assert [event.type for event in sink.events["run-static-dynamic-duplicate-tools"]] == ["run_started", "run_failed"]
assert sink.statuses["run-static-dynamic-duplicate-tools"] == "failed"
def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monkeypatch: pytest.MonkeyPatch) -> None:
model = RecordingTestModel(custom_output_text="done")
@ -684,13 +1040,14 @@ def test_runner_rejects_misnamed_output_layer_before_model_resolution(monkeypatc
RunLayerSpec(
name="plugin",
type="dify.plugin",
config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"),
config=DifyPluginLayerConfig(tenant_id="tenant-1"),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type="dify.plugin.llm",
deps={"plugin": "plugin"},
config=DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="demo-model",
credentials={"api_key": "secret"},
@ -752,13 +1109,14 @@ def test_runner_rejects_multiple_output_layers_before_model_resolution(monkeypat
RunLayerSpec(
name="plugin",
type="dify.plugin",
config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"),
config=DifyPluginLayerConfig(tenant_id="tenant-1"),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type="dify.plugin.llm",
deps={"plugin": "plugin"},
config=DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="demo-model",
credentials={"api_key": "secret"},
@ -842,13 +1200,14 @@ def test_runner_rejects_reserved_output_name_with_wrong_layer_type_before_model_
RunLayerSpec(
name="plugin",
type="dify.plugin",
config=DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="langgenius/openai"),
config=DifyPluginLayerConfig(tenant_id="tenant-1"),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type="dify.plugin.llm",
deps={"plugin": "plugin"},
config=DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="demo-model",
credentials={"api_key": "secret"},

View File

@ -149,7 +149,7 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
layer_providers = scheduler.layer_providers
assert isinstance(layer_providers, tuple)
plugin_provider = next(provider for provider in layer_providers if provider.type_id == "dify.plugin")
plugin_layer = plugin_provider.create_layer(DifyPluginLayerConfig(tenant_id="tenant-1", plugin_id="plugin-1"))
plugin_layer = plugin_provider.create_layer(DifyPluginLayerConfig(tenant_id="tenant-1"))
assert isinstance(plugin_layer, DifyPluginLayer)
assert plugin_layer.daemon_url == "http://plugin-daemon"
assert plugin_layer.daemon_api_key == "daemon-secret"

View File

@ -106,13 +106,14 @@ def test_create_run_accepts_valid_full_plugin_graph() -> None:
{
"name": "plugin-renamed",
"type": "dify.plugin",
"config": {"tenant_id": "tenant-1", "plugin_id": "langgenius/openai"},
"config": {"tenant_id": "tenant-1"},
},
{
"name": DIFY_AGENT_MODEL_LAYER_ID,
"type": "dify.plugin.llm",
"deps": {"plugin": "plugin-renamed"},
"config": {
"plugin_id": "langgenius/openai",
"model_provider": "openai",
"model": "gpt-4o-mini",
"credentials": {"api_key": "secret"},

View File

@ -81,6 +81,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
"dify_agent.adapters.llm",
"dify_agent.layers.dify_plugin.llm_layer",
"dify_agent.layers.dify_plugin.plugin_layer",
"dify_agent.layers.dify_plugin.tools_layer",
"dify_agent.layers.output.output_layer",
"dify_agent.runtime",
"dify_agent.server",
@ -94,7 +95,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
imports=["dify_agent.protocol", "dify_agent.layers.dify_plugin", "dify_agent.layers.output"],
assertions=[
"assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')",
"assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LAYER_TYPE_ID', 'DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginLayerConfig']",
"assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LAYER_TYPE_ID', 'DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']",
"assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']",
],
)