fix(api): allow inline workflow agent soul saves (#37563)

This commit is contained in:
zyssyz123 2026-06-17 14:02:03 +08:00 committed by GitHub
parent f203ab7f1d
commit 3b0f6aef8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 279 additions and 2 deletions

View File

@ -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

View File

@ -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:

View File

@ -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(
{

View File

@ -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)