fix(agent): keep debugger session across soul saves (#37620)

This commit is contained in:
zyssyz123 2026-06-18 16:24:20 +08:00 committed by GitHub
parent 2604c33e54
commit 2f72b576f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 138 additions and 10 deletions

View File

@ -72,8 +72,12 @@ class AgentAppGenerator(MessageBasedAppGenerator):
query = query.replace("\x00", "")
inputs = args["inputs"]
# Resolve the bound roster Agent + its published Agent Soul snapshot.
# Resolve the bound roster Agent + its current Agent Soul snapshot.
agent, snapshot, agent_soul = self._resolve_agent(app_model)
runtime_session_snapshot_id = self._runtime_session_snapshot_id(
invoke_from=invoke_from,
snapshot_id=snapshot.id,
)
conversation = None
conversation_id = args.get("conversation_id")
@ -120,6 +124,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
trace_manager=trace_manager,
agent_id=agent.id,
agent_config_snapshot_id=snapshot.id,
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
)
conversation, message = self._init_generate_records(application_generate_entity, conversation)
@ -341,6 +346,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
message_id=message.id,
model_name=application_generate_entity.model_conf.model,
queue_manager=queue_manager,
session_scope_snapshot_id=application_generate_entity.agent_runtime_session_snapshot_id,
)
except GenerateTaskStoppedError:
pass
@ -430,6 +436,21 @@ class AgentAppGenerator(MessageBasedAppGenerator):
tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id
)
@staticmethod
def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None:
"""Return the session scope snapshot id for Agent App runtime state.
Console preview/debug chat is an editing workspace: saving Agent Soul
creates replacement snapshots, but the user expects the same preview
conversation to keep context while trying prompt changes. Use a stable
NULL snapshot scope for debugger runs so each turn can use the latest
Agent Soul while reusing the conversation history. Published/web/API
runs keep snapshot-scoped sessions for reproducible runtime state.
"""
if invoke_from == InvokeFrom.DEBUGGER:
return None
return snapshot_id
@staticmethod
def _resolve_agent_by_id(
*, tenant_id: str, agent_id: str, snapshot_id: str | None

View File

@ -52,6 +52,13 @@ from models.agent_config_entities import AgentSoulConfig
logger = logging.getLogger(__name__)
class _DefaultSessionScopeSnapshotId:
pass
_DEFAULT_SESSION_SCOPE_SNAPSHOT_ID = _DefaultSessionScopeSnapshotId()
def _prompt_messages_from_query(user_query: str | None) -> list[PromptMessage]:
if not user_query:
return []
@ -155,13 +162,18 @@ class AgentAppRunner:
message_id: str,
model_name: str,
queue_manager: AppQueueManager,
session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId = _DEFAULT_SESSION_SCOPE_SNAPSHOT_ID,
) -> None:
if isinstance(session_scope_snapshot_id, _DefaultSessionScopeSnapshotId):
effective_session_scope_snapshot_id: str | None = agent_config_snapshot_id
else:
effective_session_scope_snapshot_id = session_scope_snapshot_id
scope = AgentAppSessionScope(
tenant_id=dify_context.tenant_id,
app_id=dify_context.app_id,
conversation_id=conversation_id,
agent_id=agent_id,
agent_config_snapshot_id=agent_config_snapshot_id,
agent_config_snapshot_id=effective_session_scope_snapshot_id,
)
# ENG-638: if a prior turn paused on ask_human and the form is now answered,
# resume by threading the human's reply into this run as deferred_tool_results.

View File

@ -45,7 +45,7 @@ class AgentAppSessionScope:
app_id: str
conversation_id: str
agent_id: str
agent_config_snapshot_id: str
agent_config_snapshot_id: str | None
@dataclass(frozen=True, slots=True)
@ -194,13 +194,15 @@ class AgentAppRuntimeSessionStore:
@staticmethod
def _scope_stmt(scope: AgentAppSessionScope):
return select(AgentRuntimeSession).where(
stmt = select(AgentRuntimeSession).where(
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
AgentRuntimeSession.tenant_id == scope.tenant_id,
AgentRuntimeSession.conversation_id == scope.conversation_id,
AgentRuntimeSession.agent_id == scope.agent_id,
AgentRuntimeSession.agent_config_snapshot_id == scope.agent_config_snapshot_id,
)
if scope.agent_config_snapshot_id is None:
return stmt.where(AgentRuntimeSession.agent_config_snapshot_id.is_(None))
return stmt.where(AgentRuntimeSession.agent_config_snapshot_id == scope.agent_config_snapshot_id)
@classmethod
def _active_stmt(cls, scope: AgentAppSessionScope):

View File

@ -224,6 +224,7 @@ class AgentAppGenerateEntity(ChatAppGenerateEntity):
agent_id: str
agent_config_snapshot_id: str
agent_runtime_session_snapshot_id: str | None = None
class AdvancedChatAppGenerateEntity(ConversationAppGenerateEntity):

View File

@ -324,8 +324,10 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base):
``workflow_id / workflow_run_id / node_id / binding_id /
agent_config_snapshot_id / composition_layer_specs`` columns are set.
- Agent App conversations: ``owner_type = conversation``; the
``conversation_id`` and ``agent_config_snapshot_id`` columns are set and
the workflow columns stay NULL.
``conversation_id`` column is set and the workflow columns stay NULL.
Published/web/API runs scope runtime state by ``agent_config_snapshot_id``;
console debugger runs may keep it NULL so prompt-only draft saves can reuse
the same preview conversation state while executing the latest Agent Soul.
The snapshot is runtime state returned by Agent backend, kept separate from
Agent Soul snapshots and workflow node-job config.

