Repository navigation
Test/uv 16309 span flush race + fix #1901
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
611fe7d
1bdff0c
37cb0f1
ea96b4d
7552722
54eeb5e
06cf7e5
d2fb21c
8dde043
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -79,8 +79,25 @@ async def evaluate( | |||||||||
| return await super().evaluate(workload_execution, evaluation_criteria) | ||||||||||
|
|
||||||||||
| def _get_actual_output(self, workload_execution: WorkloadExecution) -> Any: | ||||||||||
| """Get the actual output from the workload execution.""" | ||||||||||
| return trace_to_str(workload_execution.workload_trace) | ||||||||||
| """Get the actual output from the workload execution. | ||||||||||
|
|
||||||||||
| `trace_to_str` only ever renders tool-call spans, so a run where the | ||||||||||
| agent responded with plain text and made no tool calls at all | ||||||||||
| produces an empty string here - not because nothing happened, but | ||||||||||
| because there was nothing for `trace_to_str` to render. Append the | ||||||||||
| agent's actual final output so the judge always sees what the agent | ||||||||||
| answered, not just which tools it used (UV-16309). | ||||||||||
| """ | ||||||||||
| history = trace_to_str(workload_execution.workload_trace) | ||||||||||
| final_output = workload_execution.workload_output | ||||||||||
| if final_output: | ||||||||||
| final_output_section = f"Agent Final Response:\n{final_output}" | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For conversational agents Suggest a small renderer in # packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py
AGENT_RESPONSE_MESSAGES_KEY = "uipath__agent_response_messages"
def render_agent_output(output: dict[str, Any] | str) -> str:
"""Render the workload output for an LLM-judge prompt.
Conversational agents return ``{"uipath__agent_response_messages": [...]}``
where the text sits under ``contentParts[].data.inline``. Surface that text
(and any tool calls) instead of the raw dict repr; anything else is emitted
as indented JSON.
"""
if isinstance(output, str):
return output
messages = output.get(AGENT_RESPONSE_MESSAGES_KEY)
if not isinstance(messages, list):
return json.dumps(output, indent=2, default=str)
lines: list[str] = []
for message in messages:
if not isinstance(message, dict):
continue
role = message.get("role", "assistant")
parts = message.get("contentParts") or message.get("content_parts") or []
for part in parts:
data = part.get("data") if isinstance(part, dict) else None
text = data.get("inline") if isinstance(data, dict) else None
if text:
lines.append(f"[{role}] {text}")
calls = message.get("toolCalls") or message.get("tool_calls") or []
for call in calls:
if isinstance(call, dict):
args = json.dumps(call.get("input"), default=str)
lines.append(f"[{role}] Tool call {call.get('name')}: {args}")
rest = {k: v for k, v in output.items() if k != AGENT_RESPONSE_MESSAGES_KEY}
if rest:
lines.append(json.dumps(rest, indent=2, default=str))
return "\n".join(lines) or json.dumps(output, indent=2, default=str)Then at this call site:
Suggested change
(plus |
||||||||||
| history = ( | ||||||||||
| f"{history}\n\n{final_output_section}" | ||||||||||
| if history | ||||||||||
| else final_output_section | ||||||||||
| ) | ||||||||||
| return history | ||||||||||
|
|
||||||||||
| def _get_expected_output( | ||||||||||
| self, evaluation_criteria: TrajectoryEvaluationCriteria | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -970,6 +970,17 @@ async def _generate_input_for_eval( | |||||||||||||||||||||||
| def _get_and_clear_execution_data( | ||||||||||||||||||||||||
| self, execution_id: str | ||||||||||||||||||||||||
| ) -> tuple[list[ReadableSpan], list[logging.LogRecord]]: | ||||||||||||||||||||||||
| # Force a flush right before reading: the flush triggered by the root | ||||||||||||||||||||||||
| # execution span ending only exports spans whose on_end() had already | ||||||||||||||||||||||||
| # fired by that moment. A tool-call span that finishes a beat later | ||||||||||||||||||||||||
| # (e.g. a short-lived background task) would otherwise still be | ||||||||||||||||||||||||
| # sitting in the batch processor's queue and be silently missing from | ||||||||||||||||||||||||
| # AgentRunHistory (UV-16309). This is a snapshot barrier, not a full | ||||||||||||||||||||||||
| # closure: a span belonging to a task that is still running (hasn't | ||||||||||||||||||||||||
| # called on_end() yet) when this flush runs is still missed - closing | ||||||||||||||||||||||||
| # that would require the runtime to track and await such tasks before | ||||||||||||||||||||||||
| # this point, which is out of scope for this fix. | ||||||||||||||||||||||||
| self.trace_manager.flush_spans() | ||||||||||||||||||||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed — added
Comment on lines
+973
to
+983
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This flush cannot catch anything the read would otherwise miss, because production already flushes twice between the last span ending and this read:
The eval runtime then calls The Jira data points the same way: none of the 34 cases in UV-16309 is "tool calls happened, history empty". The two tool-call cases had 7.4k-char histories that were missing the agent's text, which is the I'd drop this block and
Suggested change
|
||||||||||||||||||||||||
| spans = self.span_exporter.get_spans(execution_id) | ||||||||||||||||||||||||
| self.span_exporter.clear(execution_id) | ||||||||||||||||||||||||
| self.span_collector.clear(execution_id) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,240 @@ | ||
| """Reproduces the UV-16309 intermittent-empty-AgentRunHistory race. | ||
|
|
||
| `ExecutionSpanProcessor` (eval/runtime/_exporters.py) is a real OTel | ||
| `BatchSpanProcessor`: `on_end()` only queues a span, it does not export it. | ||
| `ExecutionSpanExporter.get_spans()` (read by `trace_to_str()` to build | ||
| `AgentRunHistory`) only sees spans that have already reached `export()`. | ||
|
|
||
| Production flushes the queue via `UiPathTraceManager.flush_spans()` (called | ||
| from `uipath-runtime`'s `UiPathExecutionRuntime.execute()` finally block, and | ||
| from `start_execution_span`'s finally block) right as the root execution span | ||
| ends. That flush only catches spans whose `on_end()` already fired by that | ||
| moment. A tool-call span that legitimately ends *after* the root span (e.g. a | ||
| background/detached task finishing a beat late) is queued but not yet | ||
| exported, so a read of `get_spans()` taken between "root span ended" and | ||
| "next flush" sees a real, completed tool call as if it never happened - | ||
| exactly the "history genuinely exists but AgentRunHistory is empty, | ||
| intermittently" symptom. | ||
|
|
||
| The fix in `_get_and_clear_execution_data()` adds one more flush right before | ||
| the read, which closes the race for any span that has *already ended* by | ||
| read time - the common case, e.g. a tool call wrapped in a short-lived | ||
| background task. It does NOT close the race for a span belonging to a truly | ||
| detached task that is still running (has no `on_end()` yet) when the read | ||
| happens - there is no handle for the runtime to await in that case, and | ||
| closing it would require tracking/awaiting such tasks, which is a separate, | ||
| larger change than this one-line flush. | ||
| """ | ||
|
|
||
| import json | ||
|
|
||
| from opentelemetry import trace | ||
| from opentelemetry.sdk.trace import TracerProvider | ||
|
|
||
| from uipath.core.tracing import UiPathTraceManager | ||
| from uipath.eval._execution_context import ExecutionSpanCollector | ||
| from uipath.eval._helpers.evaluators_helpers import trace_to_str | ||
| from uipath.eval.runtime._exporters import ( | ||
| ExecutionLogsExporter, | ||
| ExecutionSpanExporter, | ||
| ExecutionSpanProcessor, | ||
| ) | ||
| from uipath.eval.runtime.runtime import UiPathEvalRuntime | ||
|
|
||
| EXECUTION_ID = "exec-race-1" | ||
|
|
||
|
|
||
| def _pin_batch_schedule(processor: ExecutionSpanProcessor) -> None: | ||
| """Stop the processor's own background worker from auto-exporting. | ||
|
|
||
| `ExecutionSpanProcessor` is a real `BatchSpanProcessor` with its own | ||
| background worker thread, which auto-flushes every 5s by default (or | ||
| when its queue fills). Tests asserting "not yet exported at this exact | ||
| moment" would otherwise flake if that worker thread woke up and exported | ||
| on its own between a span ending and the assertion running. Pin the | ||
| schedule delay far beyond any test's runtime so only an explicit | ||
| `force_flush()`/`flush_spans()` call ever exports anything. | ||
| """ | ||
| batch_processor = processor._batch_processor | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| batch_processor._schedule_delay_millis = 3600_000 | ||
| batch_processor._schedule_delay = 3600.0 | ||
|
|
||
|
|
||
| def _make_processor() -> tuple[ | ||
| ExecutionSpanProcessor, ExecutionSpanExporter, trace.Tracer | ||
| ]: | ||
| exporter = ExecutionSpanExporter() | ||
| collector = ExecutionSpanCollector() | ||
| processor = ExecutionSpanProcessor(exporter, collector) | ||
| _pin_batch_schedule(processor) | ||
|
|
||
| provider = TracerProvider() | ||
| provider.add_span_processor(processor) | ||
| tracer = provider.get_tracer("test") | ||
|
|
||
| return processor, exporter, tracer | ||
|
|
||
|
|
||
| def test_flush_only_exports_spans_ended_before_it_runs() -> None: | ||
| """A late-ending tool span is invisible to get_spans() until the *next* flush.""" | ||
| processor, exporter, tracer = _make_processor() | ||
|
|
||
| # Root execution span starts and ends (mirrors start_execution_span's `with` block). | ||
| with tracer.start_as_current_span( | ||
| "root", attributes={"execution.id": EXECUTION_ID} | ||
| ): | ||
| pass | ||
|
|
||
| # Mirrors flush_spans() firing right as the root span's context manager exits. | ||
| processor.force_flush() | ||
|
|
||
| assert [s.name for s in exporter.get_spans(EXECUTION_ID)] == ["root"] | ||
|
|
||
| # A tool-call span that genuinely happened, but whose on_end() only fires | ||
| # *after* the flush above - e.g. a detached/background task that outlives | ||
| # the awaited delegate.execute() call. | ||
| with tracer.start_as_current_span( | ||
| "tool_call", attributes={"execution.id": EXECUTION_ID, "tool.name": "search"} | ||
| ): | ||
| pass | ||
|
|
||
| # This is the moment _get_and_clear_execution_data() reads spans in | ||
| # eval/runtime/runtime.py: no flush has happened since the tool span ended. | ||
| spans_at_read_time = exporter.get_spans(EXECUTION_ID) | ||
|
|
||
| assert [s.name for s in spans_at_read_time] == ["root"], ( | ||
|
Copilot marked this conversation as resolved.
|
||
| "the tool-call span genuinely ended but is not yet exported - " | ||
| "trace_to_str() would build an AgentRunHistory missing this real tool call" | ||
| ) | ||
|
|
||
| # A subsequent flush (e.g. one added right before the read) makes it visible. | ||
| processor.force_flush() | ||
| assert {s.name for s in exporter.get_spans(EXECUTION_ID)} == {"root", "tool_call"} | ||
|
|
||
|
|
||
| def test_flush_immediately_before_read_closes_the_race() -> None: | ||
| """Proposed fix: force_flush() right before get_spans() sees every ended span.""" | ||
| processor, exporter, tracer = _make_processor() | ||
|
|
||
| with tracer.start_as_current_span( | ||
| "root", attributes={"execution.id": EXECUTION_ID} | ||
| ): | ||
| with tracer.start_as_current_span( | ||
| "tool_call", | ||
| attributes={"execution.id": EXECUTION_ID, "tool.name": "search"}, | ||
| ): | ||
| pass | ||
|
|
||
| # A flush right before the read (unlike production today) picks up | ||
| # everything that has ended by then, including the tool call. | ||
| processor.force_flush() | ||
|
|
||
| spans_at_read_time = exporter.get_spans(EXECUTION_ID) | ||
| assert {s.name for s in spans_at_read_time} == {"root", "tool_call"} | ||
|
|
||
|
|
||
| def test_race_produces_empty_agent_run_history_for_a_real_tool_call() -> None: | ||
| """Ties the exporter-level race to the actual UV-16309 symptom. | ||
|
|
||
| trace_to_str() (used to build AgentRunHistory for the trajectory/LLM-judge | ||
| evaluators) is handed exactly what get_spans() returns. If the read happens | ||
| in the window between the root-span flush and the late tool span's own | ||
| flush, the genuinely-completed tool call is silently dropped from the | ||
| evaluator prompt - not because it didn't happen, but because it wasn't | ||
| exported yet. | ||
| """ | ||
| processor, exporter, tracer = _make_processor() | ||
|
|
||
| with tracer.start_as_current_span( | ||
| "root", attributes={"execution.id": EXECUTION_ID} | ||
| ): | ||
| pass | ||
| processor.force_flush() | ||
|
|
||
| with tracer.start_as_current_span( | ||
| "tool_call", | ||
| attributes={ | ||
| "execution.id": EXECUTION_ID, | ||
| "tool.name": "search", | ||
| # input.value/output.value are OTel span attributes: they must be | ||
| # primitive/sequence-of-primitive values, so real spans always | ||
| # carry a JSON-encoded string here, never a raw dict. | ||
| "input.value": json.dumps({"query": "uipath"}), | ||
| "output.value": "42 results", | ||
| }, | ||
| ): | ||
| pass | ||
|
|
||
| # No flush here - mirrors _get_and_clear_execution_data() reading | ||
| # immediately after the delegate returns, with nothing forcing the | ||
| # late-ending tool span's export first. | ||
| agent_run_history = trace_to_str(exporter.get_spans(EXECUTION_ID)) | ||
| assert agent_run_history == "", ( | ||
| "expected the unflushed tool call to be missing from AgentRunHistory, " | ||
| "reproducing UV-16309's 'history genuinely exists but comes back empty' case" | ||
| ) | ||
|
|
||
| # With the extra flush (the proposed fix) in place, the same real tool | ||
| # call is captured correctly. | ||
| processor.force_flush() | ||
| agent_run_history_after_flush = trace_to_str(exporter.get_spans(EXECUTION_ID)) | ||
| assert "Tool: search" in agent_run_history_after_flush | ||
| assert "42 results" in agent_run_history_after_flush | ||
|
|
||
|
|
||
| def test_get_and_clear_execution_data_flushes_before_reading() -> None: | ||
| """Exercises the actual production method, not just a standalone processor. | ||
|
|
||
| Builds the same trace_manager/span_exporter/span_collector/logs_exporter | ||
| wiring UiPathEvalRuntime.__init__ sets up, then calls the real | ||
| `_get_and_clear_execution_data` (unbound, via the class) against it. This | ||
| fails without the `flush_spans()` line in that method - removing that | ||
| line reproduces the empty-AgentRunHistory bug here directly, not just in | ||
| the lower-level exporter tests above. | ||
| """ | ||
| # UiPathTraceManager() registers a default batch processor on the global | ||
| # delegating processor and doesn't unregister it on its own - shut it | ||
| # down in `finally` so its processors (and their background worker | ||
| # threads) don't leak into other tests/jobs. | ||
| trace_manager = UiPathTraceManager() | ||
| try: | ||
| span_exporter = ExecutionSpanExporter() | ||
| span_collector = ExecutionSpanCollector() | ||
| span_processor = ExecutionSpanProcessor(span_exporter, span_collector) | ||
| _pin_batch_schedule(span_processor) | ||
| trace_manager.add_span_processor(span_processor) | ||
| logs_exporter = ExecutionLogsExporter() | ||
|
|
||
| fake_runtime = type( | ||
| "FakeEvalRuntime", | ||
| (), | ||
| { | ||
| "trace_manager": trace_manager, | ||
| "span_exporter": span_exporter, | ||
| "span_collector": span_collector, | ||
| "logs_exporter": logs_exporter, | ||
| }, | ||
| )() | ||
|
|
||
| tracer = trace_manager.tracer_provider.get_tracer("test") | ||
| with tracer.start_as_current_span( | ||
| "root", attributes={"execution.id": EXECUTION_ID} | ||
| ): | ||
| pass | ||
| # Root span's own flush (mirrors start_execution_span's finally block) | ||
| # happens before the late tool call below - only the tool call is at risk. | ||
| trace_manager.flush_spans() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test reproduces the missing span only because it omits the flush that production performs after the tool span. In |
||
|
|
||
| with tracer.start_as_current_span( | ||
| "tool_call", | ||
| attributes={"execution.id": EXECUTION_ID, "tool.name": "search"}, | ||
| ): | ||
| pass | ||
|
|
||
| spans, _logs = UiPathEvalRuntime._get_and_clear_execution_data( | ||
| fake_runtime, EXECUTION_ID | ||
| ) | ||
|
|
||
| assert {s.name for s in spans} == {"root", "tool_call"} | ||
| finally: | ||
| trace_manager.shutdown() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same rendering issue as in
llm_judge_trajectory_evaluator.py(see that comment for the proposedrender_agent_outputhelper). Using the same helper here keeps the two trajectory evaluators consistent, which they currently are not: this one resolvestarget_output_keyand job-attachment URIs via_get_actual_output, the non-legacy one appends the raw field.