Skip to content

Commit 6d826e7

Browse files
PopescuTudorclaude
authored andcommitted
feat(a2a): emit a single A2A client span and suppress a2a-sdk noise (#932)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 288789c commit 6d826e7

5 files changed

Lines changed: 133 additions & 10 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.13.6"
3+
version = "0.13.7"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath_langchain/agent/tools/a2a/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
"""A2A (Agent-to-Agent) tools."""
22

3-
from .a2a_tool import (
3+
import os
4+
5+
# The a2a-sdk auto-instruments its JSON-RPC transport with OpenTelemetry spans
6+
# (``a2a.client.transports.jsonrpc.JsonRpcTransport.*``). Those internal spans
7+
# surface as noisy, unparented nodes in the Execution Trace and are not a
8+
# meaningful representation of the call. Disable that instrumentation before the
9+
# SDK is imported (it reads this variable at import time), so the single A2A
10+
# span emitted by ``a2a_tool`` is the only node for the call. ``setdefault``
11+
# preserves an explicit opt-in when the variable is already set.
12+
os.environ.setdefault("OTEL_INSTRUMENTATION_A2A_SDK_ENABLED", "false")
13+
14+
from .a2a_tool import ( # noqa: E402
415
A2aClient,
516
create_a2a_tools_and_clients,
617
open_a2a_tools,

src/uipath_langchain/agent/tools/a2a/a2a_tool.py

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@
3030
from langchain_core.messages import ToolCall, ToolMessage
3131
from langchain_core.tools import BaseTool
3232
from langgraph.types import Command
33+
from opentelemetry import trace as otel_trace
3334
from pydantic import BaseModel, Field
3435
from uipath._utils._ssl_context import get_httpx_client_kwargs
3536
from uipath.agent.models.agent import AgentA2aResourceConfig
37+
from uipath.core.tracing.span_utils import UiPathSpanUtils
3638

3739
from uipath_langchain._utils import get_execution_folder_path
3840
from uipath_langchain.agent.react.types import AgentGraphState
@@ -251,10 +253,50 @@ def _create_a2a_tool(
251253
"slug": config.slug,
252254
}
253255

256+
async def _invoke(
257+
*, message: str, task_id: str | None, context_id: str | None
258+
) -> tuple[str, str, str | None, str | None]:
259+
"""Send one message to the remote agent inside an A2A trace span.
260+
261+
The span is parented under the active tool-call span (via
262+
``UiPathSpanUtils.get_parent_context``) so the remote call nests under
263+
the tool in the Execution Trace, and is marked
264+
``uipath.custom_instrumentation`` so the LLMOps exporter keeps it. The
265+
a2a-sdk's own transport spans are disabled in this package's __init__,
266+
so this is the single node representing the call.
267+
"""
268+
parent_ctx = UiPathSpanUtils.get_parent_context()
269+
tracer = otel_trace.get_tracer(__name__)
270+
with tracer.start_as_current_span(raw_name, context=parent_ctx) as span:
271+
# "openinference.span.kind" drives the SpanType shown in the UI;
272+
# "toolCall" is the recognized type for a tool invocation.
273+
span.set_attribute("openinference.span.kind", "toolCall")
274+
span.set_attribute("type", "toolCall")
275+
span.set_attribute("span_type", "toolCall")
276+
span.set_attribute("uipath.custom_instrumentation", True)
277+
span.set_attribute("tool_type", "a2a")
278+
span.set_attribute("input", message)
279+
span.set_attribute("input.value", message)
280+
281+
client = await a2a_client.get()
282+
text, response_state, new_task_id, new_context_id = await _send_a2a_message(
283+
client,
284+
agent_label,
285+
message=message,
286+
task_id=task_id,
287+
context_id=context_id,
288+
)
289+
290+
span.set_attribute("output", text)
291+
span.set_attribute("output.value", text)
292+
span.set_attribute("task_state", response_state)
293+
if response_state == "error":
294+
span.set_status(otel_trace.StatusCode.ERROR, text)
295+
return text, response_state, new_task_id, new_context_id
296+
254297
async def _send(*, message: str) -> str:
255-
client = await a2a_client.get()
256-
text, state, _, _ = await _send_a2a_message(
257-
client, agent_label, message=message, task_id=None, context_id=None
298+
text, state, _, _ = await _invoke(
299+
message=message, task_id=None, context_id=None
258300
)
259301
return _format_response(text, state)
260302

@@ -267,10 +309,7 @@ async def _a2a_wrapper(
267309
task_id = prior.get("task_id")
268310
context_id = prior.get("context_id")
269311

270-
client = await a2a_client.get()
271-
text, task_state, new_task_id, new_context_id = await _send_a2a_message(
272-
client,
273-
agent_label,
312+
text, task_state, new_task_id, new_context_id = await _invoke(
274313
message=call["args"]["message"],
275314
task_id=task_id,
276315
context_id=context_id,

tests/agent/tools/test_a2a_tool.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,20 @@
55
MCP servers are resolved — not from a field baked into the resource config.
66
"""
77

8+
import os
89
from types import SimpleNamespace
910
from typing import Any, cast
1011

1112
import pytest
1213
from a2a.client import Client
1314
from a2a.types import AgentCard, Message, Part, Role, TextPart
15+
from opentelemetry import trace as otel_trace
16+
from opentelemetry.sdk.trace import TracerProvider
17+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
18+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
19+
InMemorySpanExporter,
20+
)
21+
from opentelemetry.trace import StatusCode
1422
from uipath.agent.models.agent import AgentA2aResourceConfig
1523

1624
import uipath_langchain.agent.tools.a2a.a2a_tool as a2a_tool
@@ -308,3 +316,68 @@ async def _get():
308316
stored = command.update["inner_state"]["tools_storage"][tool.name]
309317
assert stored["context_id"] == "ctx-9"
310318
assert "pong" in command.update["messages"][0].content
319+
320+
321+
def test_a2a_sdk_telemetry_suppressed_by_default() -> None:
322+
"""Importing the a2a package disables the a2a-sdk's own OTel transport spans.
323+
324+
The package __init__ runs (via the module imports above) before the a2a-sdk
325+
is imported and sets the suppression default.
326+
"""
327+
assert os.environ.get("OTEL_INSTRUMENTATION_A2A_SDK_ENABLED") == "false"
328+
329+
330+
async def test_tool_invocation_emits_custom_a2a_span(
331+
monkeypatch: pytest.MonkeyPatch,
332+
) -> None:
333+
"""Invoking the tool emits one custom-instrumentation A2A span carrying the
334+
toolCall kind and the message/response."""
335+
provider = TracerProvider()
336+
exporter = InMemorySpanExporter()
337+
provider.add_span_processor(SimpleSpanProcessor(exporter))
338+
monkeypatch.setattr(otel_trace, "get_tracer", provider.get_tracer)
339+
340+
resource = _make_resource(cached_agent_card=_cached_card())
341+
tools, clients = create_a2a_tools_and_clients([resource])
342+
fake = _FakeA2aClient([_agent_message("pong", context_id="ctx-1")])
343+
344+
async def _get():
345+
return fake
346+
347+
monkeypatch.setattr(clients[0], "get", _get)
348+
349+
result = await tools[0].ainvoke({"message": "ping"})
350+
assert "pong" in result
351+
352+
a2a_spans = [s for s in exporter.get_finished_spans() if s.name == "Remote Agent"]
353+
assert len(a2a_spans) == 1
354+
attrs = a2a_spans[0].attributes or {}
355+
assert attrs["uipath.custom_instrumentation"] is True
356+
assert attrs["openinference.span.kind"] == "toolCall"
357+
assert attrs["tool_type"] == "a2a"
358+
assert attrs["input.value"] == "ping"
359+
assert attrs["output.value"] == "pong"
360+
361+
362+
async def test_tool_invocation_marks_span_error(
363+
monkeypatch: pytest.MonkeyPatch,
364+
) -> None:
365+
"""A failed remote call sets the A2A span status to ERROR."""
366+
provider = TracerProvider()
367+
exporter = InMemorySpanExporter()
368+
provider.add_span_processor(SimpleSpanProcessor(exporter))
369+
monkeypatch.setattr(otel_trace, "get_tracer", provider.get_tracer)
370+
371+
resource = _make_resource(cached_agent_card=_cached_card())
372+
tools, clients = create_a2a_tools_and_clients([resource])
373+
374+
async def _get():
375+
return _RaisingA2aClient()
376+
377+
monkeypatch.setattr(clients[0], "get", _get)
378+
379+
await tools[0].ainvoke({"message": "ping"})
380+
381+
a2a_spans = [s for s in exporter.get_finished_spans() if s.name == "Remote Agent"]
382+
assert len(a2a_spans) == 1
383+
assert a2a_spans[0].status.status_code == StatusCode.ERROR

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)