From ab43966baf6f5233771b747d8b772f786d5f23ab Mon Sep 17 00:00:00 2001 From: m3m0ry <2813013359@qq.com> Date: Thu, 5 Nov 2026 23:34:56 +0800 Subject: [PATCH] fix(tracing): mark deferred tool responses as pending on tool spans A long-running tool, or ADK's task-delegation tool (`_defers_response`), can finish its call without a response: ADK synthesizes it later. The tool span then recorded `` for gen_ai.output, gen_ai.tool.output and cozeloop.output, indistinguishable from a missing output. Record `{"status": "pending"}` for these tools instead; other tools without a response keep ``. Co-Authored-By: Claude Opus 5.5 --- tests/test_tracing_content.py | 35 +++++++++++++++++++ .../extractors/tool_attributes_extractors.py | 18 +++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/tests/test_tracing_content.py b/tests/test_tracing_content.py index f42cb91a6..9e2397106 100644 --- a/tests/test_tracing_content.py +++ b/tests/test_tracing_content.py @@ -15,6 +15,8 @@ import json from dataclasses import dataclass +import pytest + from opentelemetry import context as context_api from opentelemetry.sdk import trace as trace_sdk @@ -309,6 +311,39 @@ def test_trace_tool_call_skips_content_when_env_false(monkeypatch): assert "gen_ai.output" not in span.attributes +class _DeferredResponseTool(_FakeTool): + # Mirrors ADK's task-delegation tool, whose response the framework + # synthesizes after the sub-agent finishes. + _defers_response = True + + +class _LongRunningTool(_FakeTool): + is_long_running = True + + +@pytest.mark.parametrize("tool", [_DeferredResponseTool(), _LongRunningTool()]) +def test_trace_tool_call_marks_a_deferred_response_as_pending(monkeypatch, tool): + monkeypatch.setenv("OBSERVABILITY_OPENTELEMETRY_TRACE_CONTENT", "true") + + with _start_test_span("execute_tool lookup") as span: + telemetry.trace_tool_call(tool, {"request": "check"}, None) + + for name in ("gen_ai.tool.output", "cozeloop.output", "gen_ai.output"): + assert json.loads(span.attributes[name]) == {"status": "pending"} + + +def test_trace_tool_call_keeps_unknown_output_when_a_response_is_missing( + monkeypatch, +): + monkeypatch.setenv("OBSERVABILITY_OPENTELEMETRY_TRACE_CONTENT", "true") + + with _start_test_span("execute_tool lookup") as span: + telemetry.trace_tool_call(_FakeTool(), {"query": "q"}, None) + + for name in ("gen_ai.tool.output", "cozeloop.output", "gen_ai.output"): + assert span.attributes[name] == "" + + def test_apmplus_tool_metrics_skip_token_usage_when_tool_content_missing(): metric_recorder = object.__new__(PortalMetricRecorder) metric_recorder.apmplus_span_latency = _FakeMetricRecorder() diff --git a/veadk/tracing/telemetry/attributes/extractors/tool_attributes_extractors.py b/veadk/tracing/telemetry/attributes/extractors/tool_attributes_extractors.py index 2f6319ad4..072ac6eb8 100644 --- a/veadk/tracing/telemetry/attributes/extractors/tool_attributes_extractors.py +++ b/veadk/tracing/telemetry/attributes/extractors/tool_attributes_extractors.py @@ -115,6 +115,14 @@ def tool_gen_ai_tool_name(params: ToolAttributesParams) -> ExtractorResponse: return ExtractorResponse(content=params.tool.name or "") +def _responds_later(tool) -> bool: + """Whether a tool's response can arrive after its call returns.""" + return bool( + getattr(tool, "is_long_running", False) + or getattr(tool, "_defers_response", False) + ) + + def tool_gen_ai_tool_output(params: ToolAttributesParams) -> ExtractorResponse: """Extract tool output data for span attributes. @@ -125,10 +133,18 @@ def tool_gen_ai_tool_output(params: ToolAttributesParams) -> ExtractorResponse: params: Tool execution parameters containing function response event Returns: - ExtractorResponse: Response containing JSON serialized tool output data + ExtractorResponse: Response containing JSON serialized tool output data, + ``{"status": "pending"}`` when a long-running or deferred-response + tool has not responded yet """ function_responses = get_event_function_responses(params.function_response_event) if not function_responses: + if _responds_later(params.tool): + # The call was accepted and its response arrives later (a + # long-running tool, or ADK's task delegation, whose response is + # synthesized once the sub-agent finishes): record that state + # instead of an output that looks missing. + return ExtractorResponse(content=safe_json_serialize({"status": "pending"})) return ExtractorResponse(content="") function_response_obj = function_responses[0] if hasattr(function_response_obj, "model_dump"):