View File

@ -66,6 +66,16 @@ class TestGenerateGuards:
class TestGenerateSuccess:
def test_runtime_session_snapshot_id_is_stable_for_debugger_only(self):
assert (
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.DEBUGGER, snapshot_id="snap-1")
is None
)
assert (
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.WEB_APP, snapshot_id="snap-1")
== "snap-1"
)
def test_generate_orchestrates_and_starts_worker(self, generator, mocker: MockerFixture):
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")
user = DummyAccount("user")
@ -201,12 +211,25 @@ class TestGenerateWorker:
mocker.patch(f"{MODULE}.AgentAppRunner", return_value=runner)
return runner
def _call(self, generator, mocker: MockerFixture, queue_manager, *, is_resume=False, query="query"):
def _call(
self,
generator,
mocker: MockerFixture,
queue_manager,
*,
is_resume=False,
query="query",
runtime_session_snapshot_id="s",
):
generator._generate_worker(
flask_app=mocker.MagicMock(),
context=mocker.MagicMock(),
application_generate_entity=mocker.MagicMock(
agent_id="a", agent_config_snapshot_id="s", model_conf=mocker.MagicMock(model="m"), query=query
agent_id="a",
agent_config_snapshot_id="s",
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
model_conf=mocker.MagicMock(model="m"),
query=query,
),
queue_manager=queue_manager,
conversation_id="conv",
@ -222,6 +245,15 @@ class TestGenerateWorker:
runner.run.assert_called_once()
queue_manager.publish_error.assert_not_called()
def test_worker_passes_runtime_session_scope_to_runner(self, generator, mocker: MockerFixture):
runner = self._wire(generator, mocker)
queue_manager = mocker.MagicMock()
self._call(generator, mocker, queue_manager, runtime_session_snapshot_id=None)
assert runner.run.call_args.kwargs["agent_config_snapshot_id"] == "s"
assert runner.run.call_args.kwargs["session_scope_snapshot_id"] is None
def test_input_guard_short_circuit_skips_backend(self, generator, mocker: MockerFixture):
runner = self._wire(generator, mocker, handled=True)
queue_manager = mocker.MagicMock()

View File

@ -139,6 +139,7 @@ class _FakeSessionStore:
) -> None:
self.loaded = loaded
self._loaded_session = loaded_session
self.loaded_scopes: list[AgentAppSessionScope] = []
self.saved: list[
tuple[
AgentAppSessionScope,
@ -151,9 +152,11 @@ class _FakeSessionStore:
] = []
def load_active_snapshot(self, scope: AgentAppSessionScope) -> CompositorSessionSnapshot | None:
self.loaded_scopes.append(scope)
return self.loaded
def load_active_session(self, scope: AgentAppSessionScope) -> StoredAgentAppSession | None:
self.loaded_scopes.append(scope)
if self._loaded_session is not None:
return self._loaded_session
if self.loaded is None:
@ -313,6 +316,31 @@ def test_prior_session_snapshot_is_threaded_into_request():
assert client.request.session_snapshot is prior
def test_debug_session_scope_can_reuse_conversation_across_config_snapshots():
prior = CompositorSessionSnapshot(layers=[])
client = FakeAgentBackendRunClient()
store = _FakeSessionStore(loaded=prior)
qm = _FakeQueueManager()
_runner(client, store).run(
dify_context=_dify_ctx(),
agent_id="agent-1",
agent_config_snapshot_id="snap-new",
agent_soul=_soul(),
conversation_id="conv-1",
query="hello",
message_id="msg-1",
model_name="gpt-4o-mini",
queue_manager=qm, # type: ignore[arg-type]
session_scope_snapshot_id=None,
)
assert client.request is not None
assert client.request.session_snapshot is prior
assert store.loaded_scopes[0].agent_config_snapshot_id is None
assert store.saved[0][0].agent_config_snapshot_id is None
def test_failed_run_raises_agent_backend_error():
client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED)
store = _FakeSessionStore()

View File

@ -22,7 +22,7 @@ from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, Agen
def _scope(
conversation_id: str = "conv-1", agent_id: str = "agent-1", agent_config_snapshot_id: str = "snap-1"
conversation_id: str = "conv-1", agent_id: str = "agent-1", agent_config_snapshot_id: str | None = "snap-1"
) -> AgentAppSessionScope:
return AgentAppSessionScope(
tenant_id="tenant-1",
@ -125,6 +125,36 @@ def test_second_turn_updates_same_conversation_row():
assert rows[0].backend_run_id == "run-2"
def test_debug_scope_with_null_snapshot_id_updates_same_conversation_row():
store = AgentAppRuntimeSessionStore()
scope = _scope(agent_config_snapshot_id=None)
store.save_active_snapshot(
scope=scope,
backend_run_id="run-1",
snapshot=_snapshot(messages=1),
runtime_layer_specs=_runtime_layer_specs(),
)
store.save_active_snapshot(
scope=scope,
backend_run_id="run-2",
snapshot=_snapshot(messages=3),
runtime_layer_specs=_runtime_layer_specs(),
)
loaded = store.load_active_snapshot(scope)
assert loaded is not None
assert loaded.layers[0].runtime_state["messages"] == [
{"role": "user", "content": "m0"},
{"role": "user", "content": "m1"},
{"role": "user", "content": "m2"},
]
with session_factory.create_session() as session:
row = session.query(AgentRuntimeSession).one()
assert row.agent_config_snapshot_id is None
assert row.backend_run_id == "run-2"
def test_mark_cleaned_then_load_returns_none_and_save_resurrects():
store = AgentAppRuntimeSessionStore()
store.save_active_snapshot(