diff --git a/api/configs/extra/agent_backend_config.py b/api/configs/extra/agent_backend_config.py index 0d65d3de97e..58228cbfb02 100644 --- a/api/configs/extra/agent_backend_config.py +++ b/api/configs/extra/agent_backend_config.py @@ -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, ) diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index 1e7dc801eb1..ef5aff1dd41 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -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§], " diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index 9740a26db0f..e7b32a55af8 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -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() diff --git a/dify-agent/src/dify_agent/layers/drive/layer.py b/dify-agent/src/dify_agent/layers/drive/layer.py index e38704d2b9d..29ec996c402 100644 --- a/dify-agent/src/dify_agent/layers/drive/layer.py +++ b/dify-agent/src/dify_agent/layers/drive/layer.py @@ -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: diff --git a/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py b/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py index c95526f046d..93283f539fe 100644 --- a/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/drive/test_layer.py @@ -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