feat(agent): edit config prompts through the sandbox CLI (#44561)

This commit is contained in:
盐粒 Yanli authored and GitHub committed 2026-10-23 02:51:24 +00:00
1 parent 65e1fbcc2b
commit 9dc53da3eb
25 files changed
+1067 -13

No files matched your search

+1
View File
@@ -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",
@@ -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/<string:agent_id>/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
+54 -4
View File
@@ -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,
@@ -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)
@@ -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:
+20
View File
@@ -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:
@@ -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",
+42 -2
View File
@@ -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",
@@ -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"},
@@ -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)
}
}
@@ -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)
@@ -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)
}
@@ -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)
+5
View File
@@ -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.
+21
View File
@@ -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/<agent_id>/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).
@@ -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",
@@ -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",
@@ -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:
@@ -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:
@@ -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
@@ -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.",
@@ -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"),
@@ -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,
@@ -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:
@@ -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"]