feat(subagents): attribute subagent spans to parent thread's Langfuse session (#3611)

The subagent execution path did not call inject_langfuse_metadata(...)
and built its model with attach_tracing=True, so subagent LLM/tool
spans landed in Langfuse as isolated top-level traces carrying fresh
session ids and the default user. They were findable in the unfiltered
trace list but did not group under the parent thread's session card,
and Langfuse cost attribution for subagent traffic did not line up
with the parent conversation — even though DeerFlow's internal token
accounting (SubagentTokenCollector) was already correct.

Extend the lead-agent tracing wiring to the subagent path so a single
subagent run produces one trace that shares the parent thread's
session_id and user_id, with a subagent:<name> trace name:

- subagents/executor.py: append build_tracing_callbacks() output to
  run_config["callbacks"] (preserving SubagentTokenCollector) and
  call inject_langfuse_metadata(...) with thread_id, user_id, and
  the normalized subagent:<name> trace name. Build the model with
  attach_tracing=False so model-level tracing does not double-count
  with the graph-root callbacks — the same pairing the lead agent
  uses.
- tools/builtins/task_tool.py: resolve user_id via
  resolve_runtime_user_id(runtime) at the parent tool layer (before
  the background thread starts) and thread it through
  SubagentExecutor.__init__, because the _current_user contextvar
  is not guaranteed to survive the _execution_pool boundary.

Trace topology is unchanged: subagent traces remain separate top-level
traces in the same session, not nested as child spans under the lead
trace (Plan B follow-up).

Tests: tests/test_subagent_executor.py::TestSubagentTracingWiring
covers the callback append, the session/user/trace-name injection,
the disabled-langfuse no-op, the DEFAULT_USER_ID fallback, the
empty-name trace-name fallback, and the env-tag emission. Existing
test_create_agent_threads_explicit_app_config_to_model_and_middlewares
now also asserts attach_tracing=False.

Docs: CLAUDE.md Tracing System section documents subagents/executor.py
as a third injection point alongside worker.py and client.py.
This commit is contained in:
heart-scalpel
2026-06-17 14:36:09 +08:00
committed by GitHub
parent 6b2716e75b
commit a72af8ea37
4 changed files with 335 additions and 5 deletions
@@ -3,6 +3,7 @@
import asyncio
import atexit
import logging
import os
import threading
import uuid
from collections.abc import Callable, Coroutine
@@ -27,6 +28,7 @@ from deerflow.skills.tool_policy import filter_tools_by_skill_allowed_tools
from deerflow.skills.types import Skill
from deerflow.subagents.config import SubagentConfig, resolve_subagent_model_name
from deerflow.subagents.token_collector import SubagentTokenCollector
from deerflow.tracing import build_tracing_callbacks, inject_langfuse_metadata
if TYPE_CHECKING:
# Imported lazily at runtime inside _build_initial_state: importing
@@ -286,6 +288,7 @@ class SubagentExecutor:
thread_data: ThreadDataState | None = None,
thread_id: str | None = None,
trace_id: str | None = None,
user_id: str | None = None,
):
"""Initialize the executor.
@@ -300,6 +303,8 @@ class SubagentExecutor:
thread_data: Thread data from parent agent.
thread_id: Thread ID for sandbox operations.
trace_id: Trace ID from parent for distributed tracing.
user_id: User ID captured from the parent tool's runtime context.
When None, the tracing layer falls back to DEFAULT_USER_ID.
"""
self.config = config
self.app_config = app_config
@@ -316,6 +321,7 @@ class SubagentExecutor:
self.thread_id = thread_id
# Generate trace_id if not provided (for top-level calls)
self.trace_id = trace_id or str(uuid.uuid4())[:8]
self.user_id = user_id
self._base_tools = _filter_tools(
tools,
@@ -336,7 +342,7 @@ class SubagentExecutor:
app_config = self.app_config or get_app_config()
if self.model_name is None:
self.model_name = resolve_subagent_model_name(self.config, self.parent_model, app_config=app_config)
model = create_chat_model(name=self.model_name, thinking_enabled=False, app_config=app_config)
model = create_chat_model(name=self.model_name, thinking_enabled=False, app_config=app_config, attach_tracing=False)
from deerflow.agents.middlewares.tool_error_handling_middleware import build_subagent_runtime_middlewares
@@ -522,6 +528,36 @@ class SubagentExecutor:
"callbacks": [collector],
"tags": [collector_caller],
}
# Inject tracing callbacks at the graph level so a single subagent run
# produces one trace with all node / LLM / tool calls as child spans.
# This mirrors the lead agent pattern: graph-level tracing paired with
# attach_tracing=False on the model avoids double-counted traces.
tracing_callbacks = build_tracing_callbacks()
if tracing_callbacks:
existing_callbacks = list(run_config.get("callbacks") or [])
run_config["callbacks"] = [*existing_callbacks, *tracing_callbacks]
# Normalize subagent name for tracing so it matches the lead-agent
# naming shape (lowercase, hyphens only). Inline because there is no
# shared helper — runtime/runs/naming.py only handles lead-agent runs.
if self.config.name:
normalized_name = self.config.name.strip().lower().replace("_", "-")
assistant_id = f"subagent:{normalized_name}"
else:
assistant_id = "subagent"
# Inject Langfuse trace-attribute metadata so the subagent trace
# links to the parent thread and carries the correct session/user IDs.
inject_langfuse_metadata(
run_config,
thread_id=self.thread_id,
user_id=self.user_id,
assistant_id=assistant_id,
model_name=self.model_name,
environment=os.environ.get("DEER_FLOW_ENV") or os.environ.get("ENVIRONMENT"),
)
context: dict[str, Any] = {}
if self.thread_id:
run_config["configurable"] = {"thread_id": self.thread_id}
@@ -11,6 +11,7 @@ from langchain_core.callbacks import BaseCallbackManager
from langgraph.config import get_stream_writer
from deerflow.config import get_app_config
from deerflow.runtime.user_context import resolve_runtime_user_id
from deerflow.sandbox.security import LOCAL_BASH_SUBAGENT_DISABLED_MESSAGE, is_host_bash_allowed
from deerflow.subagents import SubagentExecutor, get_available_subagent_names, get_subagent_config
from deerflow.subagents.config import resolve_subagent_model_name
@@ -253,6 +254,7 @@ async def task_tool(
thread_id = None
parent_model = None
trace_id = None
user_id = None
metadata: dict = {}
if runtime is not None:
@@ -269,6 +271,9 @@ async def task_tool(
# Get or generate trace_id for distributed tracing
trace_id = metadata.get("trace_id") or str(uuid.uuid4())[:8]
# Get user_id for tracing (uses standard resolution order)
user_id = resolve_runtime_user_id(runtime)
parent_available_skills = metadata.get("available_skills")
if parent_available_skills is not None:
overrides["skills"] = _merge_skill_allowlists(list(parent_available_skills), config.skills)
@@ -306,6 +311,7 @@ async def task_tool(
"thread_data": thread_data,
"thread_id": thread_id,
"trace_id": trace_id,
"user_id": user_id,
}
if resolved_app_config is not None:
executor_kwargs["app_config"] = resolved_app_config