mirror of
https://github.com/langgenius/dify.git
synced 2026-06-18 07:41:09 +08:00
fix(api): allow inline workflow agent soul saves (#37563)
This commit is contained in:
parent
f203ab7f1d
commit
3b0f6aef8e
@ -115,7 +115,15 @@ class AgentComposerService:
|
||||
and binding is not None
|
||||
and binding.agent_id
|
||||
and payload.save_strategy
|
||||
in (ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, ComposerSaveStrategy.SAVE_AS_NEW_VERSION)
|
||||
in (
|
||||
ComposerSaveStrategy.NODE_JOB_ONLY,
|
||||
ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
|
||||
ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
|
||||
)
|
||||
and (
|
||||
payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY
|
||||
or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT
|
||||
)
|
||||
):
|
||||
cls._require_drive_refs_resolved(
|
||||
tenant_id=tenant_id, agent_id=binding.agent_id, agent_soul=payload.agent_soul
|
||||
@ -823,6 +831,26 @@ class AgentComposerService:
|
||||
node_job = payload.node_job or WorkflowNodeJobConfig()
|
||||
if binding:
|
||||
binding.node_job_config = node_job
|
||||
if payload.agent_soul is not None and binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
|
||||
current_snapshot = cls._require_version(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=binding.agent_id,
|
||||
version_id=binding.current_snapshot_id,
|
||||
)
|
||||
version = cls._update_current_version(
|
||||
current_snapshot=current_snapshot,
|
||||
account_id=account_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
|
||||
version_note=payload.version_note,
|
||||
)
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=binding.agent_id)
|
||||
if agent.scope != AgentScope.WORKFLOW_ONLY:
|
||||
raise ValueError("Inline workflow agent binding must point to a workflow-only agent")
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
agent.updated_by = account_id
|
||||
binding.current_snapshot_id = version.id
|
||||
binding.updated_by = account_id
|
||||
return binding
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ from services.agent.prompt_mentions import (
|
||||
from services.entities.agent_entities import (
|
||||
AgentSoulConfig,
|
||||
ComposerSavePayload,
|
||||
ComposerSaveStrategy,
|
||||
ComposerVariant,
|
||||
WorkflowNodeJobConfig,
|
||||
)
|
||||
@ -50,7 +51,12 @@ _DANGEROUS_ACK_KEYS = (
|
||||
class ComposerConfigValidator:
|
||||
@classmethod
|
||||
def validate_save_payload(cls, payload: ComposerSavePayload) -> None:
|
||||
if payload.variant == ComposerVariant.WORKFLOW and payload.soul_lock.locked and payload.agent_soul is not None:
|
||||
if (
|
||||
payload.variant == ComposerVariant.WORKFLOW
|
||||
and payload.soul_lock.locked
|
||||
and payload.agent_soul is not None
|
||||
and payload.save_strategy != ComposerSaveStrategy.NODE_JOB_ONLY
|
||||
):
|
||||
raise AgentSoulLockedError()
|
||||
|
||||
if payload.agent_soul is not None:
|
||||
|
||||
@ -51,6 +51,19 @@ def test_locked_workflow_soul_rejects_soul_changes():
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
|
||||
|
||||
def test_locked_workflow_node_job_only_allows_inline_soul_payload():
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.WORKFLOW,
|
||||
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY,
|
||||
"soul_lock": {"locked": True},
|
||||
"agent_soul": {"prompt": {"system_prompt": "changed"}},
|
||||
}
|
||||
)
|
||||
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
|
||||
|
||||
def test_agent_app_soul_allows_app_features_and_variables():
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
|
||||
@ -459,6 +459,125 @@ def test_composer_save_helpers_create_and_rebind_agents(monkeypatch):
|
||||
assert new_version_binding.current_snapshot_id == "new-version-1"
|
||||
|
||||
|
||||
def test_node_job_only_updates_inline_agent_soul(monkeypatch):
|
||||
fake_session = FakeSession()
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
inline_agent = SimpleNamespace(
|
||||
id="inline-agent-1",
|
||||
scope=AgentScope.WORKFLOW_ONLY,
|
||||
active_config_snapshot_id="inline-version-1",
|
||||
active_config_has_model=False,
|
||||
updated_by=None,
|
||||
)
|
||||
current_snapshot = AgentConfigSnapshot(
|
||||
id="inline-version-1",
|
||||
tenant_id="tenant-1",
|
||||
agent_id="inline-agent-1",
|
||||
version=1,
|
||||
config_snapshot='{"prompt":{"system_prompt":"old"}}',
|
||||
)
|
||||
next_snapshot = AgentConfigSnapshot(
|
||||
id="inline-version-2",
|
||||
tenant_id="tenant-1",
|
||||
agent_id="inline-agent-1",
|
||||
version=2,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot)
|
||||
monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot)
|
||||
monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: inline_agent)
|
||||
|
||||
binding = WorkflowAgentNodeBinding(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_version="draft",
|
||||
node_id="node-1",
|
||||
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
|
||||
agent_id="inline-agent-1",
|
||||
current_snapshot_id="inline-version-1",
|
||||
)
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.WORKFLOW.value,
|
||||
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value,
|
||||
"agent_soul": {
|
||||
"model": {
|
||||
"plugin_id": "langgenius/openai/openai",
|
||||
"model_provider": "openai",
|
||||
"model": "gpt-4o",
|
||||
},
|
||||
"prompt": {"system_prompt": "new"},
|
||||
},
|
||||
"node_job": {"workflow_prompt": "use prior output"},
|
||||
}
|
||||
)
|
||||
|
||||
updated_binding = AgentComposerService._save_node_job_only(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
node_id="node-1",
|
||||
account_id="account-1",
|
||||
binding=binding,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
assert updated_binding.current_snapshot_id == "inline-version-2"
|
||||
assert updated_binding.node_job_config_dict["workflow_prompt"] == "use prior output"
|
||||
assert updated_binding.updated_by == "account-1"
|
||||
assert inline_agent.active_config_snapshot_id == "inline-version-2"
|
||||
assert inline_agent.active_config_has_model is True
|
||||
assert inline_agent.updated_by == "account-1"
|
||||
|
||||
|
||||
def test_node_job_only_rejects_inline_binding_pointing_to_roster_agent(monkeypatch):
|
||||
fake_session = FakeSession()
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
current_snapshot = AgentConfigSnapshot(
|
||||
id="inline-version-1",
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
version=1,
|
||||
config_snapshot='{"prompt":{"system_prompt":"old"}}',
|
||||
)
|
||||
next_snapshot = AgentConfigSnapshot(id="inline-version-2", tenant_id="tenant-1", agent_id="agent-1", version=2)
|
||||
roster_agent = SimpleNamespace(id="agent-1", scope=AgentScope.ROSTER)
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot)
|
||||
monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot)
|
||||
monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent)
|
||||
|
||||
binding = WorkflowAgentNodeBinding(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_version="draft",
|
||||
node_id="node-1",
|
||||
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
|
||||
agent_id="agent-1",
|
||||
current_snapshot_id="inline-version-1",
|
||||
)
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.WORKFLOW.value,
|
||||
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value,
|
||||
"agent_soul": {"prompt": {"system_prompt": "new"}},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="workflow-only agent"):
|
||||
AgentComposerService._save_node_job_only(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
node_id="node-1",
|
||||
account_id="account-1",
|
||||
binding=binding,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
|
||||
def test_composer_create_agents_syncs_active_config_has_model(monkeypatch):
|
||||
fake_session = FakeSession()
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
@ -2175,6 +2294,117 @@ def test_save_workflow_composer_guards_drive_refs_for_existing_agent_strategies(
|
||||
assert guarded["agent_id"] == "agent-1"
|
||||
|
||||
|
||||
def test_save_workflow_composer_guards_drive_refs_for_inline_node_job_only(monkeypatch):
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "workflow",
|
||||
"save_strategy": "node_job_only",
|
||||
"agent_soul": _drive_soul().model_dump(mode="json"),
|
||||
"soul_lock": {"locked": False},
|
||||
}
|
||||
)
|
||||
binding = WorkflowAgentNodeBinding(
|
||||
tenant_id="t-1",
|
||||
app_id="app-1",
|
||||
workflow_id="wf-1",
|
||||
workflow_version="draft",
|
||||
node_id="n-1",
|
||||
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
|
||||
agent_id="agent-1",
|
||||
current_snapshot_id="version-1",
|
||||
)
|
||||
monkeypatch.setattr(composer_service.db, "session", FakeSession())
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding))
|
||||
monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding))
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_get_agent_if_present",
|
||||
classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_get_version_if_present",
|
||||
classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []})
|
||||
)
|
||||
guarded: dict[str, str] = {}
|
||||
|
||||
def fake_guard(cls, *, tenant_id, agent_id, agent_soul):
|
||||
guarded["tenant_id"] = tenant_id
|
||||
guarded["agent_id"] = agent_id
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fake_guard))
|
||||
|
||||
result = AgentComposerService.save_workflow_composer(
|
||||
tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload
|
||||
)
|
||||
|
||||
assert result == {"state": "ok", "validation": {"warnings": []}}
|
||||
assert guarded == {"tenant_id": "t-1", "agent_id": "agent-1"}
|
||||
|
||||
|
||||
def test_save_workflow_composer_skips_drive_refs_for_roster_node_job_only(monkeypatch):
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "workflow",
|
||||
"save_strategy": "node_job_only",
|
||||
"agent_soul": _drive_soul().model_dump(mode="json"),
|
||||
"soul_lock": {"locked": False},
|
||||
}
|
||||
)
|
||||
binding = WorkflowAgentNodeBinding(
|
||||
tenant_id="t-1",
|
||||
app_id="app-1",
|
||||
workflow_id="wf-1",
|
||||
workflow_version="draft",
|
||||
node_id="n-1",
|
||||
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
|
||||
agent_id="agent-1",
|
||||
current_snapshot_id="version-1",
|
||||
)
|
||||
monkeypatch.setattr(composer_service.db, "session", FakeSession())
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1"))
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding))
|
||||
monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding))
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_get_agent_if_present",
|
||||
classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_get_version_if_present",
|
||||
classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"})
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService, "collect_validation_findings", classmethod(lambda cls, **kwargs: {"warnings": []})
|
||||
)
|
||||
|
||||
def fail_guard(cls, *, tenant_id, agent_id, agent_soul):
|
||||
raise AssertionError("roster node-job-only saves must not validate agent drive refs")
|
||||
|
||||
monkeypatch.setattr(AgentComposerService, "_require_drive_refs_resolved", classmethod(fail_guard))
|
||||
|
||||
result = AgentComposerService.save_workflow_composer(
|
||||
tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload
|
||||
)
|
||||
|
||||
assert result == {"state": "ok", "validation": {"warnings": []}}
|
||||
|
||||
|
||||
def test_remove_drive_refs_noop_when_skill_slug_unmatched(monkeypatch):
|
||||
soul_dict = {"skills_files": {"skills": [{"name": "Other", "skill_md_key": "other/SKILL.md"}], "files": []}}
|
||||
_, captured, committed = _patch_remove_drive_refs_env(monkeypatch, soul_dict=soul_dict)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user