mirror of
https://github.com/langgenius/dify.git
synced 2026-08-28 18:24:32 +08:00
fix(agent): keep debugger session across soul saves (#37620)
This commit is contained in:
parent
2604c33e54
commit
2f72b576f0
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user