diff --git a/api/controllers/common/schema.py b/api/controllers/common/schema.py index 726b35d663d..4e8c61531a9 100644 --- a/api/controllers/common/schema.py +++ b/api/controllers/common/schema.py @@ -63,6 +63,7 @@ class SchemaNameCollisionError(ValueError): # names depending on which model pulled it in. Types that share a class name with # another documented type get a fixed, unique component name here instead. SCHEMA_NAME_OVERRIDES: Mapping[str, str] = { + "services.agent_config_service.AgentConfigVersionKind": "AgentConfigTargetVersionKind", "graphon.model_runtime.entities.common_entities.I18nObject": "ModelRuntimeI18nObject", "services.knowledge.entities.knowledge_entities.FileInfo": "KnowledgeFileInfo", "core.plugin.entities.plugin.PluginDependency.Github": "PluginDependencyGithub", diff --git a/api/controllers/inner_api/plugin/agent_config.py b/api/controllers/inner_api/plugin/agent_config.py index 196909196bd..5c7b26aefc9 100644 --- a/api/controllers/inner_api/plugin/agent_config.py +++ b/api/controllers/inner_api/plugin/agent_config.py @@ -16,9 +16,13 @@ from flask import request from flask_restx import Resource from pydantic import BaseModel, ConfigDict, ValidationError, model_validator +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console.wraps import setup_required from controllers.inner_api import inner_api_ns from controllers.inner_api.wraps import plugin_inner_api_only +from fields.base import ResponseModel +from libs.exception import RelayedHTTPError +from libs.helper import dump_response from models.agent_config_entities import validate_config_name, validate_config_skill_name from services.agent_config_service import ( AgentConfigService, @@ -85,6 +89,22 @@ class _ConfigNoteUpdateRequest(_ConfigMutationRequest): note: str +class AgentConfigPromptUpdatePayload(_ConfigMutationRequest): + """Replace the raw system prompt; missing/null text is never a clear request.""" + + model_config = ConfigDict(extra="forbid") + + prompt: str + + +class AgentConfigPromptResponse(ResponseModel): + prompt: str + + +register_schema_models(inner_api_ns, AgentConfigPromptUpdatePayload) +register_response_schema_models(inner_api_ns, AgentConfigPromptResponse) + + def _target_query_from_request() -> _ConfigTargetQuery: return _ConfigTargetQuery.model_validate( { @@ -236,3 +256,48 @@ class AgentConfigNoteApi(Resource): return {"code": "invalid_request", "message": str(exc)}, 400 except AgentConfigServiceError as exc: return _error_response(exc) + + +@inner_api_ns.route("/agent-config//prompt") +class AgentConfigPromptApi(Resource): + @setup_required + @plugin_inner_api_only + @inner_api_ns.doc("agent_config_prompt_get", params=query_params_from_model(_ConfigTargetQuery)) + @inner_api_ns.response(200, "Raw system prompt", inner_api_ns.models[AgentConfigPromptResponse.__name__]) + def get(self, agent_id: str): + try: + query = _target_query_from_request() + result = AgentConfigService().get_prompt( + tenant_id=query.tenant_id, + agent_id=agent_id, + user_id=query.user_id, + config_version_id=query.config_version_id, + config_version_kind=query.config_version_kind, + ) + return dump_response(AgentConfigPromptResponse, result) + except ValidationError as exc: + raise RelayedHTTPError(status=400, error_code="invalid_request", message=str(exc)) from exc + except AgentConfigServiceError as exc: + raise RelayedHTTPError(status=exc.status_code, error_code=exc.code, message=exc.message) from exc + + @setup_required + @plugin_inner_api_only + @inner_api_ns.doc("agent_config_prompt_put") + @inner_api_ns.expect(inner_api_ns.models[AgentConfigPromptUpdatePayload.__name__]) + @inner_api_ns.response(200, "Saved system prompt", inner_api_ns.models[AgentConfigPromptResponse.__name__]) + def put(self, agent_id: str): + try: + body = AgentConfigPromptUpdatePayload.model_validate(request.get_json(silent=True) or {}) + result = AgentConfigService().update_prompt( + tenant_id=body.tenant_id, + agent_id=agent_id, + user_id=body.user_id, + config_version_id=body.config_version_id, + config_version_kind=body.config_version_kind, + prompt=body.prompt, + ) + return dump_response(AgentConfigPromptResponse, result) + except ValidationError as exc: + raise RelayedHTTPError(status=400, error_code="invalid_request", message=str(exc)) from exc + except AgentConfigServiceError as exc: + raise RelayedHTTPError(status=exc.status_code, error_code=exc.code, message=exc.message) from exc diff --git a/api/services/agent_config_service.py b/api/services/agent_config_service.py index 56a1ecb342b..7e837dbfb6b 100644 --- a/api/services/agent_config_service.py +++ b/api/services/agent_config_service.py @@ -1,11 +1,11 @@ -"""Agent Soul-backed config assets for skills, files, env, and note text. +"""Agent Soul-backed config assets for skills, files, env, note, and prompt text. This service is the single API-side control plane for Agent config assets. It resolves the requested config version (published snapshot, shared draft, or per-user build draft), reads only from ``AgentSoulConfig.config_skills``, -``config_files``, ``env.variables``, and ``config_note``, and mutates only the -target Soul JSON. Source file refs are tenant-scoped rather than user-scoped: -config writes can be owned by a build-draft Account while the uploaded +``config_files``, ``env.variables``, ``config_note``, and ``prompt.system_prompt``, +and mutates only the target Soul JSON. Source file refs are tenant-scoped rather +than user-scoped: config writes can be owned by a build-draft Account while the uploaded ``ToolFile`` came from an end-user execution context. It intentionally does not manage object-storage lifecycle: removing or replacing a config asset drops the Soul reference only. @@ -876,6 +876,56 @@ class AgentConfigService: session.commit() return {"note": agent_soul.config_note} + def get_prompt( + self, + *, + tenant_id: str, + agent_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + user_id: str | None = None, + ) -> dict[str, str]: + """Read the saved system prompt verbatim, including unexpanded mentions.""" + target = self.resolve_target( + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + return {"prompt": target.agent_soul.prompt.system_prompt} + + def update_prompt( + self, + *, + tenant_id: str, + agent_id: str, + user_id: str, + config_version_id: str, + config_version_kind: AgentConfigVersionKind, + prompt: str, + ) -> dict[str, str]: + """Replace only the caller's build-draft prompt; an empty string clears it. + + Already-built run requests retain their prompt. Subsequent requests + built from this draft use the saved text and resolve its mentions. + """ + with self._session_factory() as session: + target = self._resolve_target_in_session( + session, + tenant_id=tenant_id, + agent_id=agent_id, + config_version_id=config_version_id, + config_version_kind=config_version_kind, + user_id=user_id, + ) + self._require_writable(target, surface=AgentConfigMutationSurface.AGENT_STUB) + agent_soul = target.agent_soul.model_copy(deep=True) + agent_soul.prompt.system_prompt = prompt + target.version.config_snapshot = agent_soul + session.commit() + return {"prompt": agent_soul.prompt.system_prompt} + def _resolve_target_in_session( self, session: Session, diff --git a/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_config.py b/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_config.py index 79ecdc08cb9..3a0d5b4e7c4 100644 --- a/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_config.py +++ b/api/tests/unit_tests/controllers/inner_api/plugin/test_agent_config.py @@ -3,8 +3,10 @@ from __future__ import annotations import inspect +from collections.abc import Callable from unittest.mock import patch +import pytest from flask import Flask from controllers.inner_api.plugin.agent_config import ( @@ -12,9 +14,11 @@ from controllers.inner_api.plugin.agent_config import ( AgentConfigEnvApi, AgentConfigManifestApi, AgentConfigNoteApi, + AgentConfigPromptApi, AgentConfigPushApi, AgentConfigSkillInspectApi, ) +from libs.exception import RelayedHTTPError from services.agent_config_service import AgentConfigServiceError MODULE = "controllers.inner_api.plugin.agent_config" @@ -233,6 +237,91 @@ def test_note_happy_path_calls_service() -> None: assert service.return_value.update_note.call_args.kwargs["note"] == "hello" +@pytest.mark.parametrize("prompt", [" 原文 [§file:guide.md:Guide§]\r\n\n", ""]) +def test_prompt_reads_and_replaces_raw_text(prompt: str) -> None: + target = { + "tenant_id": "tenant-1", + "user_id": "account-user-1", + "config_version_id": "cfg-1", + "config_version_kind": "build_draft", + } + with patch(f"{MODULE}.AgentConfigService") as service: + service.return_value.get_prompt.return_value = {"prompt": prompt} + service.return_value.update_prompt.return_value = {"prompt": prompt} + with app.test_request_context("/", query_string=target): + assert _raw(AgentConfigPromptApi.get)(AgentConfigPromptApi(), "agent-1") == {"prompt": prompt} + with app.test_request_context("/", method="PUT", json={**target, "prompt": prompt}): + assert _raw(AgentConfigPromptApi.put)(AgentConfigPromptApi(), "agent-1") == {"prompt": prompt} + + assert service.return_value.get_prompt.call_args.kwargs == {**target, "agent_id": "agent-1"} + assert service.return_value.update_prompt.call_args.kwargs == {**target, "agent_id": "agent-1", "prompt": prompt} + + +@pytest.mark.parametrize("prompt_body", [{}, {"prompt": None}, {"prompt": 123}]) +def test_prompt_rejects_missing_null_or_non_string_text(prompt_body: dict[str, object]) -> None: + with ( + app.test_request_context( + "/", + method="PUT", + json={ + "tenant_id": "tenant-1", + "user_id": "user-1", + "config_version_id": "cfg-1", + "config_version_kind": "build_draft", + **prompt_body, + }, + ), + patch(f"{MODULE}.AgentConfigService") as service, + ): + with pytest.raises(RelayedHTTPError) as exc_info: + _raw(AgentConfigPromptApi.put)(AgentConfigPromptApi(), "agent-1") + assert exc_info.value.code == 400 + assert exc_info.value.error_code == "invalid_request" + service.assert_not_called() + + +@pytest.mark.parametrize( + ("method", "error_code", "status"), [("get", "config_version_not_found", 404), ("put", "config_not_writable", 403)] +) +def test_prompt_http_routes_require_inner_key_and_serialize_errors( + method: str, error_code: str, status: int, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None] +) -> None: + from controllers.inner_api import bp + + config_overrides(PLUGIN_DAEMON_KEY="enabled", INNER_API_KEY_FOR_PLUGIN="prompt-secret") + monkeypatch.setattr("controllers.console.wraps._is_setup_completed", lambda: True) + served_app = Flask(__name__) + served_app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False) + served_app.register_blueprint(bp) + client = served_app.test_client() + path = "/inner/api/agent-config/agent-1/prompt" + target = { + "tenant_id": "tenant-1", + "user_id": "user-1", + "config_version_id": "cfg-1", + "config_version_kind": "build_draft", + } + body = {**target, "prompt": "raw [§skill:research:Research§]\n"} + with patch(f"{MODULE}.AgentConfigService") as service: + service.return_value.get_prompt.return_value = {"prompt": body["prompt"]} + service.return_value.update_prompt.return_value = {"prompt": body["prompt"]} + unauthorized_headers: list[dict[str, str]] = [{}, {"X-Inner-Api-Key": "wrong"}] + for headers in unauthorized_headers: + response = client.open(path, method=method.upper(), headers=headers, query_string=target, json=body) + assert response.status_code == 404 + service.assert_not_called() + headers = {"X-Inner-Api-Key": "prompt-secret"} + response = client.open(path, method=method.upper(), headers=headers, query_string=target, json=body) + assert response.status_code == 200 + assert response.json == {"prompt": body["prompt"]} + error = AgentConfigServiceError(error_code, "config unavailable", status_code=status) + service.return_value.get_prompt.side_effect = error + service.return_value.update_prompt.side_effect = error + response = client.open(path, method=method.upper(), headers=headers, query_string=target, json=body) + assert response.status_code == status + assert response.json == {"code": error_code, "message": "config unavailable", "status": status} + + def test_manifest_invalid_query_returns_400() -> None: raw = _raw(AgentConfigManifestApi.get) diff --git a/api/tests/unit_tests/services/test_agent_config_service.py b/api/tests/unit_tests/services/test_agent_config_service.py index dcd6af670c4..97524c000b5 100644 --- a/api/tests/unit_tests/services/test_agent_config_service.py +++ b/api/tests/unit_tests/services/test_agent_config_service.py @@ -10,8 +10,12 @@ from types import SimpleNamespace from unittest.mock import patch import pytest +from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig +from dify_agent.protocol import CreateRunRequest from sqlalchemy.orm import Session, sessionmaker +from typing_extensions import TypedDict +from clients.agent_backend import AgentBackendAgentAppRunInput, AgentBackendModelConfig, AgentBackendRunRequestBuilder from extensions.storage.storage_type import StorageType from models.agent import ( Agent, @@ -59,6 +63,14 @@ NORMALIZED_SKILL_FILE = "cccccccc-cccc-cccc-cccc-cccccccccccc" AGENT_CONFIG_TABLES = (Agent, AgentConfigDraft, AgentConfigSnapshot) +class _PromptTarget(TypedDict, closed=True): + tenant_id: str + agent_id: str + user_id: str + config_version_id: str + config_version_kind: AgentConfigVersionKind + + def _soul(**updates) -> AgentSoulConfig: payload = AgentSoulConfig().model_dump(mode="json") payload.update(updates) @@ -149,6 +161,170 @@ def _target( ) +@pytest.mark.parametrize("kind", list(AgentConfigVersionKind)) +@pytest.mark.parametrize("sqlite_session", [AGENT_CONFIG_TABLES], indirect=True) +def test_get_prompt_returns_raw_text_for_each_visible_version( + kind: AgentConfigVersionKind, sqlite_session: Session +) -> None: + prompt = " 请参考 [§skill:research:Research§] 和 [§file:guide.md:Guide§]。\r\n\n" + soul = _soul(prompt={"system_prompt": prompt}) + version = ( + _snapshot(soul=soul) + if kind == AgentConfigVersionKind.SNAPSHOT + else _draft( + soul=soul, + draft_type=AgentConfigDraftType.DEBUG_BUILD + if kind == AgentConfigVersionKind.BUILD_DRAFT + else AgentConfigDraftType.DRAFT, + account_id=USER if kind == AgentConfigVersionKind.BUILD_DRAFT else None, + ) + ) + _persist_target(sqlite_session, version) + target: _PromptTarget = { + "tenant_id": TENANT, + "agent_id": AGENT, + "config_version_id": version.id, + "config_version_kind": kind, + "user_id": USER, + } + + service = _service(sqlite_session) + assert service.get_prompt(**target) == {"prompt": prompt} + assert "prompt" not in service.manifest(**target) + + +@pytest.mark.parametrize("prompt", [" 新提示 🪷 [§skill:research:Research§]\r\n\n", ""]) +@pytest.mark.parametrize("sqlite_session", [AGENT_CONFIG_TABLES], indirect=True) +def test_update_prompt_persists_only_system_prompt_and_keeps_built_requests( + prompt: str, sqlite_session: Session +) -> None: + original = _soul( + prompt={"system_prompt": "Original instructions"}, + config_note="Keep this note", + config_files=[AgentConfigFileRefConfig(name="guide.md", file_kind="upload_file", file_id=UPLOAD_FILE)], + config_skills=[AgentConfigSkillRefConfig(name="research", file_id=SKILL_FILE, description="Research")], + env={"variables": [{"name": "API_KEY", "value": "configured"}]}, + model={ + "plugin_id": "langgenius/openai", + "model_provider": "openai", + "model": "gpt-test", + "model_settings": {"temperature": 0.2}, + }, + ) + _persist_target( + sqlite_session, + _draft(version_id=BUILD_DRAFT, draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id=USER, soul=original), + ) + service = _service(sqlite_session) + target: _PromptTarget = { + "tenant_id": TENANT, + "agent_id": AGENT, + "user_id": USER, + "config_version_id": BUILD_DRAFT, + "config_version_kind": AgentConfigVersionKind.BUILD_DRAFT, + } + before = service.resolve_target(**target).agent_soul + + def build_request(soul: AgentSoulConfig) -> CreateRunRequest: + return AgentBackendRunRequestBuilder().build_for_agent_app( + AgentBackendAgentAppRunInput( + model=AgentBackendModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"), + execution_context=DifyExecutionContextLayerConfig( + tenant_id=TENANT, user_id=USER, user_from="account", agent_mode="agent_app", invoke_from="debugger" + ), + backend_binding_ref="binding-1", + user_prompt="Continue", + agent_config_version_kind="build_draft", + agent_soul_prompt=soul.prompt.system_prompt, + ) + ) + + built_request = build_request(before) + built_payload = built_request.model_dump(mode="json") + assert service.update_prompt(**target, prompt=prompt) == {"prompt": prompt} + assert service.get_prompt(**target) == {"prompt": prompt} + + sqlite_session.expire_all() + persisted = sqlite_session.get(AgentConfigDraft, BUILD_DRAFT) + assert persisted is not None + expected = original.model_dump(mode="json") + expected["prompt"]["system_prompt"] = prompt + assert persisted.config_snapshot_dict == expected + assert before == original + assert built_request.model_dump(mode="json") == built_payload + next_request = build_request(service.resolve_target(**target).agent_soul) + assert next_request.model_dump(mode="json") != built_payload + assert "Original instructions" not in next_request.model_dump_json() + next_prompt = next( + layer.config["prefix"] for layer in next_request.composition.layers if layer.name == "agent_soul_prompt" + ) + assert isinstance(next_prompt, str) + assert (prompt.strip() if prompt else "No task prompt was provided.") in next_prompt + + +@pytest.mark.parametrize("kind", [AgentConfigVersionKind.SNAPSHOT, AgentConfigVersionKind.DRAFT]) +@pytest.mark.parametrize("sqlite_session", [AGENT_CONFIG_TABLES], indirect=True) +def test_update_prompt_rejects_read_only_versions(kind: AgentConfigVersionKind, sqlite_session: Session) -> None: + soul = _soul(prompt={"system_prompt": "unchanged"}) + version = _snapshot(soul=soul) if kind == AgentConfigVersionKind.SNAPSHOT else _draft(soul=soul) + _persist_target(sqlite_session, version) + + with pytest.raises(AgentConfigServiceError) as exc_info: + _service(sqlite_session).update_prompt( + tenant_id=TENANT, + agent_id=AGENT, + user_id=USER, + config_version_id=version.id, + config_version_kind=kind, + prompt="replacement", + ) + + assert (exc_info.value.code, exc_info.value.status_code) == ("config_not_writable", 403) + sqlite_session.refresh(version) + assert version.config_snapshot_dict == soul.model_dump(mode="json") + + +@pytest.mark.parametrize("operation", ["get_prompt", "update_prompt"]) +@pytest.mark.parametrize( + "mismatch", ["tenant", "agent", "version_tenant", "version_agent", "account", "missing_account", "version", "kind"] +) +@pytest.mark.parametrize("sqlite_session", [AGENT_CONFIG_TABLES], indirect=True) +def test_prompt_access_requires_complete_owner_chain(operation: str, mismatch: str, sqlite_session: Session) -> None: + version = _draft( + version_id=BUILD_DRAFT, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id=USER, + soul=_soul(prompt={"system_prompt": "private"}), + ) + if mismatch == "version_tenant": + version.tenant_id = OTHER_TENANT + if mismatch == "version_agent": + version.agent_id = END_USER + _persist_target(sqlite_session, version) + target: _PromptTarget = { + "tenant_id": OTHER_TENANT if mismatch == "tenant" else TENANT, + "agent_id": END_USER if mismatch == "agent" else AGENT, + "user_id": "" if mismatch == "missing_account" else END_USER if mismatch == "account" else USER, + "config_version_id": SNAPSHOT if mismatch == "version" else BUILD_DRAFT, + "config_version_kind": AgentConfigVersionKind.DRAFT + if mismatch == "kind" + else AgentConfigVersionKind.BUILD_DRAFT, + } + service = _service(sqlite_session) + + def call() -> dict[str, str]: + if operation == "get_prompt": + return service.get_prompt(**target) + return service.update_prompt(**target, prompt="replacement") + + with pytest.raises(AgentConfigServiceError) as exc_info: + call() + + assert exc_info.value.status_code == (400 if mismatch == "missing_account" else 404) + sqlite_session.refresh(version) + assert version.config_snapshot.prompt.system_prompt == "private" + + def _zip_bytes(members: dict[str, bytes]) -> bytes: buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as archive: diff --git a/dify-agent-runtime/README.md b/dify-agent-runtime/README.md index ecfec126834..09cf107df82 100644 --- a/dify-agent-runtime/README.md +++ b/dify-agent-runtime/README.md @@ -14,6 +14,26 @@ cmd/ internal/ - internal implementations ``` +## Agent configuration prompt + +The sandbox's `dify-agent` CLI uses the authenticated Agent Stub connection: + +```bash +dify-agent config prompt pull # Save ./.dify_conf/prompt.md; print its absolute path +dify-agent config prompt pull --to ./prompt.txt +dify-agent config prompt push # Read ./.dify_conf/prompt.md; print saved prompt JSON +dify-agent config prompt push ./prompt.txt +printf '%s' 'New instructions' | dify-agent config prompt push - +``` + +Pull and push preserve raw UTF-8 text, including mention markers, whitespace, +and line endings. Empty input clears the prompt; a missing file is an error. +Local edits must be pushed to persist. Only the current account's build draft +is writable; snapshot and shared-draft configurations are read-only. Later +requests built from the draft use the saved prompt; already-built requests +retain their prompt. See the [runtime contract](../dify-agent/docs/dify-agent/index.md) +for the Agent Stub and inner API endpoints. + ## Job execution modes `POST /v1/jobs/run` accepts an optional `mode` field: diff --git a/dify-agent-runtime/cmd/dify-agent-cli/dumphelp_test.go b/dify-agent-runtime/cmd/dify-agent-cli/dumphelp_test.go index f176b8337c7..a831398183e 100644 --- a/dify-agent-runtime/cmd/dify-agent-cli/dumphelp_test.go +++ b/dify-agent-runtime/cmd/dify-agent-cli/dumphelp_test.go @@ -52,6 +52,8 @@ func TestDumpCLIHelpCoversPromptCommands(t *testing.T) { "config files pull", "config note pull", "config note push", + "config prompt pull", + "config prompt push", "config env push", "config files push", "config files delete", diff --git a/dify-agent-runtime/cmd/dify-agent-cli/main.go b/dify-agent-runtime/cmd/dify-agent-cli/main.go index 6f28da7432f..699d6281cf1 100644 --- a/dify-agent-runtime/cmd/dify-agent-cli/main.go +++ b/dify-agent-runtime/cmd/dify-agent-cli/main.go @@ -31,8 +31,8 @@ func main() { func runCLI(args []string) error { // Hidden developer intercept: emit the JSON help snapshot from the cobra - // tree. Not registered as a cobra command, so the visible command surface - // stays identical to the Python CLI. See dumphelp.go. + // tree. It is not registered as a cobra command, so developer-only help + // generation stays out of the visible CLI. See dumphelp.go. if len(args) == 1 && args[0] == "__dump-cli-help" { return dumpCLIHelp(os.Stdout) } @@ -163,6 +163,7 @@ func newConfigCommand() *cobra.Command { newConfigFilePullAliasCommand(), newConfigEnvCommand(), newConfigNoteCommand(), + newConfigPromptCommand(), ) return cmd } @@ -294,6 +295,45 @@ func newConfigEnvCommand() *cobra.Command { return cmd } +func newConfigPromptCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "prompt", + Short: "Pull or replace the raw system prompt, including mention markers.", + } + + var pullTo string + pull := &cobra.Command{ + Use: "pull", + Short: "Export the raw system prompt into ./.dify_conf/prompt.md by default.", + Args: cobra.NoArgs, + RunE: withEnv(func(env *agentcli.Environment, _ []string, _ *cobra.Command) error { + return agentcli.RunConfigPromptPull(env, pullTo) + }), + } + pull.Flags().StringVar(&pullTo, "to", "", "Local UTF-8 text file path.") + + push := &cobra.Command{ + Use: "push [PATH|-]", + Short: "Replace the build-draft prompt from a UTF-8 file or stdin; empty text clears it.", + Long: "Replace the build-draft system prompt from a UTF-8 file or stdin (-). " + + "Defaults to ./.dify_conf/prompt.md and prints the saved prompt as JSON. " + + "Empty text clears the prompt. " + + "Only subsequent run requests built from this draft use the saved prompt; " + + "already-built run requests keep their prompt.", + Args: cobra.MaximumNArgs(1), + RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error { + localPath := "" + if len(args) > 0 { + localPath = args[0] + } + return agentcli.RunConfigPromptPush(env, localPath) + }), + } + + cmd.AddCommand(pull, push) + return cmd +} + func newConfigNoteCommand() *cobra.Command { cmd := &cobra.Command{ Use: "note", diff --git a/dify-agent-runtime/cmd/dify-agent-cli/main_test.go b/dify-agent-runtime/cmd/dify-agent-cli/main_test.go index 2007058cb2a..7c380cd54cc 100644 --- a/dify-agent-runtime/cmd/dify-agent-cli/main_test.go +++ b/dify-agent-runtime/cmd/dify-agent-cli/main_test.go @@ -1,8 +1,7 @@ // Tests that `--help` renders correctly for every command and subcommand in // the cobra tree, including the hidden singular `config skill|file` aliases. -// These lock the command surface so it stays identical to the original Python -// (Typer) CLI: each help output must show the command's usage path, its -// Short description, and its expected flags/subcommands. +// These lock the installed command surface: each help output must show the +// command's usage path, description, and expected flags/subcommands. package main import ( @@ -73,7 +72,7 @@ func TestCommandHelp(t *testing.T) { { name: "config", args: []string{"config", "--help"}, - want: []string{"dify-agent config", "manifest", "skills", "files", "env", "note"}, + want: []string{"dify-agent config", "manifest", "skills", "files", "env", "note", "prompt"}, }, { name: "config manifest", @@ -145,6 +144,21 @@ func TestCommandHelp(t *testing.T) { args: []string{"config", "note", "push", "--help"}, want: []string{"dify-agent config note push", "Replace the current config note"}, }, + { + name: "config prompt", + args: []string{"config", "prompt", "--help"}, + want: []string{"dify-agent config prompt", "pull", "push"}, + }, + { + name: "config prompt pull", + args: []string{"config", "prompt", "pull", "--help"}, + want: []string{"dify-agent config prompt pull", "./.dify_conf/prompt.md", "--to"}, + }, + { + name: "config prompt push", + args: []string{"config", "prompt", "push", "--help"}, + want: []string{"dify-agent config prompt push [PATH|-]", "stdin", "already-built run requests keep their prompt"}, + }, { name: "config skill pull (hidden alias)", args: []string{"config", "skill", "pull", "--help"}, diff --git a/dify-agent-runtime/cmd/dify-agent-cli/prompt_test.go b/dify-agent-runtime/cmd/dify-agent-cli/prompt_test.go new file mode 100644 index 00000000000..9562fcf66b6 --- /dev/null +++ b/dify-agent-runtime/cmd/dify-agent-cli/prompt_test.go @@ -0,0 +1,214 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/langgenius/dify/dify-agent-runtime/internal/agentcli" +) + +func runPromptCLI(t *testing.T, args ...string) (string, error) { + t.Helper() + output, err := os.CreateTemp(t.TempDir(), "stdout-*") + if err != nil { + t.Fatal(err) + } + original := os.Stdout + defer func() { os.Stdout = original }() + os.Stdout = output + runErr := runCLI(args) + if err := output.Close(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(output.Name()) + if err != nil { + t.Fatal(err) + } + return string(data), runErr +} + +func TestConfigPromptPull(t *testing.T) { + for _, prompt := range []string{" 原文 🪷 [§skill:research:Research§]\r\n\n", ""} { + for _, customPath := range []bool{false, true} { + t.Run(fmt.Sprintf("custom=%t/%q", customPath, prompt), func(t *testing.T) { + t.Chdir(t.TempDir()) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/agent-stub/config/prompt" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL) + } + if r.Header.Get("Authorization") != "Bearer token" { + t.Error("missing Agent Stub authorization") + } + _ = json.NewEncoder(w).Encode(map[string]string{"prompt": prompt}) + })) + defer server.Close() + t.Setenv(agentcli.EnvAPIBaseURL, server.URL) + t.Setenv(agentcli.EnvAuthJWE, "token") + path := filepath.Join(".dify_conf", "prompt.md") + args := []string{"config", "prompt", "pull"} + if customPath { + path = filepath.Join("custom", "prompt.txt") + args = append(args, "--to", path) + } + output, err := runPromptCLI(t, args...) + if err != nil { + t.Fatal(err) + } + absolutePath, err := filepath.Abs(path) + if err != nil { + t.Fatal(err) + } + if output != absolutePath+"\n" { + t.Errorf("output = %q, want path %q", output, absolutePath) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != prompt { + t.Fatalf("saved prompt = %q, err = %v", data, err) + } + }) + } + } +} + +func TestConfigPromptPush(t *testing.T) { + for _, source := range []string{"default", "file", "stdin"} { + for _, prompt := range []string{" 新提示 🪷 [§file:guide.md:Guide§]\r\n\n", ""} { + t.Run(source+prompt, func(t *testing.T) { + t.Chdir(t.TempDir()) + want := map[string]any{"prompt": prompt} + var received map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/agent-stub/config/prompt" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL) + } + if r.Header.Get("Authorization") != "Bearer token" { + t.Error("missing Agent Stub authorization") + } + if err := json.NewDecoder(r.Body).Decode(&received); err != nil { + t.Error(err) + } + _ = json.NewEncoder(w).Encode(received) + })) + defer server.Close() + t.Setenv(agentcli.EnvAPIBaseURL, server.URL) + t.Setenv(agentcli.EnvAuthJWE, "token") + if err := os.MkdirAll(".dify_conf", 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(".dify_conf", "prompt.md") + if source != "default" { + path = "input.txt" + } + if err := os.WriteFile(path, []byte(prompt), 0o644); err != nil { + t.Fatal(err) + } + args := []string{"config", "prompt", "push"} + switch source { + case "file": + args = append(args, path) + case "stdin": + input, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = input.Close() }() + original := os.Stdin + defer func() { os.Stdin = original }() + os.Stdin = input + args = append(args, "-") + } + output, err := runPromptCLI(t, args...) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(received, want) { + t.Fatalf("request payload = %#v", received) + } + var response map[string]any + if err := json.Unmarshal([]byte(output), &response); err != nil || !reflect.DeepEqual(response, want) { + t.Fatalf("output = %q, err = %v", output, err) + } + }) + } + } +} + +func TestConfigPromptPullRejectsBadResponsesWithoutOverwritingFile(t *testing.T) { + for _, body := range []string{`{}`, `{"prompt":null}`, `{"prompt":123}`, `not-json`} { + t.Run(body, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + t.Setenv(agentcli.EnvAPIBaseURL, server.URL) + t.Setenv(agentcli.EnvAuthJWE, "token") + path := filepath.Join(t.TempDir(), "prompt.md") + if err := os.WriteFile(path, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + output, err := runPromptCLI(t, "config", "prompt", "pull", "--to", path) + if err == nil || output != "" { + t.Fatalf("output = %q, err = %v; want failure without output", output, err) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "keep" { + t.Fatalf("existing file changed: %q, err = %v", data, err) + } + }) + } +} + +func TestConfigPromptReportsFailures(t *testing.T) { + t.Chdir(t.TempDir()) + var requestCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount.Add(1) + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"detail":{"code":"config_not_writable","message":"read only"}}`)) + })) + defer server.Close() + t.Setenv(agentcli.EnvAPIBaseURL, server.URL) + t.Setenv(agentcli.EnvAuthJWE, "token") + if err := os.WriteFile("prompt.md", []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"config", "prompt", "pull", "--to", "prompt.md"}, + {"config", "prompt", "push", "prompt.md"}, + } { + output, err := runPromptCLI(t, args...) + if err == nil || !strings.Contains(err.Error(), "HTTP 403") || output != "" { + t.Errorf("%v: output = %q, err = %v", args, output, err) + } + } + for _, args := range [][]string{ + {"config", "prompt", "push"}, // Missing default file must not clear the prompt. + {"config", "prompt", "pull", "unexpected"}, + {"config", "prompt", "push", "one", "two"}, + } { + if _, err := runPromptCLI(t, args...); err == nil { + t.Errorf("%v: expected error", args) + } + } + if err := os.WriteFile("invalid.txt", []byte{0xff}, 0o644); err != nil { + t.Fatal(err) + } + if _, err := runPromptCLI(t, "config", "prompt", "push", "invalid.txt"); err == nil || !strings.Contains(err.Error(), "UTF-8") { + t.Errorf("invalid text error = %v", err) + } + if requestCount.Load() != 2 { + t.Errorf("unexpected requests for invalid input: %d", requestCount.Load()) + } + data, err := os.ReadFile("prompt.md") + if err != nil || string(data) != "keep" { + t.Fatalf("existing file changed: %q, err = %v", data, err) + } +} diff --git a/dify-agent-runtime/internal/agentcli/client.go b/dify-agent-runtime/internal/agentcli/client.go index e58efd50cff..8f8b516b1ce 100644 --- a/dify-agent-runtime/internal/agentcli/client.go +++ b/dify-agent-runtime/internal/agentcli/client.go @@ -16,6 +16,8 @@ type StubClient interface { PushConfig(ctx context.Context, payload any) ([]byte, error) PatchConfigEnv(ctx context.Context, envText string) ([]byte, error) PutConfigNote(ctx context.Context, note string) ([]byte, error) + GetConfigPrompt(ctx context.Context) ([]byte, error) + PutConfigPrompt(ctx context.Context, prompt string) ([]byte, error) // Data-plane (always HTTP, signed URLs) UploadFileToURL(uploadURL, filePath, filename, mimetype string) ([]byte, error) diff --git a/dify-agent-runtime/internal/agentcli/client_http.go b/dify-agent-runtime/internal/agentcli/client_http.go index 495bf0a822c..ff9c0df14dd 100644 --- a/dify-agent-runtime/internal/agentcli/client_http.go +++ b/dify-agent-runtime/internal/agentcli/client_http.go @@ -202,6 +202,28 @@ func (c *httpStubClient) PutConfigNote(_ context.Context, note string) ([]byte, return body, nil } +func (c *httpStubClient) GetConfigPrompt(_ context.Context) ([]byte, error) { + body, statusCode, err := c.http.getJSON("/config/prompt", nil) + if err != nil { + return nil, err + } + if err := checkAgentStubHTTPError(body, statusCode); err != nil { + return nil, fmt.Errorf("agent stub config prompt read failed: %w", err) + } + return body, nil +} + +func (c *httpStubClient) PutConfigPrompt(_ context.Context, prompt string) ([]byte, error) { + body, statusCode, err := c.http.putJSON("/config/prompt", map[string]string{"prompt": prompt}) + if err != nil { + return nil, err + } + if err := checkAgentStubHTTPError(body, statusCode); err != nil { + return nil, fmt.Errorf("agent stub config prompt update failed: %w", err) + } + return body, nil +} + func (c *httpStubClient) UploadFileToURL(uploadURL, filePath, filename, mimetype string) ([]byte, error) { return c.http.uploadFile(uploadURL, filePath, filename, mimetype) } diff --git a/dify-agent-runtime/internal/agentcli/config.go b/dify-agent-runtime/internal/agentcli/config.go index ef34676792a..3a2cf5d277e 100644 --- a/dify-agent-runtime/internal/agentcli/config.go +++ b/dify-agent-runtime/internal/agentcli/config.go @@ -5,9 +5,11 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "sync" + "unicode/utf8" ) const ( @@ -566,6 +568,82 @@ func RunConfigNotePush(env *Environment, localPath string) error { return nil } +// RunConfigPromptPull saves the raw system prompt without expanding mentions. +func RunConfigPromptPull(env *Environment, localPath string) error { + client, err := NewStubClient(env) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + + body, err := client.GetConfigPrompt(context.Background()) + if err != nil { + return err + } + var response struct { + Prompt *string `json:"prompt"` + } + if err := json.Unmarshal(body, &response); err != nil { + return fmt.Errorf("parse prompt response: %w", err) + } + if response.Prompt == nil { + return fmt.Errorf("prompt response is missing prompt text") + } + + targetPath := localPath + if targetPath == "" { + targetPath = filepath.Join(defaultConfigBase, "prompt.md") + } + absPath, err := filepath.Abs(targetPath) + if err != nil { + return fmt.Errorf("resolve path: %w", err) + } + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + if err := os.WriteFile(absPath, []byte(*response.Prompt), 0o644); err != nil { + return fmt.Errorf("write prompt: %w", err) + } + fmt.Println(absPath) + return nil +} + +// RunConfigPromptPush replaces the build-draft prompt; empty text clears it. +func RunConfigPromptPush(env *Environment, localPath string) error { + var data []byte + var err error + if localPath == "-" { + data, err = io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("read stdin: %w", err) + } + } else { + if localPath == "" { + localPath = filepath.Join(defaultConfigBase, "prompt.md") + } + data, err = os.ReadFile(localPath) + if err != nil { + return fmt.Errorf("read file: %w", err) + } + } + if !utf8.Valid(data) { + return fmt.Errorf("prompt must be valid UTF-8 text") + } + + client, err := NewStubClient(env) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + + body, err := client.PutConfigPrompt(context.Background(), string(data)) + if err != nil { + return err + } + fmt.Println(string(body)) + return nil +} + // extractZipArchive extracts a zip file into targetDir. func extractZipArchive(archivePath string, targetDir string) error { return extractZip(archivePath, targetDir) diff --git a/dify-agent/README.md b/dify-agent/README.md index 3cac71369aa..7c63a8a8a16 100644 --- a/dify-agent/README.md +++ b/dify-agent/README.md @@ -4,5 +4,10 @@ Dify Agent runs Pydantic AI with native capabilities and toolsets. Module configuration and session state are JSON data; infrastructure services and operation resources have explicit owners. +In the sandbox, `dify-agent config prompt pull` exports the raw system prompt to +`./.dify_conf/prompt.md`; `dify-agent config prompt push` saves local edits to the +current account's build draft. Only later requests built from that draft use +the saved prompt. See the runtime documentation for access rules and stdin usage. + See [runtime documentation](docs/dify-agent/index.md) and the [operations guide](docs/dify-agent/guide/index.md). Run `make docs` to build docs. diff --git a/dify-agent/docs/dify-agent/index.md b/dify-agent/docs/dify-agent/index.md index bf5a55be667..9bbcdb58760 100644 --- a/dify-agent/docs/dify-agent/index.md +++ b/dify-agent/docs/dify-agent/index.md @@ -39,6 +39,27 @@ assembly and native Agent arguments. Knowledge eager results stay in the user role through a native model-request hook. Tool dispatch, instructions and capability hooks use native Pydantic AI interfaces. +The sandbox's installed `dify-agent` CLI can read the saved Agent Soul system +prompt with `dify-agent config prompt pull [--to PATH]`. It writes UTF-8 text to +`./.dify_conf/prompt.md` by default and prints the file's absolute path. The text +includes raw, unexpanded mention markers; whitespace and line endings are preserved. +The prompt is fetched separately and is not included in the config manifest. + +To persist an edit, use `dify-agent config prompt push [PATH|-]`. Omitting PATH +reads the default file; `-` reads stdin. Push fully replaces the prompt and prints +`{"prompt": "saved text"}` as JSON. Empty text clears it. A missing file, invalid +UTF-8, missing prompt field, or JSON null is an error. Local edits alone do not +change Agent configuration. + +Authenticated `GET/PUT /agent-stub/config/prompt` exchange `{"prompt": "raw text"}` +with `GET/PUT /inner/api/agent-config//prompt`. The Stub derives tenant, +agent, user, and version scope solely from the signed execution context. Reads +follow snapshot, shared-draft, and personal build-draft access rules. Writes are +allowed only to the current account's build draft and only replace +`AgentSoulConfig.prompt.system_prompt`. Later requests built from that draft use +the saved prompt and resolve its mentions; already-built requests keep their +prompt. Draft merge and publication retain their existing lifecycle. + See [run lifecycle](concepts/run-lifecycle/index.md), [resources](concepts/runtime-resources/index.md), and the [operations guide](guide/index.md). diff --git a/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py b/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py index d776f92ec83..59e754251f5 100644 --- a/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py +++ b/dify-agent/src/dify_agent/agent_stub/protocol/__init__.py @@ -12,6 +12,8 @@ from .agent_stub import ( AgentStubConfigFileRef, AgentStubConfigManifestResponse, AgentStubConfigNoteUpdateRequest, + AgentStubConfigPromptResponse, + AgentStubConfigPromptUpdateRequest, AgentStubConfigPushFileItem, AgentStubConfigPushRequest, AgentStubConfigPushResponse, @@ -28,6 +30,7 @@ from .agent_stub import ( agent_stub_config_env_url, agent_stub_config_manifest_url, agent_stub_config_note_url, + agent_stub_config_prompt_url, agent_stub_config_push_url, agent_stub_config_skill_inspect_url, agent_stub_connections_url, @@ -50,6 +53,8 @@ __all__ = [ "AgentStubConfigFileRef", "AgentStubConfigManifestResponse", "AgentStubConfigNoteUpdateRequest", + "AgentStubConfigPromptResponse", + "AgentStubConfigPromptUpdateRequest", "AgentStubConfigPushFileItem", "AgentStubConfigPushRequest", "AgentStubConfigPushResponse", @@ -66,6 +71,7 @@ __all__ = [ "agent_stub_config_env_url", "agent_stub_config_manifest_url", "agent_stub_config_note_url", + "agent_stub_config_prompt_url", "agent_stub_config_push_url", "agent_stub_config_skill_inspect_url", "agent_stub_connections_url", diff --git a/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py index ffd4aca3782..f6e792ace59 100644 --- a/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py +++ b/dify-agent/src/dify_agent/agent_stub/protocol/agent_stub.py @@ -110,6 +110,10 @@ def agent_stub_config_note_url(base_url: str) -> str: return f"{_require_http_base_url(base_url)}/config/note" +def agent_stub_config_prompt_url(base_url: str) -> str: + return f"{_require_http_base_url(base_url)}/config/prompt" + + def is_canonical_dify_file_reference(reference: str) -> bool: """Return whether one string matches Dify's opaque file reference format.""" prefix = "dify-file-ref:" @@ -330,6 +334,20 @@ class AgentStubConfigNoteUpdateRequest(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") +class AgentStubConfigPromptUpdateRequest(BaseModel): + """Full raw prompt replacement. Empty text clears; missing/null is invalid.""" + + prompt: str + + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + +class AgentStubConfigPromptResponse(BaseModel): + """Saved system prompt text, including unexpanded mention markers.""" + + prompt: str + + def _require_http_base_url(base_url: str) -> str: return parse_agent_stub_endpoint(base_url).url @@ -348,6 +366,8 @@ __all__ = [ "AgentStubConfigFileRef", "AgentStubConfigManifestResponse", "AgentStubConfigNoteUpdateRequest", + "AgentStubConfigPromptResponse", + "AgentStubConfigPromptUpdateRequest", "AgentStubConfigPushFileItem", "AgentStubConfigPushRequest", "AgentStubConfigPushResponse", @@ -364,6 +384,7 @@ __all__ = [ "agent_stub_config_env_url", "agent_stub_config_manifest_url", "agent_stub_config_note_url", + "agent_stub_config_prompt_url", "agent_stub_config_push_url", "agent_stub_config_skill_inspect_url", "agent_stub_connections_url", diff --git a/dify-agent/src/dify_agent/agent_stub/server/agent_stub_config.py b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_config.py index 993267c8c7f..918255df96c 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/agent_stub_config.py +++ b/dify-agent/src/dify_agent/agent_stub/server/agent_stub_config.py @@ -18,6 +18,7 @@ from pydantic import ValidationError from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubConfigDownloadSource, AgentStubConfigManifestResponse, + AgentStubConfigPromptResponse, AgentStubConfigPushRequest, AgentStubConfigPushResponse, AgentStubFileDownloadResponse, @@ -50,6 +51,10 @@ class AgentStubConfigRequestHandler(Protocol): async def update_note(self, *, principal: AgentStubPrincipal, note: str) -> dict[str, object]: ... + async def get_prompt(self, *, principal: AgentStubPrincipal) -> AgentStubConfigPromptResponse: ... + + async def update_prompt(self, *, principal: AgentStubPrincipal, prompt: str) -> AgentStubConfigPromptResponse: ... + class AgentStubConfigRequestError(RuntimeError): status_code: int @@ -182,6 +187,28 @@ class DifyApiAgentStubConfigRequestHandler: raise AgentStubConfigRequestError(502, "Dify API config note response is invalid") return payload + async def get_prompt(self, *, principal: AgentStubPrincipal) -> AgentStubConfigPromptResponse: + execution_context = self._require_config_context(principal.execution_context) + payload = await self._get_inner_api_json( + f"/inner/api/agent-config/{execution_context.agent_id}/prompt", + self._config_query_params(execution_context), + ) + try: + return AgentStubConfigPromptResponse.model_validate(payload) + except ValidationError as exc: + raise AgentStubConfigRequestError(502, "Dify API config prompt response is invalid") from exc + + async def update_prompt(self, *, principal: AgentStubPrincipal, prompt: str) -> AgentStubConfigPromptResponse: + execution_context = self._require_user_context(self._require_config_context(principal.execution_context)) + payload = await self._put_inner_api_json( + f"/inner/api/agent-config/{execution_context.agent_id}/prompt", + {**self._config_query_params(execution_context), "prompt": prompt}, + ) + try: + return AgentStubConfigPromptResponse.model_validate(payload) + except ValidationError as exc: + raise AgentStubConfigRequestError(502, "Dify API config prompt response is invalid") from exc + @staticmethod def _require_config_context(execution_context: DifyExecutionContextLayerConfig) -> DifyExecutionContextLayerConfig: if execution_context.agent_id is None: diff --git a/dify-agent/src/dify_agent/agent_stub/server/control_plane.py b/dify-agent/src/dify_agent/agent_stub/server/control_plane.py index 23cc1ee7d72..9c6e302fe6f 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/control_plane.py +++ b/dify-agent/src/dify_agent/agent_stub/server/control_plane.py @@ -13,6 +13,7 @@ from uuid import uuid4 from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubConnectResponse, AgentStubConfigManifestResponse, + AgentStubConfigPromptResponse, AgentStubConfigPushRequest, AgentStubConfigPushResponse, AgentStubFileDownloadRequest, @@ -174,6 +175,22 @@ class AgentStubControlPlaneService: except AgentStubConfigRequestError as exc: raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + async def get_config_prompt(self, *, authorization: str | None) -> AgentStubConfigPromptResponse: + principal = self._authenticate(authorization, expose_expiration=True) + handler = self._require_config_request_handler() + try: + return await handler.get_prompt(principal=principal) + except AgentStubConfigRequestError as exc: + raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + + async def update_config_prompt(self, *, prompt: str, authorization: str | None) -> AgentStubConfigPromptResponse: + principal = self._authenticate(authorization, expose_expiration=True) + handler = self._require_config_request_handler() + try: + return await handler.update_prompt(principal=principal, prompt=prompt) + except AgentStubConfigRequestError as exc: + raise AgentStubControlPlaneError(exc.status_code, exc.detail) from exc + def _authenticate(self, authorization: str | None, *, expose_expiration: bool = False) -> AgentStubPrincipal: token_codec = self.token_codec if token_codec is None: diff --git a/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py b/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py index 6778a590556..cc17e6d5993 100644 --- a/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py +++ b/dify-agent/src/dify_agent/agent_stub/server/routes/agent_stub.py @@ -15,6 +15,8 @@ from dify_agent.agent_stub.protocol.agent_stub import ( AgentStubConfigEnvUpdateRequest, AgentStubConfigManifestResponse, AgentStubConfigNoteUpdateRequest, + AgentStubConfigPromptResponse, + AgentStubConfigPromptUpdateRequest, AgentStubConfigPushRequest, AgentStubConfigPushResponse, AgentStubFileDownloadRequest, @@ -126,6 +128,25 @@ def create_agent_stub_http_router( except AgentStubControlPlaneError as exc: raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + @router.get("/config/prompt", response_model=AgentStubConfigPromptResponse) + async def get_config_prompt( + authorization: str | None = Header(default=None, alias="Authorization"), + ) -> AgentStubConfigPromptResponse: + try: + return await service.get_config_prompt(authorization=authorization) + except AgentStubControlPlaneError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + + @router.put("/config/prompt", response_model=AgentStubConfigPromptResponse) + async def update_config_prompt( + request: AgentStubConfigPromptUpdateRequest, + authorization: str | None = Header(default=None, alias="Authorization"), + ) -> AgentStubConfigPromptResponse: + try: + return await service.update_config_prompt(prompt=request.prompt, authorization=authorization) + except AgentStubControlPlaneError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc + return router diff --git a/dify-agent/src/dify_agent/layers/_agent_cli_help.json b/dify-agent/src/dify_agent/layers/_agent_cli_help.json index ebc8364be4c..edb8ea9cee3 100644 --- a/dify-agent/src/dify_agent/layers/_agent_cli_help.json +++ b/dify-agent/src/dify_agent/layers/_agent_cli_help.json @@ -1,5 +1,5 @@ { - "config": "Inspect or update Agent Soul-backed config assets through the Agent Stub.\n\nUsage:\n dify-agent config [command]\n\nAvailable Commands:\n env Update config env variables visible to the current run.\n files Pull or update config files through the Agent Stub.\n manifest Show the current visible Agent config manifest as JSON.\n note Pull or update the current config note.\n skills Pull or update config skills through the Agent Stub.\n\nFlags:\n -h, --help help for config\n\nUse \"dify-agent config [command] --help\" for more information about a command.", + "config": "Inspect or update Agent Soul-backed config assets through the Agent Stub.\n\nUsage:\n dify-agent config [command]\n\nAvailable Commands:\n env Update config env variables visible to the current run.\n files Pull or update config files through the Agent Stub.\n manifest Show the current visible Agent config manifest as JSON.\n note Pull or update the current config note.\n prompt Pull or replace the raw system prompt, including mention markers.\n skills Pull or update config skills through the Agent Stub.\n\nFlags:\n -h, --help help for config\n\nUse \"dify-agent config [command] --help\" for more information about a command.", "config env": "Update config env variables visible to the current run.\n\nUsage:\n dify-agent config env [command]\n\nAvailable Commands:\n push Set or delete config env entries from one local dotenv file or stdin.\n\nFlags:\n -h, --help help for env\n\nUse \"dify-agent config env [command] --help\" for more information about a command.", "config env push": "Set or delete config env entries from one local dotenv file or stdin.\n\nUsage:\n dify-agent config env push PATH|- [flags]\n\nFlags:\n -h, --help help for push", "config files": "Pull or update config files through the Agent Stub.\n\nUsage:\n dify-agent config files [command]\n\nAvailable Commands:\n delete Delete one or more config files by name without touching local files.\n pull Pull one or all visible config files into ./.dify_conf/files by default.\n push Upload one or more local files into the current config manifest.\n\nFlags:\n -h, --help help for files\n\nUse \"dify-agent config files [command] --help\" for more information about a command.", @@ -10,6 +10,9 @@ "config note": "Pull or update the current config note.\n\nUsage:\n dify-agent config note [command]\n\nAvailable Commands:\n pull Export the current config note into ./.dify_conf/note.md by default.\n push Replace the current config note from one local text file or stdin.\n\nFlags:\n -h, --help help for note\n\nUse \"dify-agent config note [command] --help\" for more information about a command.", "config note pull": "Export the current config note into ./.dify_conf/note.md by default.\n\nUsage:\n dify-agent config note pull [flags]\n\nFlags:\n -h, --help help for pull\n --to string Local markdown file path.", "config note push": "Replace the current config note from one local text file or stdin.\n\nUsage:\n dify-agent config note push [PATH|-] [flags]\n\nFlags:\n -h, --help help for push", + "config prompt": "Pull or replace the raw system prompt, including mention markers.\n\nUsage:\n dify-agent config prompt [command]\n\nAvailable Commands:\n pull Export the raw system prompt into ./.dify_conf/prompt.md by default.\n push Replace the build-draft prompt from a UTF-8 file or stdin; empty text clears it.\n\nFlags:\n -h, --help help for prompt\n\nUse \"dify-agent config prompt [command] --help\" for more information about a command.", + "config prompt pull": "Export the raw system prompt into ./.dify_conf/prompt.md by default.\n\nUsage:\n dify-agent config prompt pull [flags]\n\nFlags:\n -h, --help help for pull\n --to string Local UTF-8 text file path.", + "config prompt push": "Replace the build-draft system prompt from a UTF-8 file or stdin (-). Defaults to ./.dify_conf/prompt.md and prints the saved prompt as JSON. Empty text clears the prompt. Only subsequent run requests built from this draft use the saved prompt; already-built run requests keep their prompt.\n\nUsage:\n dify-agent config prompt push [PATH|-] [flags]\n\nFlags:\n -h, --help help for push", "config skills": "Pull or update config skills through the Agent Stub.\n\nUsage:\n dify-agent config skills [command]\n\nAvailable Commands:\n delete Delete one or more config skills by name without touching local directories.\n pull Pull one or all visible config skills into ./.dify_conf/skills by default.\n push Upload one or more local skill directories into the current config manifest.\n\nFlags:\n -h, --help help for skills\n\nUse \"dify-agent config skills [command] --help\" for more information about a command.", "config skills delete": "Delete one or more config skills by name without touching local directories.\n\nUsage:\n dify-agent config skills delete NAME... [flags]\n\nFlags:\n -h, --help help for delete", "config skills pull": "Pull one or all visible config skills into ./.dify_conf/skills by default.\n\nUsage:\n dify-agent config skills pull [NAME]... [flags]\n\nFlags:\n -h, --help help for pull\n --json Emit the pull result as JSON.\n --to string Local directory for pulled config skills.", diff --git a/dify-agent/src/dify_agent/layers/config/layer.py b/dify-agent/src/dify_agent/layers/config/layer.py index 08060a4dea0..ecb5f332c74 100644 --- a/dify-agent/src/dify_agent/layers/config/layer.py +++ b/dify-agent/src/dify_agent/layers/config/layer.py @@ -34,19 +34,24 @@ for command names, arguments, and options. Config persistence rules: -- Local shell edits to config files, skills, env, or notes are not saved by themselves. +- Local shell edits to config files, skills, env, notes, or the prompt are not saved by themselves. - To persist an Agent config change, run the matching `dify-agent config ...` mutation command. - Mutation commands are available only when the manifest shows `config_version.kind` as `build_draft` and - `config_version.writable` as true.""" + `config_version.writable` as true. +- `config prompt pull` reads the raw system prompt, including unexpanded mention markers. Edit the local file and + run `config prompt push` to persist it. Only subsequent run requests built from that draft use the saved prompt; + already-built run requests keep their prompt.""" _CONFIG_CLI_HELP_COMMANDS: dict[str, tuple[str, ...]] = { "dify-agent config --help": ("config",), "dify-agent config manifest --help": ("config", "manifest"), "dify-agent config skills pull --help": ("config", "skills", "pull"), "dify-agent config files pull --help": ("config", "files", "pull"), "dify-agent config note pull --help": ("config", "note", "pull"), + "dify-agent config prompt pull --help": ("config", "prompt", "pull"), } _CONFIG_CLI_MUTATION_HELP_COMMANDS: dict[str, tuple[str, ...]] = { "dify-agent config note push --help": ("config", "note", "push"), + "dify-agent config prompt push --help": ("config", "prompt", "push"), "dify-agent config env push --help": ("config", "env", "push"), "dify-agent config files push --help": ("config", "files", "push"), "dify-agent config files delete --help": ("config", "files", "delete"), diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_config.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_config.py index 6215192a98b..61c26ffbf1b 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_config.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_config.py @@ -115,6 +115,154 @@ async def test_dify_api_handler_manifest_success(monkeypatch: pytest.MonkeyPatch } +@pytest.mark.parametrize("prompt", [" 原文 🪷 [§skill:research:Research§]\r\n\n", ""]) +def test_http_prompt_routes_use_signed_scope_and_preserve_text(monkeypatch: pytest.MonkeyPatch, prompt: str) -> None: + original_async_client = httpx.AsyncClient + saved = {"prompt": "original"} + requests: list[str] = [] + scope = { + "tenant_id": "tenant-1", + "user_id": "user-1", + "config_version_id": "cfg-1", + "config_version_kind": "build_draft", + } + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/inner/api/agent-config/agent-1/prompt" + assert request.headers["X-Inner-Api-Key"] == "inner-secret" + requests.append(request.method) + if request.method == "GET": + assert dict(request.url.params) == scope + else: + assert request.method == "PUT" + assert json.loads(request.content) == {**scope, "prompt": prompt} + saved["prompt"] = prompt + return httpx.Response(200, json=saved) + + monkeypatch.setattr( + "dify_agent.agent_stub.server.agent_stub_config.httpx.AsyncClient", + lambda **kwargs: original_async_client(transport=httpx.MockTransport(handler), **kwargs), + ) + codec = _token_codec() + app = FastAPI() + app.include_router( + create_agent_stub_http_router( + codec, + config_request_handler=DifyApiAgentStubConfigRequestHandler( + inner_api_url="https://api.example.com", + inner_api_key="inner-secret", + sandbox_files_base_url="https://sandbox-files.example.com", + ), + ) + ) + with TestClient(app) as client: + headers = {"Authorization": f"Bearer {codec.encode_connection_token(_execution_context())}"} + assert client.get( + "/agent-stub/config/prompt", + headers=headers, + params={**dict.fromkeys(scope, "other"), "agent_id": "other"}, + ).json() == {"prompt": "original"} + response = client.put("/agent-stub/config/prompt", headers=headers, json={"prompt": prompt}) + assert response.status_code == 200 + assert response.json() == {"prompt": prompt} + assert client.get("/agent-stub/config/prompt", headers=headers).json() == {"prompt": prompt} + assert requests == ["GET", "PUT", "GET"] + + +@pytest.mark.parametrize("method", ["GET", "PUT"]) +@pytest.mark.parametrize( + ("status", "payload", "expected_status"), + [ + (200, {}, 502), + (200, {"prompt": None}, 502), + (200, {"prompt": 123}, 502), + (403, {"code": "config_not_writable"}, 403), + ], +) +def test_http_prompt_routes_map_inner_response_errors( + monkeypatch: pytest.MonkeyPatch, method: str, status: int, payload: object, expected_status: int +) -> None: + original_async_client = httpx.AsyncClient + monkeypatch.setattr( + "dify_agent.agent_stub.server.agent_stub_config.httpx.AsyncClient", + lambda **kwargs: original_async_client( + transport=httpx.MockTransport(lambda request: httpx.Response(status, json=payload)), **kwargs + ), + ) + codec = _token_codec() + app = FastAPI() + app.include_router( + create_agent_stub_http_router( + codec, + config_request_handler=DifyApiAgentStubConfigRequestHandler( + inner_api_url="https://api.example.com", + inner_api_key="inner-secret", + sandbox_files_base_url="https://sandbox-files.example.com", + ), + ) + ) + with TestClient(app) as client: + response = client.request( + method, + "/agent-stub/config/prompt", + headers={"Authorization": f"Bearer {codec.encode_connection_token(_execution_context())}"}, + json={"prompt": "new"} if method == "PUT" else None, + ) + assert response.status_code == expected_status + if status == 403: + assert response.json() == {"detail": payload} + + +@pytest.mark.parametrize( + "body", + [ + {}, + {"prompt": None}, + {"prompt": 123}, + *[ + {"prompt": "x", key: "other"} + for key in ("tenant_id", "agent_id", "user_id", "config_version_id", "config_version_kind") + ], + ], +) +def test_http_prompt_rejects_invalid_text_and_sandbox_scope_overrides(body: dict[str, object]) -> None: + codec = _token_codec() + app = FastAPI() + app.include_router(create_agent_stub_http_router(codec)) + with TestClient(app) as client: + response = client.put( + "/agent-stub/config/prompt", + headers={"Authorization": f"Bearer {codec.encode_connection_token(_execution_context())}"}, + json=body, + ) + assert response.status_code == 422 + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("operation", "field"), + [ + (operation, field) + for operation in ("get_prompt", "update_prompt") + for field in ("agent_id", "agent_config_version_id", "agent_config_version_kind", "user_id") + if operation == "update_prompt" or field != "user_id" + ], +) +async def test_prompt_requires_execution_scope(operation: str, field: str) -> None: + handler = DifyApiAgentStubConfigRequestHandler( + inner_api_url="https://api.example.com", + inner_api_key="secret", + sandbox_files_base_url="https://files.example.com", + ) + with pytest.raises(AgentStubConfigRequestError) as exc_info: + principal = _principal(**{field: None}) + if operation == "get_prompt": + await handler.get_prompt(principal=principal) + else: + await handler.update_prompt(principal=principal, prompt="new") + assert exc_info.value.status_code == 400 + + @pytest.mark.anyio async def test_dify_api_handler_config_download_returns_sandbox_data_plane_url( monkeypatch: pytest.MonkeyPatch, diff --git a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py index 2fdda63efc3..25e3aadc736 100644 --- a/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py +++ b/dify-agent/tests/local/dify_agent/agent_stub/server/test_agent_stub_routes.py @@ -152,6 +152,8 @@ def test_agent_stub_file_download_and_config_routes_return_structured_401_for_ex json={"file": {"transfer_method": "tool_file", "reference": _reference("tool-file-1")}}, ), client.get("/agent-stub/config/manifest", headers=headers), + client.get("/agent-stub/config/prompt", headers=headers), + client.put("/agent-stub/config/prompt", headers=headers, json={"prompt": "new"}), ] for response in responses: diff --git a/dify-agent/tests/local/dify_agent/layers/config/test_layer.py b/dify-agent/tests/local/dify_agent/layers/config/test_layer.py index 1190b861df6..bd068b0d0c8 100644 --- a/dify-agent/tests/local/dify_agent/layers/config/test_layer.py +++ b/dify-agent/tests/local/dify_agent/layers/config/test_layer.py @@ -84,5 +84,10 @@ async def test_first_pull_failure_retries_then_current_config_refreshes_without_ assert "original" in seen_instructions[0] and "revised" in seen_instructions[1] assert "dify-agent config env push --help" not in seen_instructions[0] assert "dify-agent config env push --help" in seen_instructions[1] + assert "dify-agent config prompt pull --help" in seen_instructions[0] + assert "dify-agent config prompt pull --help" in seen_instructions[1] + assert "dify-agent config prompt push --help" not in seen_instructions[0] + assert "dify-agent config prompt push --help" in seen_instructions[1] + assert "already-built run requests keep their prompt" in seen_instructions[1] assert set(state) == {"initialized", "pulled_skill_outputs", "pulled_file_outputs"} assert "Instructions from skill" in state["pulled_skill_outputs"]["skill"]