Merge branch 'feat/agent-v2' of https://github.com/langgenius/dify into feat/agent-v2

This commit is contained in:
yyh 2026-06-25 14:17:57 +08:00
commit 46de0d78d9
No known key found for this signature in database
5 changed files with 89 additions and 112 deletions

View File

@ -36,8 +36,8 @@ class AgentBackendConfig(BaseSettings):
description=(
"Inject the dify.drive layer (Skills & Files drive manifest declaration) "
"into Agent runs. The declaration is an index only — the agent backend "
"pulls the actual SKILL.md / files through the back proxy. Keep it off "
"until the agent backend registers the dify.drive layer type."
"pulls the actual SKILL.md / files through the back proxy. Set this to "
"false only when temporarily rolling back the drive integration."
),
default=False,
default=True,
)

View File

@ -128,7 +128,7 @@ class TestAgentAppRuntimeRequestBuilder:
req = result.request
assert req.purpose == "agent_app"
names = [layer.name for layer in req.composition.layers]
assert names == ["agent_soul_prompt", "agent_app_user_prompt", "execution_context", "history", "llm"]
assert names == ["agent_soul_prompt", "agent_app_user_prompt", "execution_context", "drive", "history", "llm"]
# plugin_id / provider normalized for plugin-daemon transport.
llm = next(layer for layer in req.composition.layers if layer.name == "llm")
assert llm.config.plugin_id == "langgenius/openai"
@ -251,9 +251,26 @@ class TestAgentAppRuntimeRequestBuilder:
def _soul_with_model_and_skill() -> AgentSoulConfig:
soul = _soul_with_model()
soul.prompt.system_prompt = "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]"
return soul
return AgentSoulConfig.model_validate(
{
"model": {
"plugin_id": "langgenius/openai",
"model_provider": "langgenius/openai/openai",
"model": "gpt-4o-mini",
},
"prompt": {"system_prompt": "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§]"},
"files": {
"skills": [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
}
]
},
}
)
class TestAgentAppDriveLayer:
@ -261,28 +278,6 @@ class TestAgentAppDriveLayer:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": None,
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 1,
"mime_type": "text/markdown",
"hash": None,
"created_at": 1,
}
],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True}
],
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
@ -305,14 +300,6 @@ class TestAgentAppDriveLayer:
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr("core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_SHELL_ENABLED", True)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
@ -328,7 +315,10 @@ class TestAgentAppDriveLayer:
"drive": "drive",
}
def test_no_drive_layer_when_flag_disabled(self):
def test_no_drive_layer_when_flag_disabled(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
plugin_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
@ -343,29 +333,6 @@ class TestAgentAppDriveLayer:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": None,
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 1,
"mime_type": "text/markdown",
"hash": None,
"created_at": 1,
}
],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
{"key": "files/sample.pdf", "is_skill": False},
],
)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
@ -388,14 +355,6 @@ class TestAgentAppDriveLayer:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "
@ -416,29 +375,6 @@ class TestAgentAppDriveLayer:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": None,
"name": "Tender Analyzer",
"description": "Parses RFPs.",
"size": 1,
"mime_type": "text/markdown",
"hash": None,
"created_at": 1,
}
],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
{"key": "files/sample.pdf", "is_skill": False},
],
)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]"
@ -461,14 +397,6 @@ class TestAgentAppDriveLayer:
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
lambda self, *, tenant_id, agent_id: [],
)
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
)
soul = _soul_with_model()
soul.prompt.system_prompt = (
"Use [§skill:ghost%2FSKILL.md:Ghost Skill§], [§file:files%2Fghost.txt:Ghost File§], "

View File

@ -181,7 +181,8 @@ def test_builds_create_run_request_from_agent_soul_and_node_job():
assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"]
assert dumped["composition"]["layers"][-1]["config"]["json_schema"]["properties"]["summary"]["type"] == "string"
assert DIFY_AGENT_HISTORY_LAYER_ID in layers
assert result.redacted_request["composition"]["layers"][5]["config"]["credentials"] == "[REDACTED]"
redacted_layers = {layer["name"]: layer for layer in result.redacted_request["composition"]["layers"]}
assert redacted_layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["credentials"] == "[REDACTED]"
def test_normalizes_langgenius_model_provider_for_agent_backend_transport():
@ -262,7 +263,7 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada
assert report_schema["oneOf"][3]["required"] == ["transfer_method", "url"]
assert output_schema["properties"]["confidence"]["type"] == "number"
assert output_schema["required"] == ["report"]
assert dumped["composition"]["layers"][5]["config"]["model_settings"] == {"temperature": 0.2}
assert layers[DIFY_AGENT_MODEL_LAYER_ID]["config"]["model_settings"] == {"temperature": 0.2}
assert result.metadata["runtime_support"]["reserved_status"]["tools.dify_tools"] == "supported_when_config_valid"
assert result.metadata["runtime_support"]["reserved_status"]["tools.cli_tools"] == "supported_by_shell_bootstrap"
assert result.metadata["runtime_support"]["unsupported_runtime_warnings"] == []
@ -1103,11 +1104,14 @@ def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert soul_prompt.config.prefix == "Use Ghost Skill, Ghost File, and files/no-label.txt."
assert soul_prompt.config.prefix == "Use Ghost Skill, Ghost File, and no-label.txt."
assert "" not in soul_prompt.config.prefix
def test_workflow_run_request_has_no_drive_layer_when_flag_disabled():
def test_workflow_run_request_has_no_drive_layer_when_flag_disabled(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", False
)
context = _context()
context.snapshot.config_snapshot = _soul_with_drive_skill()

View File

@ -5,7 +5,10 @@ mentioned in the prompt. When the layer enters a run context it eagerly pulls
those mentioned skills/files from the Dify inner drive bridge, materializes them
under the fixed Agent Stub drive base for ``drive_ref``, and contributes a
concise prompt block describing what was loaded and what other skills remain
available for lazy pull.
available for lazy pull. It also contributes a suffix prompt with
``dify-agent drive`` and ``dify-agent file`` usage so the model has concrete
Agent Stub commands for materializing drive content and workflow files when a
shell layer is available.
"""
from __future__ import annotations
@ -27,6 +30,26 @@ from dify_agent.layers.drive.configs import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveL
_SKILL_ARCHIVE_FILENAME = ".DIFY-SKILL-FULL.zip"
_DOWNLOAD_CONCURRENCY = 4
_AGENT_STUB_CLI_USAGE_PROMPT = """Agent Stub CLI usage is available inside shell jobs:
Drive commands:
- List drive items: `dify-agent drive list [PATH_PREFIX]`
- Emit the drive manifest as JSON: `dify-agent drive list [PATH_PREFIX] --json`
- Pull drive keys or prefixes: `dify-agent drive pull TARGET ...`
Pulled files are written under `$DIFY_AGENT_STUB_DRIVE_BASE` by default.
Use `--drive-base .` to materialize pulled files under the current working directory.
- Upload a local file or directory: `dify-agent drive push LOCAL_PATH DRIVE_PATH`
Add `--recursive` to upload raw directory contents. Without `--recursive`, a directory must contain `SKILL.md`
and is uploaded as a standardized skill.
File commands:
- Download one workflow file mapping: `dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL [DIR]`
`TRANSFER_METHOD` is one of `local_file`, `tool_file`, `datasource_file`, or `remote_url`.
If `DIR` is omitted, the file is saved in the current working directory.
- Upload one sandbox-local output file: `dify-agent file upload PATH`
The command prints a JSON file mapping such as `{"transfer_method":"tool_file","reference":"..."}`."""
class DifyDriveLayerError(RuntimeError):
@ -81,6 +104,11 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim
def prefix_prompts(self) -> list[str]:
return [self.build_prompt_context()]
@property
@override
def suffix_prompts(self) -> list[str]:
return [_AGENT_STUB_CLI_USAGE_PROMPT]
@override
async def on_context_create(self) -> None:
await self._pull_mentioned_targets()
@ -122,9 +150,6 @@ class DifyDriveLayer(PlainLayer[DifyDriveDeps, DifyDriveLayerConfig, EmptyRuntim
if not sections:
return ""
sections.append(
"Additional drive skills/files can be pulled lazily later with the Agent Stub drive commands if needed."
)
return "\n\n".join(sections)
async def _pull_mentioned_targets(self) -> None:

View File

@ -3,6 +3,7 @@
from __future__ import annotations
from pathlib import Path
from typing import ClassVar
import pytest
from agenton.layers import EmptyRuntimeState, LayerConfig, NoLayerDeps, PlainLayer
@ -15,10 +16,15 @@ class _FakeExecutionContextConfig(LayerConfig):
class _FakeExecutionContextLayer(PlainLayer[NoLayerDeps, _FakeExecutionContextConfig, EmptyRuntimeState]):
type_id = None
type_id: ClassVar[str | None] = None
def __init__(self, tenant_id: str) -> None:
self.config = _FakeExecutionContextConfig(tenant_id=tenant_id)
config: _FakeExecutionContextConfig
def __new__(cls) -> _FakeExecutionContextLayer:
return super().__new__(cls)
def __init__(self) -> None:
self.config = _FakeExecutionContextConfig(tenant_id="tenant-1")
def _build_layer(tmp_path: Path) -> DifyDriveLayer:
@ -47,10 +53,24 @@ def _build_layer(tmp_path: Path) -> DifyDriveLayer:
inner_api_url="https://api.example.com",
inner_api_key="secret",
)
layer.bind_deps({"execution_context": _FakeExecutionContextLayer("tenant-1")})
layer.bind_deps({"execution_context": _FakeExecutionContextLayer()})
return layer
def test_drive_layer_exposes_agent_stub_cli_usage_suffix_prompt(tmp_path: Path) -> None:
layer = _build_layer(tmp_path)
assert len(layer.suffix_prompts) == 1
prompt = layer.suffix_prompts[0]
assert "dify-agent drive list [PATH_PREFIX]" in prompt
assert "dify-agent drive pull TARGET ..." in prompt
assert "--drive-base ." in prompt
assert "dify-agent drive push LOCAL_PATH DRIVE_PATH" in prompt
assert "dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL [DIR]" in prompt
assert "dify-agent file upload PATH" in prompt
assert '{"transfer_method":"tool_file","reference":"..."}' in prompt
@pytest.mark.anyio
async def test_on_context_create_loads_mentioned_targets_into_prompt(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path