mirror of
https://github.com/langgenius/dify.git
synced 2026-05-26 03:47:42 +08:00
feat(dify-agent): add plugin tools layer
This commit is contained in:
774
.context/impl/tool_layer.md
Normal file
774
.context/impl/tool_layer.md
Normal file
@ -0,0 +1,774 @@
|
||||
# Dify Agent plugin tools layer implementation notes
|
||||
|
||||
## 0. Update summary relative to the previous version of this file
|
||||
|
||||
The previous version documented an implementation where `dify-agent` fetched
|
||||
plugin tool provider declarations, fetched runtime parameters, merged the two,
|
||||
and built the model-facing JSON schema at run time. That has changed.
|
||||
|
||||
The current implementation moves clean declaration/schema preparation to the API
|
||||
side and keeps `dify-agent` focused on invocation:
|
||||
|
||||
- API-side `Tool` now owns effective parameter merging through
|
||||
`Tool.get_merged_runtime_parameters(...)`.
|
||||
- API-side `Tool` now owns LLM-facing JSON schema generation through
|
||||
`Tool.get_llm_parameters_json_schema(...)`.
|
||||
- `BaseAgentRunner` now calls `Tool.get_llm_parameters_json_schema()` directly
|
||||
when preparing/updating `PromptMessageTool` for normal agent tools.
|
||||
- No existing `api/` caller was found that constructs `dify.plugin`,
|
||||
`dify.plugin.llm`, or `dify.plugin.tools` run-composition layers, so no
|
||||
API-side composition rewiring was needed for the plugin-id split.
|
||||
- `dify-agent` `DifyPluginToolConfig` now carries API-prepared:
|
||||
- `parameters`
|
||||
- `parameters_json_schema`
|
||||
- `dify-agent` no longer fetches tool provider declarations or runtime
|
||||
parameters while building tools.
|
||||
- `dify-agent` no longer contains provider-discovery/runtime-parameter DTOs or
|
||||
client methods in `tool_client.py`.
|
||||
- `DifyPluginDaemonToolClient` is now an invoke-only daemon boundary.
|
||||
- `dify-agent` still prepares invocation payloads from the prepared parameter
|
||||
declarations: it validates required hidden/manual inputs, applies defaults,
|
||||
casts values into daemon-facing shapes, invokes the daemon, merges blob
|
||||
chunks, and maps expected daemon/tool errors into agent-facing observations.
|
||||
- The plugin-id split invariant remains unchanged:
|
||||
- `dify.plugin` owns shared tenant/user daemon context only.
|
||||
- `dify.plugin.llm` owns its LLM `plugin_id`.
|
||||
- each `DifyPluginToolConfig` owns its tool `plugin_id`.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
The Dify Agent tool layer lets a `dify-agent` run expose Dify plugin tools to a
|
||||
Pydantic AI agent. The current design deliberately separates **preparation** from
|
||||
**execution**:
|
||||
|
||||
- Dify API prepares the effective tool declaration and LLM-facing JSON schema.
|
||||
- Dify Agent receives that clean prepared config and performs only runtime
|
||||
invocation work.
|
||||
|
||||
This avoids duplicating Dify API's original agent-node declaration merge and
|
||||
schema-building semantics inside `dify-agent` while preserving the daemon tool
|
||||
invocation contract.
|
||||
|
||||
The public layer split is:
|
||||
|
||||
| Layer | Type id | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| `dify.plugin` | `"dify.plugin"` | Shared plugin-daemon tenant/user context plus server-injected daemon settings. |
|
||||
| `dify.plugin.llm` | `"dify.plugin.llm"` | One plugin-backed LLM selection, including its own `plugin_id`. |
|
||||
| `dify.plugin.tools` | `"dify.plugin.tools"` | One or more prepared plugin-backed tools, each with its own `plugin_id`. |
|
||||
|
||||
## 2. API-side preparation contract
|
||||
|
||||
### 2.1 `Tool.get_merged_runtime_parameters(...)`
|
||||
|
||||
File:
|
||||
|
||||
```text
|
||||
api/core/tools/__base/tool.py
|
||||
```
|
||||
|
||||
Spec:
|
||||
|
||||
- Start from the tool entity's declared parameters.
|
||||
- Fetch runtime parameters with `get_runtime_parameters(...)`.
|
||||
- Runtime parameters override declared parameters with the same `name`.
|
||||
- Runtime parameters with new names are appended.
|
||||
- All returned parameters are deep-copied and detached from cached tool
|
||||
declarations.
|
||||
|
||||
Invariant:
|
||||
|
||||
- Callers can safely mutate the returned parameter list while preparing schemas
|
||||
or downstream config.
|
||||
- The merge is owned by API-side tool logic, not by `dify-agent`.
|
||||
|
||||
### 2.2 `Tool.get_llm_parameters_json_schema(...)`
|
||||
|
||||
File:
|
||||
|
||||
```text
|
||||
api/core/tools/__base/tool.py
|
||||
```
|
||||
|
||||
Spec:
|
||||
|
||||
- Build the model-visible JSON schema from the effective parameters returned by
|
||||
`get_merged_runtime_parameters(...)`.
|
||||
- Include only parameters with `form == LLM`.
|
||||
- Exclude file-like inputs that should not be directly supplied by the model:
|
||||
- `system-files`
|
||||
- `file`
|
||||
- `files`
|
||||
- Use `parameter.input_schema` when present.
|
||||
- Otherwise derive a schema from `parameter.type.as_normal_type()`.
|
||||
- Preserve `llm_description` as the JSON schema `description`.
|
||||
- Add enum values for select options.
|
||||
- Add required LLM parameters to the schema `required` list.
|
||||
|
||||
Invariant:
|
||||
|
||||
- Hidden/manual parameters remain available for invocation preparation but are
|
||||
omitted from the model-facing schema.
|
||||
- This helper is the API-side source of truth for normal tool schema generation.
|
||||
|
||||
### 2.3 `BaseAgentRunner` call sites
|
||||
|
||||
File:
|
||||
|
||||
```text
|
||||
api/core/agent/base_agent_runner.py
|
||||
```
|
||||
|
||||
Normal agent tool conversion now uses:
|
||||
|
||||
```python
|
||||
tool_entity.get_llm_parameters_json_schema()
|
||||
```
|
||||
|
||||
and prompt-tool updates use:
|
||||
|
||||
```python
|
||||
prompt_tool.parameters = tool.get_llm_parameters_json_schema()
|
||||
```
|
||||
|
||||
Invariant:
|
||||
|
||||
- `BaseAgentRunner` no longer carries duplicated normal-tool schema-building
|
||||
logic.
|
||||
- Schema branch behavior belongs in `Tool`, while the runner only wires the
|
||||
prepared schema into `PromptMessageTool`.
|
||||
|
||||
Note:
|
||||
|
||||
- Dataset retriever tools still have their own existing prompt-tool conversion
|
||||
path. That path is outside this plugin-tools preparation split.
|
||||
|
||||
## 3. Public `dify-agent` config spec
|
||||
|
||||
File:
|
||||
|
||||
```text
|
||||
dify-agent/src/dify_agent/layers/dify_plugin/configs.py
|
||||
```
|
||||
|
||||
### 3.1 `DifyPluginLayerConfig`
|
||||
|
||||
```python
|
||||
class DifyPluginLayerConfig(LayerConfig):
|
||||
tenant_id: str
|
||||
user_id: str | None = None
|
||||
```
|
||||
|
||||
Spec:
|
||||
|
||||
- Represents only shared tenant/user context for plugin-daemon calls.
|
||||
- Does not contain `plugin_id`.
|
||||
- Does not contain daemon URL or daemon API key.
|
||||
|
||||
Invariants:
|
||||
|
||||
- Daemon URL/API key are server-side runtime settings and must not be accepted
|
||||
from public run payloads.
|
||||
- `extra="forbid"` rejects obsolete or unknown public fields.
|
||||
- The layer can be shared by multiple LLM/tool business layers targeting
|
||||
different plugin ids.
|
||||
|
||||
### 3.2 `DifyPluginLLMLayerConfig`
|
||||
|
||||
```python
|
||||
class DifyPluginLLMLayerConfig(LayerConfig):
|
||||
plugin_id: str
|
||||
model_provider: str
|
||||
model: str
|
||||
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
|
||||
model_settings: ModelSettings | None = None
|
||||
```
|
||||
|
||||
Spec:
|
||||
|
||||
- Selects a plugin-backed LLM provider/model.
|
||||
- Owns the LLM plugin id.
|
||||
- Carries scalar credentials and optional Pydantic AI model settings.
|
||||
|
||||
Invariants:
|
||||
|
||||
- `plugin_id` identifies daemon/plugin transport for the LLM plugin.
|
||||
- `model_provider` is request-level business model identity, for example
|
||||
`"openai"`.
|
||||
- Credentials are scalar values only: `str | int | float | bool | None`.
|
||||
- Old `provider` config is rejected.
|
||||
|
||||
### 3.3 Prepared tool parameter DTOs
|
||||
|
||||
`dify-agent` exposes client-safe DTOs for the prepared declarations it receives
|
||||
from API-side preparation:
|
||||
|
||||
```python
|
||||
class DifyPluginToolOption(BaseModel):
|
||||
value: str
|
||||
|
||||
class DifyPluginToolParameterType(StrEnum):
|
||||
STRING = "string"
|
||||
NUMBER = "number"
|
||||
BOOLEAN = "boolean"
|
||||
SELECT = "select"
|
||||
SECRET_INPUT = "secret-input"
|
||||
FILE = "file"
|
||||
FILES = "files"
|
||||
APP_SELECTOR = "app-selector"
|
||||
MODEL_SELECTOR = "model-selector"
|
||||
ANY = "any"
|
||||
DYNAMIC_SELECT = "dynamic-select"
|
||||
CHECKBOX = "checkbox"
|
||||
SYSTEM_FILES = "system-files"
|
||||
ARRAY = "array"
|
||||
OBJECT = "object"
|
||||
|
||||
class DifyPluginToolParameterForm(StrEnum):
|
||||
SCHEMA = "schema"
|
||||
FORM = "form"
|
||||
LLM = "llm"
|
||||
|
||||
class DifyPluginToolParameter(BaseModel):
|
||||
name: str
|
||||
type: DifyPluginToolParameterType
|
||||
form: DifyPluginToolParameterForm
|
||||
required: bool = False
|
||||
default: DifyPluginToolValue = None
|
||||
llm_description: str | None = None
|
||||
input_schema: dict[str, JsonValue] | None = None
|
||||
options: list[DifyPluginToolOption] = Field(default_factory=list)
|
||||
```
|
||||
|
||||
Spec:
|
||||
|
||||
- These DTOs describe the API-prepared effective parameter declarations that the
|
||||
agent runtime needs for hidden/manual validation, default application, and
|
||||
daemon-facing type coercion.
|
||||
|
||||
Invariant:
|
||||
|
||||
- These DTOs are not used by `dify-agent` to rebuild the model-facing schema;
|
||||
the model-facing schema is supplied directly as `parameters_json_schema`.
|
||||
|
||||
### 3.4 `DifyPluginToolConfig`
|
||||
|
||||
```python
|
||||
class DifyPluginToolConfig(LayerConfig):
|
||||
plugin_id: str
|
||||
provider: str
|
||||
tool_name: str
|
||||
credential_type: DifyPluginToolCredentialType
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
|
||||
runtime_parameters: dict[str, DifyPluginToolValue] = Field(default_factory=dict)
|
||||
parameters: list[DifyPluginToolParameter] = Field(default_factory=list)
|
||||
parameters_json_schema: dict[str, JsonValue] = Field(
|
||||
default_factory=lambda: {"type": "object", "properties": {}, "required": []}
|
||||
)
|
||||
strict: bool | None = None
|
||||
```
|
||||
|
||||
Spec:
|
||||
|
||||
- Describes one prepared plugin tool to expose to the agent.
|
||||
- `plugin_id` is the plugin that provides this tool.
|
||||
- `provider` is the provider id inside the plugin.
|
||||
- `tool_name` is the daemon-declared tool name used for invocation.
|
||||
- `credential_type` is the daemon credential transport mode.
|
||||
- `name` optionally overrides the agent-visible tool name.
|
||||
- `description` optionally overrides the agent-visible description.
|
||||
- `runtime_parameters` supplies hidden/manual invocation inputs.
|
||||
- `parameters` supplies API-prepared effective declarations for invocation-time
|
||||
validation/defaults/type coercion.
|
||||
- `parameters_json_schema` supplies API-prepared model-visible JSON schema.
|
||||
- `strict` is forwarded to Pydantic AI tool definition semantics.
|
||||
|
||||
Credential invariant:
|
||||
|
||||
- `credential_type` is explicit caller-supplied daemon transport mode, not a
|
||||
value inferred from provider metadata.
|
||||
- It must match the supplied credentials, for example `"api-key"`, `"oauth2"`,
|
||||
or `"unauthorized"`.
|
||||
- A wrong value can make daemon invocation fail at runtime even when local config
|
||||
validation succeeds.
|
||||
|
||||
Prepared-config invariant:
|
||||
|
||||
- `dify-agent` trusts `parameters_json_schema` as the model-visible schema.
|
||||
- `dify-agent` does not fetch provider declarations or runtime parameters to
|
||||
repair or regenerate the schema at run time.
|
||||
|
||||
### 3.5 `DifyPluginToolsLayerConfig`
|
||||
|
||||
```python
|
||||
class DifyPluginToolsLayerConfig(LayerConfig):
|
||||
tools: list[DifyPluginToolConfig] = Field(default_factory=list)
|
||||
```
|
||||
|
||||
Spec:
|
||||
|
||||
- Carries the list of plugin tools contributed by one `dify.plugin.tools` layer.
|
||||
- Empty tool lists are valid.
|
||||
|
||||
Invariants:
|
||||
|
||||
- Individual tools may refer to different plugin ids.
|
||||
- Duplicate tool-name validation happens after all static and dynamic tools are
|
||||
aggregated by the runner.
|
||||
|
||||
## 4. Client-safe import boundary
|
||||
|
||||
`dify_agent.layers.dify_plugin` exports only client-safe DTOs and stable type ids:
|
||||
|
||||
- `DIFY_PLUGIN_LAYER_TYPE_ID`
|
||||
- `DIFY_PLUGIN_LLM_LAYER_TYPE_ID`
|
||||
- `DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID`
|
||||
- `DifyPluginCredentialValue`
|
||||
- `DifyPluginLLMLayerConfig`
|
||||
- `DifyPluginLayerConfig`
|
||||
- `DifyPluginToolCredentialType`
|
||||
- `DifyPluginToolConfig`
|
||||
- `DifyPluginToolOption`
|
||||
- `DifyPluginToolParameter`
|
||||
- `DifyPluginToolParameterForm`
|
||||
- `DifyPluginToolParameterType`
|
||||
- `DifyPluginToolsLayerConfig`
|
||||
- `DifyPluginToolValue`
|
||||
|
||||
It intentionally does not export implementation layers, daemon clients, runtime
|
||||
objects, or server-only modules. Client code can build run requests without
|
||||
pulling in server dependencies.
|
||||
|
||||
## 5. Runtime plugin context layer
|
||||
|
||||
`DifyPluginLayer` carries shared plugin-daemon identity:
|
||||
|
||||
```python
|
||||
@dataclass(slots=True)
|
||||
class DifyPluginLayer(PlainLayer[NoLayerDeps, DifyPluginLayerConfig, EmptyRuntimeState]):
|
||||
config: DifyPluginLayerConfig
|
||||
daemon_url: str
|
||||
daemon_api_key: str
|
||||
```
|
||||
|
||||
Construction spec:
|
||||
|
||||
- `from_config(...)` rejects plain construction because daemon settings must be
|
||||
injected by server provider factories.
|
||||
- `from_config_with_settings(...)` constructs the layer from public config plus
|
||||
server-only daemon URL/API key.
|
||||
|
||||
Factory methods:
|
||||
|
||||
```python
|
||||
def create_daemon_provider(*, plugin_id: str, http_client: httpx.AsyncClient) -> DifyPluginDaemonProvider
|
||||
def create_tool_client(*, plugin_id: str, http_client: httpx.AsyncClient) -> DifyPluginDaemonToolClient
|
||||
```
|
||||
|
||||
Invariants:
|
||||
|
||||
- The caller supplies the concrete `plugin_id`.
|
||||
- The passed HTTP client must be open.
|
||||
- The layer never opens, caches, closes, serializes, or snapshots live HTTP
|
||||
clients.
|
||||
|
||||
## 6. LLM layer
|
||||
|
||||
`DifyPluginLLMLayer` depends directly on `DifyPluginLayer`:
|
||||
|
||||
```python
|
||||
class DifyPluginLLMDeps(LayerDeps):
|
||||
plugin: DifyPluginLayer
|
||||
```
|
||||
|
||||
`get_model(...)` asks the plugin layer to create a daemon provider using the LLM
|
||||
layer's own `plugin_id`, then builds a `DifyLLMAdapterModel` from configured
|
||||
model provider, model, credentials, and settings.
|
||||
|
||||
Invariants:
|
||||
|
||||
- Daemon transport identity is derived from shared plugin context plus LLM
|
||||
`plugin_id`.
|
||||
- Business model provider identity remains request-level model data.
|
||||
- The shared HTTP client comes from the runner.
|
||||
|
||||
## 7. Tools layer flow in `dify-agent`
|
||||
|
||||
File:
|
||||
|
||||
```text
|
||||
dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py
|
||||
```
|
||||
|
||||
`DifyPluginToolsLayer` depends directly on `DifyPluginLayer`:
|
||||
|
||||
```python
|
||||
class DifyPluginToolsDeps(LayerDeps):
|
||||
plugin: DifyPluginLayer
|
||||
```
|
||||
|
||||
`get_tools(http_client=...)` resolves prepared plugin tool configs into Pydantic
|
||||
AI `Tool` objects. For each tool config it:
|
||||
|
||||
1. Creates or reuses a `DifyPluginDaemonToolClient` for `tool_config.plugin_id`.
|
||||
2. Deep-copies `tool_config.parameters` into effective invocation parameters.
|
||||
3. Validates required hidden/manual parameters.
|
||||
4. Builds a Pydantic AI tool whose model schema is a deep copy of
|
||||
`tool_config.parameters_json_schema`.
|
||||
|
||||
Invariants:
|
||||
|
||||
- No provider metadata is fetched.
|
||||
- No runtime-parameter endpoint is called.
|
||||
- No declaration/schema merge happens in `dify-agent`.
|
||||
- Per-tool `plugin_id` chooses the tool daemon transport identity.
|
||||
|
||||
## 8. Hidden/manual parameter validation
|
||||
|
||||
`_validate_required_hidden_parameters(...)` checks prepared parameters before
|
||||
tool construction.
|
||||
|
||||
Spec:
|
||||
|
||||
- For parameters where `form != LLM`, if the parameter is required, has no
|
||||
default, and is not present in `runtime_parameters`, construction fails with a
|
||||
validation error.
|
||||
|
||||
Invariant:
|
||||
|
||||
- Required hidden/manual inputs must be provided by config or by prepared
|
||||
defaults before the agent can expose the tool.
|
||||
|
||||
## 9. Invocation argument preparation
|
||||
|
||||
`_prepare_tool_arguments(...)` combines prepared config and model tool args.
|
||||
|
||||
Argument precedence:
|
||||
|
||||
1. Start from config-supplied `runtime_parameters` for hidden/manual inputs.
|
||||
2. Model-supplied tool arguments override same-named entries.
|
||||
3. If no value was supplied, use the prepared parameter default.
|
||||
4. If a required parameter still has no value, raise a validation error.
|
||||
|
||||
Spec:
|
||||
|
||||
- Declared parameters are type-cast into daemon-facing wire shapes.
|
||||
- Extra merged keys not present in `parameters` pass through unchanged.
|
||||
|
||||
Invariant:
|
||||
|
||||
- The prepared parameter list is still required even though schema generation
|
||||
moved to the API side, because the runtime must validate hidden/manual inputs,
|
||||
apply defaults, and normalize invocation payload values.
|
||||
|
||||
## 10. Invocation type coercion
|
||||
|
||||
`_cast_tool_parameter_value(...)` normalizes values before daemon invocation.
|
||||
|
||||
Rules:
|
||||
|
||||
| Parameter type | Runtime coercion |
|
||||
| --- | --- |
|
||||
| `string`, `secret-input`, `select`, `checkbox`, `dynamic-select` | `None` -> empty string; non-string -> `str(value)` |
|
||||
| `boolean` | common truthy/falsey strings are parsed; otherwise bool-like coercion |
|
||||
| `number` | numeric values pass through; numeric strings become int/float |
|
||||
| `files`, `system-files` | non-list values are wrapped in a list |
|
||||
| `file` | list must contain exactly one item; otherwise error |
|
||||
| `model-selector`, `app-selector` | must be a dict |
|
||||
| `any` | must be JSON-like if not `None` |
|
||||
| `array` | list passes through; string tries JSON parse; otherwise wraps in list |
|
||||
| `object` | dict passes through; string tries JSON parse; invalid strings become `{}` |
|
||||
|
||||
Invariant:
|
||||
|
||||
- Unexpected local validation/coercion errors are not swallowed by a blanket
|
||||
catch; only expected daemon/tool validation paths become agent observations.
|
||||
|
||||
## 11. Pydantic AI tool adapter
|
||||
|
||||
`_build_pydantic_ai_tool(...)` creates a Pydantic AI `Tool` with:
|
||||
|
||||
- an invocation closure that prepares daemon arguments, invokes the plugin tool,
|
||||
and converts daemon stream messages to observation text;
|
||||
- a prepare closure that sets `parameters_json_schema` from the API-prepared
|
||||
`tool_config.parameters_json_schema`.
|
||||
|
||||
Invariants:
|
||||
|
||||
- The tool name is `tool_config.name or tool_config.tool_name`.
|
||||
- The tool description is `tool_config.description or tool_name`.
|
||||
- The model-facing schema is not rebuilt from parameter declarations.
|
||||
- Expected `DifyPluginToolClientError` and local `ValueError` are converted into
|
||||
agent-facing text.
|
||||
- Unexpected local errors propagate.
|
||||
|
||||
## 12. Invoke-only daemon tool client
|
||||
|
||||
File:
|
||||
|
||||
```text
|
||||
dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py
|
||||
```
|
||||
|
||||
`DifyPluginDaemonToolClient` now exposes only invocation:
|
||||
|
||||
```python
|
||||
async def invoke(
|
||||
*,
|
||||
provider: str,
|
||||
tool_name: str,
|
||||
credential_type: DifyPluginToolCredentialType,
|
||||
credentials: dict[str, object],
|
||||
tool_parameters: Mapping[str, object],
|
||||
) -> list[DifyPluginToolInvokeMessage]
|
||||
```
|
||||
|
||||
Daemon endpoint:
|
||||
|
||||
```text
|
||||
POST /plugin/{tenant_id}/dispatch/tool/invoke
|
||||
```
|
||||
|
||||
Headers:
|
||||
|
||||
```text
|
||||
X-Api-Key: daemon api key
|
||||
X-Plugin-ID: per-tool plugin id
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Payload shape:
|
||||
|
||||
```python
|
||||
{
|
||||
"data": {
|
||||
"provider": provider,
|
||||
"tool": tool_name,
|
||||
"credentials": credentials,
|
||||
"credential_type": credential_type,
|
||||
"tool_parameters": dict(tool_parameters),
|
||||
},
|
||||
"user_id": user_id, # only when configured on shared plugin context
|
||||
}
|
||||
```
|
||||
|
||||
Invariants:
|
||||
|
||||
- `tenant_id` comes from shared plugin context and appears in the path.
|
||||
- `user_id` comes from shared plugin context and is forwarded top-level when
|
||||
present.
|
||||
- `X-Plugin-ID` comes from the individual tool config's `plugin_id`.
|
||||
- Provider/tool/credentials/credential type/parameters are per invocation.
|
||||
|
||||
## 13. Shared plugin-daemon transport helpers
|
||||
|
||||
`dify_agent.plugin_daemon_transport` contains daemon-transport behavior shared by
|
||||
LLM and tools clients:
|
||||
|
||||
```python
|
||||
def to_plugin_daemon_jsonable(value: object) -> object
|
||||
def decode_plugin_daemon_error_payload(raw_message: str) -> PluginDaemonErrorPayload | None
|
||||
def unwrap_plugin_daemon_error(*, error_type: str, message: str) -> PluginDaemonErrorPayload
|
||||
```
|
||||
|
||||
Spec:
|
||||
|
||||
- Convert Pydantic models and nested collections to JSON-safe daemon payloads.
|
||||
- Decode daemon JSON error strings shaped like `{"error_type": ..., "message": ...}`.
|
||||
- Recursively unwrap nested `PluginInvokeError` payloads.
|
||||
|
||||
Invariant:
|
||||
|
||||
- LLM and tools daemon adapters must not duplicate this protocol logic.
|
||||
|
||||
## 14. Tool stream messages and blob chunks
|
||||
|
||||
`DifyPluginToolInvokeMessage` models the daemon stream message subset needed for
|
||||
agent observations: text, JSON, image, links, variables, logs, file/blob
|
||||
messages, and blob chunks.
|
||||
|
||||
`merge_blob_chunks(...)` merges streamed `blob_chunk` messages into final `blob`
|
||||
messages before higher-level observation conversion.
|
||||
|
||||
Invariants:
|
||||
|
||||
- Chunks are grouped by id.
|
||||
- Each file is capped at 30MB.
|
||||
- Each chunk is capped at 8KB.
|
||||
- Completed chunks become a single `BLOB` message.
|
||||
- Higher-level observation conversion does not see raw chunk sequence details.
|
||||
|
||||
## 15. Observation conversion and error mapping
|
||||
|
||||
`_convert_tool_response_to_text(...)` maps daemon messages into text for the
|
||||
agent:
|
||||
|
||||
- text messages append their text;
|
||||
- link messages become a user-checkable link instruction;
|
||||
- image messages become a user-checkable image instruction;
|
||||
- JSON messages are serialized unless suppressed;
|
||||
- variable messages are ignored;
|
||||
- unknown messages fall back to `str(message)`;
|
||||
- JSON fragments are deduplicated against existing text.
|
||||
|
||||
`_tool_error_text(...)` maps expected daemon invocation errors into agent-facing
|
||||
text:
|
||||
|
||||
- credential/authorization errors -> `Please check your tool provider credentials`;
|
||||
- tool/provider not found -> `there is not a tool named {tool_name}`;
|
||||
- validation/bad-request errors -> `tool parameters validation error: ...`;
|
||||
- other daemon errors -> `tool invoke error: ...`.
|
||||
|
||||
Invariant:
|
||||
|
||||
- Known daemon/tool rejections are softened into observations.
|
||||
- Unexpected local bugs are not caught by the tool adapter and should fail
|
||||
loudly.
|
||||
|
||||
## 16. Runner integration
|
||||
|
||||
The runner resolves tools with `_resolve_run_tools(...)`:
|
||||
|
||||
1. Start with static compositor tools from `run.tools`.
|
||||
2. Traverse run slots and call `get_tools(...)` on every `DifyPluginToolsLayer`.
|
||||
3. Validate aggregate tool-name uniqueness.
|
||||
4. Pass the final list to Pydantic AI agent construction.
|
||||
|
||||
Invariant:
|
||||
|
||||
- Duplicate tool names are rejected across all sources: static tools, one tools
|
||||
layer, or multiple tools layers.
|
||||
- Validation happens before Pydantic AI agent construction so conflicts are
|
||||
reported as run validation errors.
|
||||
|
||||
## 17. Provider factory and lifecycle
|
||||
|
||||
`create_default_layer_providers(...)` includes providers for:
|
||||
|
||||
- prompt layers;
|
||||
- history layer;
|
||||
- output layer;
|
||||
- `DifyPluginLayer` through a server-settings factory;
|
||||
- `DifyPluginLLMLayer`;
|
||||
- `DifyPluginToolsLayer`.
|
||||
|
||||
Lifecycle invariant:
|
||||
|
||||
- FastAPI/server runtime owns the shared plugin daemon HTTP client.
|
||||
- Runner passes the shared client to LLM/tools layers.
|
||||
- Layers and snapshots remain state-only and never own live resources.
|
||||
|
||||
## 18. Usage example
|
||||
|
||||
```python
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DifyPluginLayerConfig,
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolParameter,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.protocol.schemas import RunComposition, RunLayerSpec
|
||||
|
||||
composition = RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(
|
||||
name="plugin",
|
||||
type="dify.plugin",
|
||||
config=DifyPluginLayerConfig(tenant_id="tenant-1", user_id="user-1"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="llm",
|
||||
type="dify.plugin.llm",
|
||||
deps={"plugin": "plugin"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-4o-mini",
|
||||
credentials={"api_key": "replace-with-model-key"},
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="tools",
|
||||
type="dify.plugin.tools",
|
||||
deps={"plugin": "plugin"},
|
||||
config=DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/search",
|
||||
provider="search",
|
||||
tool_name="web_search",
|
||||
credential_type="api-key",
|
||||
credentials={"api_key": "replace-with-tool-key"},
|
||||
runtime_parameters={"site": "docs.dify.ai"},
|
||||
parameters=[
|
||||
DifyPluginToolParameter(
|
||||
name="query",
|
||||
type="string",
|
||||
form="llm",
|
||||
required=True,
|
||||
llm_description="Search query",
|
||||
)
|
||||
],
|
||||
parameters_json_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
In production, API-side preparation should fill `parameters` and
|
||||
`parameters_json_schema` from the effective Dify tool declaration before the
|
||||
composition is submitted to `dify-agent`.
|
||||
|
||||
## 19. Test coverage
|
||||
|
||||
The local tests now cover:
|
||||
|
||||
- API-side runtime-parameter merge semantics in `Tool.get_merged_runtime_parameters(...)`;
|
||||
- API-side LLM JSON schema generation in `Tool.get_llm_parameters_json_schema(...)`;
|
||||
- `BaseAgentRunner` using the API-side schema helper instead of inline schema logic;
|
||||
- client-safe exports and DTO validation;
|
||||
- explicit tool `credential_type` requirement;
|
||||
- prepared `parameters` and `parameters_json_schema` in public request payloads;
|
||||
- plugin layer shared HTTP client behavior and closed-client rejection;
|
||||
- LLM layer model construction from direct plugin dependency;
|
||||
- prepared plugin tools being converted to Pydantic AI tools;
|
||||
- required hidden/manual parameter validation;
|
||||
- defaults being applied during invocation;
|
||||
- daemon-facing type coercion for non-string prepared parameter types;
|
||||
- per-tool `plugin_id` driving `X-Plugin-ID` for multiple tools;
|
||||
- shared `user_id` being forwarded in tool invocation payloads;
|
||||
- agent-friendly daemon/tool error text;
|
||||
- nested `PluginInvokeError` unwrapping;
|
||||
- blob chunk merging before observation conversion;
|
||||
- unexpected local/transport failures propagating instead of being swallowed;
|
||||
- dynamic plugin tools being passed to the runner's agent;
|
||||
- duplicate tool names across dynamic layers and static/dynamic tools;
|
||||
- import boundary safety.
|
||||
|
||||
## 20. Non-goals and boundaries
|
||||
|
||||
- The agent does not import provider SDKs directly; all LLM/tool execution goes
|
||||
through the plugin daemon.
|
||||
- The agent does not infer `credential_type` from provider metadata.
|
||||
- The agent does not store provider credential schema or OAuth schema in its tool
|
||||
client DTOs.
|
||||
- The agent does not fetch provider declarations or runtime parameters for tools
|
||||
at execution time.
|
||||
- The agent does not rebuild model-facing tool JSON schemas from declarations.
|
||||
- The agent does not persist daemon clients or HTTP clients in session snapshots.
|
||||
- Local tests mock daemon contracts and do not prove real daemon integration.
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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"],
|
||||
# },
|
||||
# )
|
||||
# ]
|
||||
# ),
|
||||
# ),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@ -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"],
|
||||
# },
|
||||
# )
|
||||
# ]
|
||||
# ),
|
||||
# ),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
328
dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py
Normal file
328
dify-agent/src/dify_agent/layers/dify_plugin/tool_client.py
Normal file
@ -0,0 +1,328 @@
|
||||
"""Async plugin-daemon client for Dify plugin tool invocation.
|
||||
|
||||
The agent runtime talks to the plugin daemon rather than importing provider SDKs
|
||||
directly. The tools layer now consumes API-prepared declarations from config, so
|
||||
this module only keeps the invoke-time boundary:
|
||||
|
||||
- POST ``/plugin/{tenant_id}/dispatch/tool/invoke``
|
||||
- request headers ``X-Api-Key``, ``X-Plugin-ID``, and ``Content-Type``
|
||||
- top-level ``user_id`` forwarding when shared plugin context includes one
|
||||
- stream decoding and blob-chunk merging for agent observations
|
||||
|
||||
The shared plugin layer still owns tenant/user daemon context, while each tool's
|
||||
own ``plugin_id`` determines the transport identity placed in ``X-Plugin-ID``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator
|
||||
|
||||
from dify_agent.layers.dify_plugin.configs import DifyPluginToolCredentialType
|
||||
from dify_agent.plugin_daemon_transport import (
|
||||
decode_plugin_daemon_error_payload,
|
||||
to_plugin_daemon_jsonable,
|
||||
unwrap_plugin_daemon_error,
|
||||
)
|
||||
|
||||
|
||||
class PluginDaemonBasicResponse(BaseModel):
|
||||
"""Common plugin-daemon stream and JSON wrapper."""
|
||||
|
||||
code: int
|
||||
message: str
|
||||
data: object | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FileChunk:
|
||||
"""Buffer for accumulating streamed blob chunks."""
|
||||
|
||||
total_length: int
|
||||
bytes_written: int = field(default=0, init=False)
|
||||
data: bytearray = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.data = bytearray(self.total_length)
|
||||
|
||||
|
||||
class DifyPluginToolInvokeMessage(BaseModel):
|
||||
"""Subset of Dify tool stream messages needed for agent observations."""
|
||||
|
||||
class TextMessage(BaseModel):
|
||||
text: str
|
||||
|
||||
class JsonMessage(BaseModel):
|
||||
json_object: dict[str, object] | list[object]
|
||||
suppress_output: bool = False
|
||||
|
||||
class BlobMessage(BaseModel):
|
||||
blob: bytes
|
||||
|
||||
class BlobChunkMessage(BaseModel):
|
||||
id: str
|
||||
sequence: int
|
||||
total_length: int
|
||||
blob: bytes
|
||||
end: bool
|
||||
|
||||
class FileMessage(BaseModel):
|
||||
file_marker: str = "file_marker"
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def validate_file_marker(cls, values: object) -> object:
|
||||
if isinstance(values, dict) and "file_marker" not in values:
|
||||
raise ValueError("Invalid FileMessage: missing file_marker")
|
||||
return values
|
||||
|
||||
class VariableMessage(BaseModel):
|
||||
variable_name: str
|
||||
variable_value: object
|
||||
stream: bool = False
|
||||
|
||||
class LogMessage(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
parent_id: str | None = None
|
||||
error: str | None = None
|
||||
status: str
|
||||
data: Mapping[str, object] = Field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = Field(default_factory=dict)
|
||||
|
||||
class MessageType(StrEnum):
|
||||
TEXT = "text"
|
||||
IMAGE = "image"
|
||||
LINK = "link"
|
||||
BLOB = "blob"
|
||||
JSON = "json"
|
||||
IMAGE_LINK = "image_link"
|
||||
BINARY_LINK = "binary_link"
|
||||
VARIABLE = "variable"
|
||||
FILE = "file"
|
||||
LOG = "log"
|
||||
BLOB_CHUNK = "blob_chunk"
|
||||
|
||||
type: MessageType = MessageType.TEXT
|
||||
message: TextMessage | JsonMessage | BlobChunkMessage | BlobMessage | LogMessage | FileMessage | VariableMessage | None
|
||||
meta: dict[str, object] | None = None
|
||||
|
||||
@field_validator("message", mode="before")
|
||||
@classmethod
|
||||
def decode_message(cls, value: object, info: ValidationInfo) -> object:
|
||||
if isinstance(value, dict) and "blob" in value:
|
||||
try:
|
||||
value = {**value, "blob": base64.b64decode(value["blob"])}
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
msg_type = info.data.get("type") if isinstance(info.data, dict) else None
|
||||
if msg_type == cls.MessageType.JSON and isinstance(value, dict) and "json_object" not in value:
|
||||
return {"json_object": value}
|
||||
if msg_type == cls.MessageType.FILE and isinstance(value, dict):
|
||||
return {"file_marker": value.get("file_marker", "file_marker")}
|
||||
return value
|
||||
|
||||
|
||||
class DifyPluginToolClientError(Exception):
|
||||
"""Raised when the plugin daemon rejects a tool-layer request."""
|
||||
|
||||
error_type: str | None
|
||||
status_code: int | None
|
||||
|
||||
def __init__(self, message: str, *, error_type: str | None = None, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.error_type = error_type
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyPluginDaemonToolClient:
|
||||
"""HTTP wrapper for the invoke-only plugin-daemon tool boundary.
|
||||
|
||||
Callers provide business-level provider/tool/credential data per invocation,
|
||||
while this client supplies daemon transport identity from shared runtime
|
||||
context: tenant path segment, daemon API key, plugin-specific ``X-Plugin-ID``
|
||||
header, and optional top-level ``user_id``.
|
||||
"""
|
||||
|
||||
plugin_daemon_url: str
|
||||
plugin_daemon_api_key: str
|
||||
tenant_id: str
|
||||
plugin_id: str
|
||||
user_id: str | None
|
||||
http_client: httpx.AsyncClient = field(repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.plugin_daemon_url = self.plugin_daemon_url.rstrip("/")
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
*,
|
||||
provider: str,
|
||||
tool_name: str,
|
||||
credential_type: DifyPluginToolCredentialType,
|
||||
credentials: dict[str, object],
|
||||
tool_parameters: Mapping[str, object],
|
||||
) -> list[DifyPluginToolInvokeMessage]:
|
||||
"""Invoke a plugin tool and collect its observation stream."""
|
||||
raw_messages = [
|
||||
item
|
||||
async for item in self._iter_stream_response(
|
||||
path=f"plugin/{self.tenant_id}/dispatch/tool/invoke",
|
||||
request_data={
|
||||
"provider": provider,
|
||||
"tool": tool_name,
|
||||
"credentials": credentials,
|
||||
"credential_type": credential_type,
|
||||
"tool_parameters": dict(tool_parameters),
|
||||
},
|
||||
response_model=DifyPluginToolInvokeMessage,
|
||||
)
|
||||
]
|
||||
return merge_blob_chunks(raw_messages)
|
||||
|
||||
async def _iter_stream_response[T: BaseModel](
|
||||
self,
|
||||
*,
|
||||
path: str,
|
||||
request_data: Mapping[str, object],
|
||||
response_model: type[T],
|
||||
) -> AsyncIterator[T]:
|
||||
"""Send one daemon stream request and yield typed items.
|
||||
|
||||
The daemon expects the actual invoke payload nested under ``data``. When
|
||||
the shared plugin context included ``user_id``, it is forwarded as a
|
||||
top-level peer to ``data`` so daemon-side auditing and credential logic
|
||||
can attribute the request to the end user.
|
||||
"""
|
||||
payload: dict[str, object] = {"data": to_plugin_daemon_jsonable(dict(request_data))}
|
||||
if self.user_id is not None:
|
||||
payload["user_id"] = self.user_id
|
||||
|
||||
url = f"{self.plugin_daemon_url}/{path}"
|
||||
async with self.http_client.stream("POST", url, headers=self._headers(), json=payload) as response:
|
||||
if response.is_error:
|
||||
body = (await response.aread()).decode("utf-8", errors="replace")
|
||||
error = decode_plugin_daemon_error_payload(body)
|
||||
if error is not None:
|
||||
resolved_error = unwrap_plugin_daemon_error(
|
||||
error_type=error["error_type"],
|
||||
message=error["message"],
|
||||
)
|
||||
_raise_tool_daemon_error(
|
||||
error_type=resolved_error["error_type"],
|
||||
message=resolved_error["message"],
|
||||
status_code=response.status_code,
|
||||
)
|
||||
raise DifyPluginToolClientError(body or "Plugin daemon stream request failed.", status_code=response.status_code)
|
||||
|
||||
async for raw_line in response.aiter_lines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("data:"):
|
||||
line = line[5:].strip()
|
||||
|
||||
wrapped = PluginDaemonBasicResponse.model_validate_json(line)
|
||||
if wrapped.code != 0:
|
||||
error = decode_plugin_daemon_error_payload(wrapped.message)
|
||||
if error is not None:
|
||||
resolved_error = unwrap_plugin_daemon_error(
|
||||
error_type=error["error_type"],
|
||||
message=error["message"],
|
||||
)
|
||||
_raise_tool_daemon_error(
|
||||
error_type=resolved_error["error_type"],
|
||||
message=resolved_error["message"],
|
||||
)
|
||||
raise DifyPluginToolClientError(wrapped.message or "Plugin daemon returned an error stream item.")
|
||||
if wrapped.data is None:
|
||||
raise DifyPluginToolClientError("Plugin daemon returned an empty stream item.")
|
||||
yield response_model.model_validate(wrapped.data)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
"""Build required plugin-daemon transport headers for tool invocation."""
|
||||
return {
|
||||
"X-Api-Key": self.plugin_daemon_api_key,
|
||||
"X-Plugin-ID": self.plugin_id,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def merge_blob_chunks(
|
||||
response: list[DifyPluginToolInvokeMessage],
|
||||
*,
|
||||
max_file_size: int = 30 * 1024 * 1024,
|
||||
max_chunk_size: int = 8192,
|
||||
) -> list[DifyPluginToolInvokeMessage]:
|
||||
"""Merge streamed blob chunks into complete blob messages.
|
||||
|
||||
This mirrors Dify API's plugin-daemon chunk-merging behavior before the
|
||||
higher-level observation conversion logic sees tool stream messages.
|
||||
"""
|
||||
files: dict[str, FileChunk] = {}
|
||||
merged_messages: list[DifyPluginToolInvokeMessage] = []
|
||||
|
||||
for resp in response:
|
||||
if resp.type is DifyPluginToolInvokeMessage.MessageType.BLOB_CHUNK:
|
||||
if not isinstance(resp.message, DifyPluginToolInvokeMessage.BlobChunkMessage):
|
||||
raise TypeError("Blob chunk responses must carry BlobChunkMessage payloads.")
|
||||
|
||||
chunk_id = resp.message.id
|
||||
total_length = resp.message.total_length
|
||||
blob_data = resp.message.blob
|
||||
is_end = resp.message.end
|
||||
|
||||
if chunk_id not in files:
|
||||
files[chunk_id] = FileChunk(total_length)
|
||||
|
||||
if files[chunk_id].bytes_written + len(blob_data) > max_file_size:
|
||||
del files[chunk_id]
|
||||
raise ValueError(f"File is too large which reached the limit of {max_file_size / 1024 / 1024}MB")
|
||||
if len(blob_data) > max_chunk_size:
|
||||
raise ValueError(f"File chunk is too large which reached the limit of {max_chunk_size / 1024}KB")
|
||||
|
||||
files[chunk_id].data[files[chunk_id].bytes_written : files[chunk_id].bytes_written + len(blob_data)] = (
|
||||
blob_data
|
||||
)
|
||||
files[chunk_id].bytes_written += len(blob_data)
|
||||
|
||||
if is_end:
|
||||
merged_messages.append(
|
||||
DifyPluginToolInvokeMessage(
|
||||
type=DifyPluginToolInvokeMessage.MessageType.BLOB,
|
||||
message=DifyPluginToolInvokeMessage.BlobMessage(
|
||||
blob=bytes(files[chunk_id].data[: files[chunk_id].bytes_written])
|
||||
),
|
||||
meta=resp.meta,
|
||||
)
|
||||
)
|
||||
del files[chunk_id]
|
||||
else:
|
||||
merged_messages.append(resp)
|
||||
|
||||
return merged_messages
|
||||
|
||||
|
||||
def _raise_tool_daemon_error(
|
||||
*,
|
||||
error_type: str,
|
||||
message: str,
|
||||
status_code: int | None = None,
|
||||
) -> None:
|
||||
raise DifyPluginToolClientError(message, error_type=error_type, status_code=status_code)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DifyPluginDaemonToolClient",
|
||||
"DifyPluginToolClientError",
|
||||
"DifyPluginToolCredentialType",
|
||||
"DifyPluginToolInvokeMessage",
|
||||
"merge_blob_chunks",
|
||||
]
|
||||
331
dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py
Normal file
331
dify-agent/src/dify_agent/layers/dify_plugin/tools_layer.py
Normal file
@ -0,0 +1,331 @@
|
||||
"""Dify plugin tools layer for agent-accessible plugin tools.
|
||||
|
||||
This layer consumes API-prepared plugin tool declarations. The API side is
|
||||
responsible for resolving daemon declarations, applying runtime-parameter
|
||||
overrides, and producing the clean LLM-facing JSON schema. At run time the layer
|
||||
only validates hidden/manual inputs, prepares invocation arguments, and maps
|
||||
daemon responses into agent-friendly observations.
|
||||
|
||||
Like the LLM layer, this layer never owns live HTTP clients. The runtime passes
|
||||
the FastAPI lifespan-owned shared client into ``get_tools`` so the layer can
|
||||
build Pydantic AI tool adapters on demand.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from pydantic_ai import RunContext, Tool
|
||||
from pydantic_ai.tools import ToolDefinition
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import LayerDeps, PlainLayer
|
||||
from dify_agent.layers.dify_plugin.configs import (
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
DifyPluginToolConfig,
|
||||
DifyPluginToolParameter,
|
||||
DifyPluginToolParameterForm,
|
||||
DifyPluginToolParameterType,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.dify_plugin.plugin_layer import DifyPluginLayer
|
||||
from dify_agent.layers.dify_plugin.tool_client import (
|
||||
DifyPluginDaemonToolClient,
|
||||
DifyPluginToolClientError,
|
||||
DifyPluginToolInvokeMessage,
|
||||
)
|
||||
|
||||
|
||||
class DifyPluginToolsDeps(LayerDeps):
|
||||
"""Dependencies required by ``DifyPluginToolsLayer``."""
|
||||
|
||||
plugin: DifyPluginLayer # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyPluginToolsLayer(PlainLayer[DifyPluginToolsDeps, DifyPluginToolsLayerConfig]):
|
||||
"""Layer that resolves Dify plugin tools into Pydantic AI tools."""
|
||||
|
||||
type_id = DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID
|
||||
|
||||
config: DifyPluginToolsLayerConfig
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: DifyPluginToolsLayerConfig) -> Self:
|
||||
"""Create the tools layer from validated public config."""
|
||||
return cls(config=DifyPluginToolsLayerConfig.model_validate(config))
|
||||
|
||||
async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
|
||||
"""Build Pydantic AI tool adapters from prepared plugin tool config."""
|
||||
tool_clients: dict[str, DifyPluginDaemonToolClient] = {}
|
||||
tools: list[Tool[object]] = []
|
||||
|
||||
for tool_config in self.config.tools:
|
||||
client = tool_clients.get(tool_config.plugin_id)
|
||||
if client is None:
|
||||
client = self.deps.plugin.create_tool_client(plugin_id=tool_config.plugin_id, http_client=http_client)
|
||||
tool_clients[tool_config.plugin_id] = client
|
||||
effective_parameters = [parameter.model_copy(deep=True) for parameter in tool_config.parameters]
|
||||
_validate_required_hidden_parameters(tool_config, effective_parameters)
|
||||
|
||||
tools.append(
|
||||
_build_pydantic_ai_tool(
|
||||
client=client,
|
||||
tool_config=tool_config,
|
||||
effective_parameters=effective_parameters,
|
||||
)
|
||||
)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def _validate_required_hidden_parameters(
|
||||
tool_config: DifyPluginToolConfig,
|
||||
effective_parameters: Sequence[DifyPluginToolParameter],
|
||||
) -> None:
|
||||
missing_names = [
|
||||
parameter.name
|
||||
for parameter in effective_parameters
|
||||
if parameter.form is not DifyPluginToolParameterForm.LLM
|
||||
and parameter.required
|
||||
and parameter.default is None
|
||||
and parameter.name not in tool_config.runtime_parameters
|
||||
]
|
||||
if missing_names:
|
||||
names = ", ".join(sorted(missing_names))
|
||||
raise ValueError(
|
||||
f"Tool '{tool_config.tool_name}' requires non-LLM runtime_parameters for: {names}."
|
||||
)
|
||||
|
||||
|
||||
def _build_pydantic_ai_tool(
|
||||
*,
|
||||
client: DifyPluginDaemonToolClient,
|
||||
tool_config: DifyPluginToolConfig,
|
||||
effective_parameters: Sequence[DifyPluginToolParameter],
|
||||
) -> Tool[object]:
|
||||
tool_name = tool_config.name or tool_config.tool_name
|
||||
tool_description = tool_config.description or tool_name
|
||||
tool_schema = deepcopy(tool_config.parameters_json_schema)
|
||||
|
||||
async def invoke_tool(_ctx: RunContext[object], **tool_arguments: object) -> str:
|
||||
try:
|
||||
merged_arguments = _prepare_tool_arguments(effective_parameters, tool_config, tool_arguments)
|
||||
messages = await client.invoke(
|
||||
provider=tool_config.provider,
|
||||
tool_name=tool_config.tool_name,
|
||||
credential_type=tool_config.credential_type,
|
||||
credentials=dict(tool_config.credentials),
|
||||
tool_parameters=merged_arguments,
|
||||
)
|
||||
return _convert_tool_response_to_text(messages)
|
||||
except DifyPluginToolClientError as exc:
|
||||
return _tool_error_text(tool_name=tool_name, error=exc)
|
||||
except ValueError as exc:
|
||||
return f"tool parameters validation error: {exc}, please check your tool parameters"
|
||||
|
||||
async def prepare_tool_definition(_ctx: RunContext[object], tool_def: ToolDefinition) -> ToolDefinition:
|
||||
return ToolDefinition(
|
||||
name=tool_def.name,
|
||||
description=tool_def.description,
|
||||
parameters_json_schema=tool_schema,
|
||||
strict=tool_config.strict,
|
||||
sequential=tool_def.sequential,
|
||||
metadata=tool_def.metadata,
|
||||
timeout=tool_def.timeout,
|
||||
defer_loading=tool_def.defer_loading,
|
||||
kind=tool_def.kind,
|
||||
return_schema=tool_def.return_schema,
|
||||
include_return_schema=tool_def.include_return_schema,
|
||||
)
|
||||
|
||||
return Tool(
|
||||
invoke_tool,
|
||||
takes_ctx=True,
|
||||
name=tool_name,
|
||||
description=tool_description,
|
||||
prepare=prepare_tool_definition,
|
||||
strict=tool_config.strict,
|
||||
)
|
||||
|
||||
|
||||
def _prepare_tool_arguments(
|
||||
effective_parameters: Sequence[DifyPluginToolParameter],
|
||||
tool_config: DifyPluginToolConfig,
|
||||
tool_arguments: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""Build the daemon invocation payload from prepared config + model args.
|
||||
|
||||
Argument precedence intentionally mirrors the old Dify tool runtime contract:
|
||||
|
||||
1. start from config-supplied ``runtime_parameters`` for hidden/manual inputs;
|
||||
2. let model-supplied tool arguments override same-named entries;
|
||||
3. if neither provided a value, fall back to the prepared parameter default;
|
||||
4. if a required parameter still has no value, raise validation error.
|
||||
|
||||
Only parameters declared in ``effective_parameters`` are type-cast here;
|
||||
extra merged keys are passed through unchanged for forward compatibility with
|
||||
prepared config that may contain additional daemon inputs.
|
||||
"""
|
||||
merged_arguments: dict[str, object] = dict(tool_config.runtime_parameters)
|
||||
merged_arguments.update(tool_arguments)
|
||||
prepared_arguments: dict[str, object] = {}
|
||||
|
||||
for parameter in effective_parameters:
|
||||
if parameter.name in merged_arguments:
|
||||
value = merged_arguments[parameter.name]
|
||||
elif parameter.default is not None:
|
||||
value = parameter.default
|
||||
elif parameter.required:
|
||||
raise ValueError(f"tool parameter {parameter.name} not found in tool config")
|
||||
else:
|
||||
continue
|
||||
prepared_arguments[parameter.name] = _cast_tool_parameter_value(parameter.type, value)
|
||||
|
||||
for key, value in merged_arguments.items():
|
||||
prepared_arguments.setdefault(key, value)
|
||||
return prepared_arguments
|
||||
|
||||
|
||||
def _cast_tool_parameter_value(parameter_type: DifyPluginToolParameterType, value: object) -> object:
|
||||
"""Cast prepared tool argument values into daemon-facing wire shapes.
|
||||
|
||||
The API side prepares declaration metadata, but the actual invocation payload
|
||||
still needs to match Dify plugin-daemon expectations. This helper keeps the
|
||||
runtime-side coercion rules for common scalar, collection, file, and selector
|
||||
parameter types so model-supplied JSON values and config-supplied hidden
|
||||
inputs are normalized before transport.
|
||||
"""
|
||||
match parameter_type:
|
||||
case (
|
||||
DifyPluginToolParameterType.STRING
|
||||
| DifyPluginToolParameterType.SECRET_INPUT
|
||||
| DifyPluginToolParameterType.SELECT
|
||||
| DifyPluginToolParameterType.CHECKBOX
|
||||
| DifyPluginToolParameterType.DYNAMIC_SELECT
|
||||
):
|
||||
return "" if value is None else value if isinstance(value, str) else str(value)
|
||||
case DifyPluginToolParameterType.BOOLEAN:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
lowered = value.lower()
|
||||
if lowered in {"true", "yes", "y", "1"}:
|
||||
return True
|
||||
if lowered in {"false", "no", "n", "0"}:
|
||||
return False
|
||||
return value if isinstance(value, bool) else bool(value)
|
||||
case DifyPluginToolParameterType.NUMBER:
|
||||
if isinstance(value, int | float):
|
||||
return value
|
||||
if isinstance(value, str) and value:
|
||||
return float(value) if "." in value else int(value)
|
||||
return value
|
||||
case DifyPluginToolParameterType.SYSTEM_FILES | DifyPluginToolParameterType.FILES:
|
||||
return value if isinstance(value, list) else [value]
|
||||
case DifyPluginToolParameterType.FILE:
|
||||
if isinstance(value, list):
|
||||
if len(value) != 1:
|
||||
raise ValueError("This parameter only accepts one file but got multiple files while invoking.")
|
||||
return value[0]
|
||||
return value
|
||||
case DifyPluginToolParameterType.MODEL_SELECTOR | DifyPluginToolParameterType.APP_SELECTOR:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("The selector must be a dictionary.")
|
||||
return value
|
||||
case DifyPluginToolParameterType.ANY:
|
||||
if value is not None and not isinstance(value, dict | list | str | int | float | bool):
|
||||
raise ValueError("The var selector must be a string, dictionary, list or number.")
|
||||
return value
|
||||
case DifyPluginToolParameterType.ARRAY:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed_value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return [value]
|
||||
if isinstance(parsed_value, list):
|
||||
return parsed_value
|
||||
return [value]
|
||||
case DifyPluginToolParameterType.OBJECT:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed_value = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if isinstance(parsed_value, dict):
|
||||
return parsed_value
|
||||
return {}
|
||||
|
||||
raise AssertionError(f"Unsupported tool parameter type: {parameter_type}")
|
||||
|
||||
|
||||
def _tool_error_text(*, tool_name: str, error: DifyPluginToolClientError) -> str:
|
||||
"""Map expected daemon/tool failures into agent-visible observation text.
|
||||
|
||||
Only known plugin-daemon rejection categories should be softened into tool
|
||||
observations. Unexpected local bugs are intentionally not handled here and
|
||||
should propagate so tests and callers notice the regression.
|
||||
"""
|
||||
error_type = error.error_type or ""
|
||||
if any(token in error_type for token in ("Credential", "Authorization", "Unauthorized")):
|
||||
return "Please check your tool provider credentials"
|
||||
if any(token in error_type for token in ("ToolNotFound", "ProviderNotFound")):
|
||||
return f"there is not a tool named {tool_name}"
|
||||
if error.status_code == 400 or any(token in error_type for token in ("BadRequest", "Validate", "Validation")):
|
||||
return f"tool parameters validation error: {error}, please check your tool parameters"
|
||||
return f"tool invoke error: {error}"
|
||||
|
||||
|
||||
def _convert_tool_response_to_text(tool_response: Sequence[DifyPluginToolInvokeMessage]) -> str:
|
||||
"""Convert daemon stream messages into the plain-text tool observation.
|
||||
|
||||
This preserves the user-facing semantics Dify's agent tool runtime relies on:
|
||||
text is appended directly, links/images become user-check instructions, JSON
|
||||
output is included unless explicitly suppressed, variable messages stay
|
||||
internal, and everything else falls back to ``str(message)``. JSON fragments
|
||||
are deduplicated against existing text so mixed text/JSON streams do not
|
||||
repeat the same content unnecessarily.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
json_parts: list[str] = []
|
||||
|
||||
for response in tool_response:
|
||||
if response.type is DifyPluginToolInvokeMessage.MessageType.TEXT:
|
||||
text_message = response.message
|
||||
if isinstance(text_message, DifyPluginToolInvokeMessage.TextMessage):
|
||||
parts.append(text_message.text)
|
||||
elif response.type is DifyPluginToolInvokeMessage.MessageType.LINK:
|
||||
link_message = response.message
|
||||
if isinstance(link_message, DifyPluginToolInvokeMessage.TextMessage):
|
||||
parts.append(f"result link: {link_message.text}. please tell user to check it.")
|
||||
elif response.type in {
|
||||
DifyPluginToolInvokeMessage.MessageType.IMAGE_LINK,
|
||||
DifyPluginToolInvokeMessage.MessageType.IMAGE,
|
||||
}:
|
||||
parts.append(
|
||||
"image has been created and sent to user already, "
|
||||
"you do not need to create it, just tell the user to check it now."
|
||||
)
|
||||
elif response.type is DifyPluginToolInvokeMessage.MessageType.JSON:
|
||||
json_message = response.message
|
||||
if isinstance(json_message, DifyPluginToolInvokeMessage.JsonMessage) and not json_message.suppress_output:
|
||||
json_parts.append(json.dumps(json_message.json_object, ensure_ascii=False, default=str))
|
||||
elif response.type is DifyPluginToolInvokeMessage.MessageType.VARIABLE:
|
||||
continue
|
||||
else:
|
||||
parts.append(str(response.message))
|
||||
|
||||
if json_parts:
|
||||
existing_parts = set(parts)
|
||||
parts.extend(part for part in json_parts if part not in existing_parts)
|
||||
return "".join(parts)
|
||||
__all__ = ["DifyPluginToolsDeps", "DifyPluginToolsLayer"]
|
||||
72
dify-agent/src/dify_agent/plugin_daemon_transport.py
Normal file
72
dify-agent/src/dify_agent/plugin_daemon_transport.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Shared plugin-daemon transport helpers.
|
||||
|
||||
These helpers define the common request-payload and nested-error semantics used
|
||||
by Dify Agent's LLM and tools daemon clients so the two transport adapters do
|
||||
not drift when the daemon protocol evolves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TypedDict
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PluginDaemonErrorPayload(TypedDict):
|
||||
"""Decoded plugin-daemon error payload."""
|
||||
|
||||
error_type: str
|
||||
message: str
|
||||
|
||||
|
||||
def to_plugin_daemon_jsonable(value: object) -> object:
|
||||
"""Convert nested request data into JSON-safe daemon payload values."""
|
||||
if isinstance(value, BaseModel):
|
||||
return value.model_dump(mode="json")
|
||||
if isinstance(value, dict):
|
||||
return {key: to_plugin_daemon_jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, list | tuple):
|
||||
return [to_plugin_daemon_jsonable(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def decode_plugin_daemon_error_payload(raw_message: str) -> PluginDaemonErrorPayload | None:
|
||||
"""Decode one plugin-daemon JSON error payload if present."""
|
||||
try:
|
||||
parsed = json.loads(raw_message)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
|
||||
error_type = parsed.get("error_type")
|
||||
message = parsed.get("message")
|
||||
if not isinstance(error_type, str) or not isinstance(message, str):
|
||||
return None
|
||||
return {"error_type": error_type, "message": message}
|
||||
|
||||
|
||||
def unwrap_plugin_daemon_error(
|
||||
*,
|
||||
error_type: str,
|
||||
message: str,
|
||||
) -> PluginDaemonErrorPayload:
|
||||
"""Unwrap nested ``PluginInvokeError`` payloads to their effective error."""
|
||||
if error_type == "PluginInvokeError":
|
||||
nested_error = decode_plugin_daemon_error_payload(message)
|
||||
if nested_error is not None:
|
||||
return unwrap_plugin_daemon_error(
|
||||
error_type=nested_error["error_type"],
|
||||
message=nested_error["message"],
|
||||
)
|
||||
return {"error_type": error_type, "message": message}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PluginDaemonErrorPayload",
|
||||
"decode_plugin_daemon_error_payload",
|
||||
"to_plugin_daemon_jsonable",
|
||||
"unwrap_plugin_daemon_error",
|
||||
]
|
||||
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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']",
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user