From 923f516debc9989cecc688eaa47c32a32b30b86f Mon Sep 17 00:00:00 2001 From: Airene Fang Date: Thu, 21 May 2026 14:48:28 +0800 Subject: [PATCH] feat(trace):LangGraph -> lead_agent and set custom agent_name to run_name (#3101) * feat(trace):LangGraph -> lead_agent and set user custom agent name to run_name * feat(trace):follow github copilot suggest * feat(trace):Refactor run_name resolution and improve test coverage --- backend/app/gateway/services.py | 2 + .../harness/deerflow/runtime/runs/naming.py | 16 +++ .../harness/deerflow/runtime/runs/worker.py | 4 + backend/tests/test_gateway_services.py | 4 + backend/tests/test_run_naming.py | 34 ++++++ backend/tests/test_run_worker_rollback.py | 102 ++++++++++++++++++ 6 files changed, 162 insertions(+) create mode 100644 backend/packages/harness/deerflow/runtime/runs/naming.py create mode 100644 backend/tests/test_run_naming.py diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index 96521b86f..4713d303e 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -32,6 +32,7 @@ from deerflow.runtime import ( UnsupportedStrategyError, run_agent, ) +from deerflow.runtime.runs.naming import resolve_root_run_name logger = logging.getLogger(__name__) @@ -235,6 +236,7 @@ def build_run_config( target = config.setdefault("configurable", {}) if target is not None and "agent_name" not in target: target["agent_name"] = normalized + config.setdefault("run_name", resolve_root_run_name(config, normalized)) if metadata: config.setdefault("metadata", {}).update(metadata) return config diff --git a/backend/packages/harness/deerflow/runtime/runs/naming.py b/backend/packages/harness/deerflow/runtime/runs/naming.py new file mode 100644 index 000000000..57c67f17c --- /dev/null +++ b/backend/packages/harness/deerflow/runtime/runs/naming.py @@ -0,0 +1,16 @@ +"""Run naming helpers for LangChain/LangSmith tracing.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + + +def resolve_root_run_name(config: Mapping[str, Any], assistant_id: str | None) -> str: + for container_name in ("context", "configurable"): + container = config.get(container_name) + if isinstance(container, Mapping): + agent_name = container.get("agent_name") + if isinstance(agent_name, str) and agent_name.strip(): + return agent_name + return assistant_id or "lead_agent" diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py index f78d425a2..09e3c66e9 100644 --- a/backend/packages/harness/deerflow/runtime/runs/worker.py +++ b/backend/packages/harness/deerflow/runtime/runs/worker.py @@ -33,6 +33,7 @@ from deerflow.runtime.serialization import serialize from deerflow.runtime.stream_bridge import StreamBridge from .manager import RunManager, RunRecord +from .naming import resolve_root_run_name from .schemas import RunStatus logger = logging.getLogger(__name__) @@ -224,6 +225,9 @@ async def run_agent( if journal is not None: config.setdefault("callbacks", []).append(journal) + # Resolve after runtime context installation so context/configurable reflect + # the agent name that this run will actually execute. + config.setdefault("run_name", resolve_root_run_name(config, record.assistant_id)) runnable_config = RunnableConfig(**config) if ctx.app_config is not None and _agent_factory_supports_app_config(agent_factory): agent = agent_factory(config=runnable_config, app_config=ctx.app_config) diff --git a/backend/tests/test_gateway_services.py b/backend/tests/test_gateway_services.py index b024405b5..aa9e20e78 100644 --- a/backend/tests/test_gateway_services.py +++ b/backend/tests/test_gateway_services.py @@ -114,6 +114,7 @@ def test_build_run_config_custom_agent_injects_agent_name(): config = build_run_config("thread-1", None, None, assistant_id="finalis") assert config["configurable"]["agent_name"] == "finalis" + assert config["run_name"] == "finalis" def test_build_run_config_lead_agent_no_agent_name(): @@ -122,6 +123,7 @@ def test_build_run_config_lead_agent_no_agent_name(): config = build_run_config("thread-1", None, None, assistant_id="lead_agent") assert "agent_name" not in config["configurable"] + assert "run_name" not in config def test_build_run_config_none_assistant_id_no_agent_name(): @@ -130,6 +132,7 @@ def test_build_run_config_none_assistant_id_no_agent_name(): config = build_run_config("thread-1", None, None, assistant_id=None) assert "agent_name" not in config["configurable"] + assert "run_name" not in config def test_build_run_config_explicit_agent_name_not_overwritten(): @@ -143,6 +146,7 @@ def test_build_run_config_explicit_agent_name_not_overwritten(): assistant_id="other-agent", ) assert config["configurable"]["agent_name"] == "explicit-agent" + assert config["run_name"] == "explicit-agent" def test_build_run_config_context_custom_agent_injects_agent_name(): diff --git a/backend/tests/test_run_naming.py b/backend/tests/test_run_naming.py new file mode 100644 index 000000000..4afb6fad7 --- /dev/null +++ b/backend/tests/test_run_naming.py @@ -0,0 +1,34 @@ +from deerflow.runtime.runs.naming import resolve_root_run_name + + +def test_resolve_root_run_name_from_context_agent_name(): + assert resolve_root_run_name({"context": {"agent_name": "finalis"}}, "lead_agent") == "finalis" + + +def test_resolve_root_run_name_from_configurable_agent_name(): + assert resolve_root_run_name({"configurable": {"agent_name": "finalis"}}, "lead_agent") == "finalis" + + +def test_resolve_root_run_name_falls_back_to_assistant_id(): + assert resolve_root_run_name({}, "my-agent") == "my-agent" + + +def test_resolve_root_run_name_falls_back_to_lead_agent(): + assert resolve_root_run_name({}, None) == "lead_agent" + + +def test_resolve_root_run_name_prefers_context_over_configurable(): + config = { + "context": {"agent_name": "ctx-agent"}, + "configurable": {"agent_name": "cfg-agent"}, + } + + assert resolve_root_run_name(config, "lead_agent") == "ctx-agent" + + +def test_resolve_root_run_name_ignores_blank_agent_name(): + assert resolve_root_run_name({"context": {"agent_name": " "}}, "my-agent") == "my-agent" + + +def test_resolve_root_run_name_ignores_non_string_agent_name(): + assert resolve_root_run_name({"context": {"agent_name": None}}, "my-agent") == "my-agent" diff --git a/backend/tests/test_run_worker_rollback.py b/backend/tests/test_run_worker_rollback.py index 72e3ac98e..5a8ec71f7 100644 --- a/backend/tests/test_run_worker_rollback.py +++ b/backend/tests/test_run_worker_rollback.py @@ -95,6 +95,108 @@ async def test_run_agent_threads_explicit_app_config_into_config_only_factory(): bridge.cleanup.assert_awaited_once_with(record.run_id, delay=60) +@pytest.mark.anyio +async def test_run_agent_defaults_root_run_name_from_assistant_id(): + run_manager = RunManager() + record = await run_manager.create("thread-1", assistant_id="lead_agent") + bridge = SimpleNamespace( + publish=AsyncMock(), + publish_end=AsyncMock(), + cleanup=AsyncMock(), + ) + captured: dict[str, object] = {} + + class DummyAgent: + async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False): + captured["astream_run_name"] = config["run_name"] + yield {"messages": []} + + def factory(*, config): + captured["factory_run_name"] = config["run_name"] + return DummyAgent() + + await run_agent( + bridge, + run_manager, + record, + ctx=RunContext(checkpointer=None), + agent_factory=factory, + graph_input={}, + config={}, + ) + + assert captured["factory_run_name"] == "lead_agent" + assert captured["astream_run_name"] == "lead_agent" + + +@pytest.mark.anyio +async def test_run_agent_defaults_root_run_name_from_context_agent_name(): + run_manager = RunManager() + record = await run_manager.create("thread-1", assistant_id="lead_agent") + bridge = SimpleNamespace( + publish=AsyncMock(), + publish_end=AsyncMock(), + cleanup=AsyncMock(), + ) + captured: dict[str, object] = {} + + class DummyAgent: + async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False): + captured["astream_run_name"] = config["run_name"] + yield {"messages": []} + + def factory(*, config): + captured["factory_run_name"] = config["run_name"] + return DummyAgent() + + await run_agent( + bridge, + run_manager, + record, + ctx=RunContext(checkpointer=None), + agent_factory=factory, + graph_input={}, + config={"context": {"agent_name": "finalis"}}, + ) + + assert captured["factory_run_name"] == "finalis" + assert captured["astream_run_name"] == "finalis" + + +@pytest.mark.anyio +async def test_run_agent_defaults_root_run_name_from_configurable_agent_name(): + run_manager = RunManager() + record = await run_manager.create("thread-1", assistant_id="lead_agent") + bridge = SimpleNamespace( + publish=AsyncMock(), + publish_end=AsyncMock(), + cleanup=AsyncMock(), + ) + captured: dict[str, object] = {} + + class DummyAgent: + async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False): + captured["astream_run_name"] = config["run_name"] + yield {"messages": []} + + def factory(*, config): + captured["factory_run_name"] = config["run_name"] + return DummyAgent() + + await run_agent( + bridge, + run_manager, + record, + ctx=RunContext(checkpointer=None), + agent_factory=factory, + graph_input={}, + config={"configurable": {"agent_name": "finalis"}}, + ) + + assert captured["factory_run_name"] == "finalis" + assert captured["astream_run_name"] == "finalis" + + @pytest.mark.anyio async def test_rollback_restores_snapshot_without_deleting_thread(): checkpointer = FakeCheckpointer(put_result={"configurable": {"thread_id": "thread-1", "checkpoint_ns": "", "checkpoint_id": "restored-1"}})