diff --git a/.context/impl/tool_layer.md b/.context/impl/tool_layer.md new file mode 100644 index 0000000000..e3dadcb942 --- /dev/null +++ b/.context/impl/tool_layer.md @@ -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. diff --git a/api/core/agent/base_agent_runner.py b/api/core/agent/base_agent_runner.py index cba4659483..694d633148 100644 --- a/api/core/agent/base_agent_runner.py +++ b/api/core/agent/base_agent_runner.py @@ -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( diff --git a/api/core/tools/__base/tool.py b/api/core/tools/__base/tool.py index ab0f73a9a2..1528a7b711 100644 --- a/api/core/tools/__base/tool.py +++ b/api/core/tools/__base/tool.py @@ -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, diff --git a/api/tests/unit_tests/core/agent/test_base_agent_runner.py b/api/tests/unit_tests/core/agent/test_base_agent_runner.py index d5fb853ee3..6895190eaa 100644 --- a/api/tests/unit_tests/core/agent/test_base_agent_runner.py +++ b/api/tests/unit_tests/core/agent/test_base_agent_runner.py @@ -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" diff --git a/api/tests/unit_tests/core/tools/test_base_tool.py b/api/tests/unit_tests/core/tools/test_base_tool.py index 23d3e77c1d..0ed84f8560 100644 --- a/api/tests/unit_tests/core/tools/test_base_tool.py +++ b/api/tests/unit_tests/core/tools/test_base_tool.py @@ -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(): diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py index a3d0474b46..8fcfa1f620 100644 --- a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py +++ b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_consumer.py @@ -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"], + # }, + # ) + # ] + # ), + # ), ], ), ) diff --git a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py index 3c789571f1..ec95bd1f15 100644 --- a/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py +++ b/dify-agent/examples/dify_agent/dify_agent_examples/run_server_sync_client.py @@ -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"], + # }, + # ) + # ] + # ), + # ), ], ), ) diff --git a/dify-agent/src/dify_agent/adapters/llm/provider.py b/dify-agent/src/dify_agent/adapters/llm/provider.py index 6e7b92f646..a210cce1e3 100644 --- a/dify-agent/src/dify_agent/adapters/llm/provider.py +++ b/dify-agent/src/dify_agent/adapters/llm/provider.py @@ -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": diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/__init__.py b/dify-agent/src/dify_agent/layers/dify_plugin/__init__.py index 5b2f1dccce..c9aa5fbb26 100644 --- a/dify-agent/src/dify_agent/layers/dify_plugin/__init__.py +++ b/dify-agent/src/dify_agent/layers/dify_plugin/__init__.py @@ -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", ] diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/configs.py b/dify-agent/src/dify_agent/layers/dify_plugin/configs.py index 5fff7dde51..3be6fbade2 100644 --- a/dify-agent/src/dify_agent/layers/dify_plugin/configs.py +++ b/dify-agent/src/dify_agent/layers/dify_plugin/configs.py @@ -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", ] diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py b/dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py index 4ac053df3f..7bc38727e0 100644 --- a/dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py +++ b/dify-agent/src/dify_agent/layers/dify_plugin/llm_layer.py @@ -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, diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/plugin_layer.py b/dify-agent/src/dify_agent/layers/dify_plugin/plugin_layer.py index 71c649b6de..30a3c12df8 100644 --- a/dify-agent/src/dify_agent/layers/dify_plugin/plugin_layer.py +++ b/dify-agent/src/dify_agent/layers/dify_plugin/plugin_layer.py @@ -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, diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py b/dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py new file mode 100644 index 0000000000..ed2e77017d --- /dev/null +++ b/dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py b/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py new file mode 100644 index 0000000000..c3d3e09c32 --- /dev/null +++ b/dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py @@ -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"] diff --git a/dify-agent/src/dify_agent/plugin_daemon_transport.py b/dify-agent/src/dify_agent/plugin_daemon_transport.py new file mode 100644 index 0000000000..dc88c3f01e --- /dev/null +++ b/dify-agent/src/dify_agent/plugin_daemon_transport.py @@ -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", +] diff --git a/dify-agent/src/dify_agent/runtime/compositor_factory.py b/dify-agent/src/dify_agent/runtime/compositor_factory.py index 8750dbc71d..59da191732 100644 --- a/dify-agent/src/dify_agent/runtime/compositor_factory.py +++ b/dify-agent/src/dify_agent/runtime/compositor_factory.py @@ -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), ) diff --git a/dify-agent/src/dify_agent/runtime/runner.py b/dify-agent/src/dify_agent/runtime/runner.py index b1e1758928..11e99bb838 100644 --- a/dify-agent/src/dify_agent/runtime/runner.py +++ b/dify-agent/src/dify_agent/runtime/runner.py @@ -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"] diff --git a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py index f6f84772ba..c910a15dab 100644 --- a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py +++ b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_configs.py @@ -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", + } + ) diff --git a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py index 78c833d946..88ae48a5ef 100644 --- a/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py +++ b/dify-agent/tests/local/dify_agent/layers/dify_plugin/test_layers.py @@ -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 diff --git a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py index ce39511302..b827b49a29 100644 --- a/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py +++ b/dify-agent/tests/local/dify_agent/protocol/test_protocol_schemas.py @@ -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 diff --git a/dify-agent/tests/local/dify_agent/runtime/test_runner.py b/dify-agent/tests/local/dify_agent/runtime/test_runner.py index ddf860beb6..bde73f3069 100644 --- a/dify-agent/tests/local/dify_agent/runtime/test_runner.py +++ b/dify-agent/tests/local/dify_agent/runtime/test_runner.py @@ -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"}, diff --git a/dify-agent/tests/local/dify_agent/server/test_app.py b/dify-agent/tests/local/dify_agent/server/test_app.py index 73bfde69bd..bdeec6898c 100644 --- a/dify-agent/tests/local/dify_agent/server/test_app.py +++ b/dify-agent/tests/local/dify_agent/server/test_app.py @@ -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" diff --git a/dify-agent/tests/local/dify_agent/server/test_runs_routes.py b/dify-agent/tests/local/dify_agent/server/test_runs_routes.py index bed7883170..4675e443f1 100644 --- a/dify-agent/tests/local/dify_agent/server/test_runs_routes.py +++ b/dify-agent/tests/local/dify_agent/server/test_runs_routes.py @@ -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"}, diff --git a/dify-agent/tests/local/dify_agent/test_import_boundaries.py b/dify-agent/tests/local/dify_agent/test_import_boundaries.py index 7ff55b167b..0099d9c7eb 100644 --- a/dify-agent/tests/local/dify_agent/test_import_boundaries.py +++ b/dify-agent/tests/local/dify_agent/test_import_boundaries.py @@ -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']", ], )