Skip to content

Commit 45865e9

Browse files
fix(tracing): attach Langfuse trace metadata to the goal evaluator (#4202)
The goal evaluator (runtime/goal.py) runs from runtime/runs/worker.py after the main graph run has already completed, so there is no graph root for it to inherit tracing from. create_goal_evaluator_model was built with attach_tracing=False, and evaluate_goal_completion invoked the model with a bare config={"run_name": "goal_evaluator"} — no tracing callbacks, no Langfuse session/user attribution. Every goal-evaluator LLM call went untraced. Same class of gap fixed by #2944 for the main agent graph and by #3902 for memory_agent/suggest_agent: a standalone call site that invokes a model directly instead of through a traced graph root must attach its own tracing callbacks and inject Langfuse trace-attribute metadata itself. - create_goal_evaluator_model: attach_tracing=False -> True, matching the other standalone non-graph callers (oneshot_llm.run_oneshot_llm, MemoryUpdater). - evaluate_goal_completion: accept optional thread_id/user_id/ deerflow_trace_id and inject Langfuse trace metadata onto the ainvoke config via the shared inject_langfuse_metadata() helper, mirroring oneshot_llm.py's pattern. - worker.py: thread user_id (resolve_runtime_user_id(runtime)) and deerflow_trace_id through _prepare_goal_continuation_input into evaluate_goal_completion so the evaluator's trace groups under the triggering run's thread/session. Updates the existing test that pinned attach_tracing=False as expected behavior, and adds a regression test asserting the ainvoke config carries Langfuse trace metadata when enabled.
1 parent 6ef0aa2 commit 45865e9

4 files changed

Lines changed: 114 additions & 6 deletions

File tree

backend/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ metadata only.
347347
- `POST /wait` (both thread-scoped and `/api/runs/wait`) drains the stream bridge via `wait_for_run_completion()` instead of bare `await record.task`, so it honours the run's `on_disconnect` setting and cancels the background run on real client disconnect rather than returning a stale checkpoint (issue #3265).
348348
- Redis `StreamBridge` keys use a rolling retained-buffer TTL (`stream_bridge.stream_ttl_seconds`, refreshed on `publish()` / `publish_end()`) as a leak safety net, not as a run timeout. Startup orphan recovery publishes `END_SENTINEL` and schedules stream cleanup for recovered runs; malformed `Last-Event-ID` reconnect values live-tail new Redis events rather than replaying the retained buffer. Do not broaden this into a shared-database multi-pod reaper without adding worker ownership/liveness first.
349349
- Thread-scoped run creation accepts `checkpoint` / `checkpoint_id`; Gateway validates the checkpoint belongs to the request thread before writing `checkpoint_id` / `checkpoint_ns` into `config.configurable` for LangGraph branching.
350-
- Thread-scoped Gateway runs evaluate an active `ThreadState.goal` after the visible turn completes. `runtime/goal.py` asks a non-thinking evaluator model to judge only visible conversation evidence and return a typed blocker; the evaluator model is created once per run and reused across hidden continuation checks. Satisfied goals are cleared; every non-satisfied evaluation — continuable or stand-down — is persisted with `last_evaluation` (the blocker, reason, and evidence summary; outcomes that stop the loop additionally record a `stand_down_reason` for observability), but only `goal_not_met_yet` evaluations are streamed as hidden `HumanMessage` continuations, and only when a durable assistant end-of-turn checkpoint exists, the run has not been aborted, the thread did not change during evaluation, and the no-progress breaker has not fired. The continuation cap is 8 — a hard maximum in the `0`–`8` range; callers requesting more are clamped (`set_goal`/TUI) or rejected with 422 (`PUT /goal`). The no-progress breaker keys on the latest visible assistant evidence (not the evaluator's free-text reason, which an LLM rewords every turn), so two consecutive continuations that add no new visible assistant output stop the loop after 2 attempts. Model-response cleanup helpers such as think-block stripping and code-fence stripping live in `deerflow.utils.llm_text` so `runtime/goal.py` and Gateway suggestion parsing share the same JSON-prep behavior.
350+
- Thread-scoped Gateway runs evaluate an active `ThreadState.goal` after the visible turn completes. `runtime/goal.py` asks a non-thinking evaluator model to judge only visible conversation evidence and return a typed blocker; the evaluator model is created once per run and reused across hidden continuation checks. The evaluator runs after the graph root's tracing scope has already closed, so `create_goal_evaluator_model`/`evaluate_goal_completion` attach their own model-level tracing callbacks (`attach_tracing=True`) and inject Langfuse trace metadata (`thread_id`/`user_id`/`deerflow_trace_id`) directly onto the `ainvoke` call — the same standalone-caller pattern as `oneshot_llm.run_oneshot_llm` and `MemoryUpdater` (see Tracing System below). Satisfied goals are cleared; every non-satisfied evaluation — continuable or stand-down — is persisted with `last_evaluation` (the blocker, reason, and evidence summary; outcomes that stop the loop additionally record a `stand_down_reason` for observability), but only `goal_not_met_yet` evaluations are streamed as hidden `HumanMessage` continuations, and only when a durable assistant end-of-turn checkpoint exists, the run has not been aborted, the thread did not change during evaluation, and the no-progress breaker has not fired. The continuation cap is 8 — a hard maximum in the `0`–`8` range; callers requesting more are clamped (`set_goal`/TUI) or rejected with 422 (`PUT /goal`). The no-progress breaker keys on the latest visible assistant evidence (not the evaluator's free-text reason, which an LLM rewords every turn), so two consecutive continuations that add no new visible assistant output stop the loop after 2 attempts. Model-response cleanup helpers such as think-block stripping and code-fence stripping live in `deerflow.utils.llm_text` so `runtime/goal.py` and Gateway suggestion parsing share the same JSON-prep behavior.
351351

352352
Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runtime, all other `/api/*` → Gateway REST APIs.
353353

backend/packages/harness/deerflow/runtime/goal.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import inspect
1414
import json
1515
import logging
16+
import os
1617
import threading
1718
import weakref
1819
from collections.abc import AsyncIterator
@@ -25,6 +26,7 @@
2526
import deerflow.utils.llm_text as llm_text
2627
from deerflow.agents.goal_state import GoalBlocker, GoalEvaluation, GoalState
2728
from deerflow.models import create_chat_model
29+
from deerflow.tracing import inject_langfuse_metadata
2830
from deerflow.utils.messages import message_to_text
2931
from deerflow.utils.time import now_iso
3032

@@ -242,24 +244,49 @@ def create_goal_evaluator_model(
242244
model_name: str | None = None,
243245
app_config: Any | None = None,
244246
) -> Any:
245-
"""Create the non-thinking chat model used by the goal evaluator."""
247+
"""Create the non-thinking chat model used by the goal evaluator.
248+
249+
The evaluator runs from ``runtime/runs/worker.py`` after the main graph
250+
run has already completed, so — unlike ``make_lead_agent``/
251+
``DeerFlowClient.stream``, which attach ``build_tracing_callbacks()`` at
252+
the graph root and correctly pass ``attach_tracing=False`` to avoid
253+
double-attaching — there is no graph root here for the evaluator's model
254+
call to inherit tracing from. It must attach its own model-level tracing
255+
callbacks, same as the other standalone, non-graph callers
256+
(``oneshot_llm.run_oneshot_llm``, ``MemoryUpdater``).
257+
"""
246258
return create_chat_model(
247259
name=model_name,
248260
thinking_enabled=False,
249261
app_config=app_config,
250-
attach_tracing=False,
262+
attach_tracing=True,
251263
)
252264

253265

266+
def _resolve_environment() -> str | None:
267+
return os.environ.get("DEER_FLOW_ENV") or os.environ.get("ENVIRONMENT")
268+
269+
254270
async def evaluate_goal_completion(
255271
goal: GoalState,
256272
messages: list[Any],
257273
*,
258274
model: Any | None = None,
259275
model_name: str | None = None,
260276
app_config: Any | None = None,
277+
thread_id: str | None = None,
278+
user_id: str | None = None,
279+
deerflow_trace_id: str | None = None,
261280
) -> GoalEvaluation:
262-
"""Ask a small non-thinking model whether the active goal is satisfied."""
281+
"""Ask a small non-thinking model whether the active goal is satisfied.
282+
283+
``thread_id``/``user_id``/``deerflow_trace_id`` are forwarded to Langfuse
284+
trace metadata only (mirrors ``oneshot_llm.run_oneshot_llm``): this is a
285+
standalone model call outside the main graph, so it must inject its own
286+
Langfuse session/user attribution instead of relying on graph-root
287+
callbacks to lift it — same fix as PR #2944 (main graph) and PR #3902
288+
(memory_agent/suggest_agent).
289+
"""
263290
conversation = format_visible_conversation(messages)
264291
if not conversation or not has_visible_assistant_evidence(messages):
265292
return GoalEvaluation(
@@ -283,9 +310,19 @@ async def evaluate_goal_completion(
283310

284311
if model is None:
285312
model = create_goal_evaluator_model(model_name=model_name, app_config=app_config)
313+
invoke_config: dict[str, Any] = {"run_name": "goal_evaluator"}
314+
inject_langfuse_metadata(
315+
invoke_config,
316+
thread_id=thread_id,
317+
user_id=user_id,
318+
assistant_id="goal_evaluator",
319+
model_name=model_name,
320+
environment=_resolve_environment(),
321+
deerflow_trace_id=deerflow_trace_id,
322+
)
286323
response = await model.ainvoke(
287324
[SystemMessage(content=system_instruction), HumanMessage(content=user_content)],
288-
config={"run_name": "goal_evaluator"},
325+
config=invoke_config,
289326
)
290327
return parse_goal_evaluation_response(_extract_response_text(response.content))
291328

backend/packages/harness/deerflow/runtime/runs/worker.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,8 @@ async def _stream_once(input_payload: Any, stream_config: RunnableConfig) -> Non
528528
app_config=ctx.app_config,
529529
evaluator_model_factory=_get_goal_evaluator_model,
530530
abort_event=record.abort_event,
531+
user_id=resolve_runtime_user_id(runtime),
532+
deerflow_trace_id=deerflow_trace_id,
531533
)
532534
if continuation_input is None or record.abort_event.is_set():
533535
break
@@ -842,6 +844,8 @@ async def _prepare_goal_continuation_input(
842844
app_config: AppConfig | None,
843845
evaluator_model_factory: Any | None = None,
844846
abort_event: asyncio.Event | None = None,
847+
user_id: str | None = None,
848+
deerflow_trace_id: str | None = None,
845849
) -> dict[str, Any] | None:
846850
"""Evaluate the active goal and return a hidden continuation input if needed.
847851
@@ -919,6 +923,9 @@ async def _persist(
919923
model=evaluator_model,
920924
model_name=model_name,
921925
app_config=app_config,
926+
thread_id=thread_id,
927+
user_id=user_id,
928+
deerflow_trace_id=deerflow_trace_id,
922929
)
923930
if abort_event is not None and abort_event.is_set():
924931
return None

backend/tests/test_goal_runtime.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,75 @@ def fake_create_chat_model(**kwargs):
116116
assert result["satisfied"] is True
117117
assert result["blocker"] == "none"
118118
assert captured["thinking_enabled"] is False
119-
assert captured["attach_tracing"] is False
119+
# The goal evaluator runs from runtime/runs/worker.py after the main graph
120+
# run has already finished, so there is no graph root for it to inherit
121+
# tracing callbacks from (unlike make_lead_agent/DeerFlowClient.stream,
122+
# which attach build_tracing_callbacks() at the graph root and correctly
123+
# pass attach_tracing=False to avoid double-attaching). It must attach its
124+
# own model-level tracing callbacks, same as the other standalone,
125+
# non-graph callers (oneshot_llm.run_oneshot_llm, MemoryUpdater).
126+
assert captured["attach_tracing"] is True
120127
fake_model.ainvoke.assert_awaited_once()
128+
# No thread_id/user_id supplied here, and Langfuse is not enabled in the
129+
# ambient test env, so inject_langfuse_metadata() is a no-op and the
130+
# config is unchanged from the plain run_name — see
131+
# test_evaluate_goal_completion_injects_langfuse_metadata below for the
132+
# Langfuse-enabled case.
121133
assert fake_model.ainvoke.await_args.kwargs["config"] == {"run_name": "goal_evaluator"}
122134

123135

136+
def test_evaluate_goal_completion_injects_langfuse_metadata(monkeypatch):
137+
"""Regression test for the goal evaluator's Langfuse tracing gap.
138+
139+
Mirrors PR #2944 (main graph) and PR #3902 (memory_agent/suggest_agent):
140+
a standalone, non-graph model call must inject Langfuse trace-attribute
141+
metadata itself since there is no graph root to lift it from.
142+
"""
143+
from deerflow.config.tracing_config import reset_tracing_config
144+
145+
for name in ("LANGFUSE_TRACING", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_BASE_URL"):
146+
monkeypatch.delenv(name, raising=False)
147+
monkeypatch.setenv("LANGFUSE_TRACING", "true")
148+
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-lf-test")
149+
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-lf-test")
150+
reset_tracing_config()
151+
152+
fake_model = MagicMock()
153+
fake_model.ainvoke = AsyncMock(return_value=SimpleNamespace(content='{"satisfied": true, "reason": "Done", "evidence_summary": "Done"}'))
154+
state = goal.build_goal_state("Finish")
155+
156+
try:
157+
result = asyncio.run(
158+
goal.evaluate_goal_completion(
159+
state,
160+
[
161+
HumanMessage(content="Please finish this."),
162+
AIMessage(content="Done."),
163+
],
164+
model=fake_model,
165+
model_name="gpt-4o",
166+
app_config=object(),
167+
thread_id="thread-xyz",
168+
user_id="alice",
169+
deerflow_trace_id="gateway-trace-1",
170+
)
171+
)
172+
finally:
173+
reset_tracing_config()
174+
175+
assert result["satisfied"] is True
176+
fake_model.ainvoke.assert_awaited_once()
177+
config = fake_model.ainvoke.await_args.kwargs["config"]
178+
assert config["run_name"] == "goal_evaluator"
179+
metadata = config.get("metadata") or {}
180+
assert metadata.get("langfuse_session_id") == "thread-xyz", "goal evaluator trace must group under the thread's session"
181+
assert metadata.get("langfuse_user_id") == "alice"
182+
assert metadata.get("langfuse_trace_name") == "goal_evaluator"
183+
assert metadata.get("deerflow_trace_id") == "gateway-trace-1"
184+
tags = metadata.get("langfuse_tags") or []
185+
assert "model:gpt-4o" in tags
186+
187+
124188
def test_evaluate_goal_completion_uses_injected_model(monkeypatch):
125189
fake_model = MagicMock()
126190
fake_model.ainvoke = AsyncMock(return_value=SimpleNamespace(content='{"satisfied": true, "reason": "Done", "evidence_summary": "Done"}'))

0 commit comments

Comments
 (0)