Skip to content

Commit 133bad3

Browse files
committed
feat: add A2A tool for invoking remote agents at runtime
1 parent 1389d3a commit 133bad3

File tree

6 files changed

+353
-2
lines changed

6 files changed

+353
-2
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.9.23"
3+
version = "0.9.24"
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"
@@ -23,6 +23,7 @@ dependencies = [
2323
"mcp==1.26.0",
2424
"langchain-mcp-adapters==0.2.1",
2525
"pillow>=12.1.1",
26+
"a2a-sdk>=0.2.0",
2627
]
2728

2829
classifiers = [

src/uipath_langchain/agent/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tool creation and management for LowCode agents."""
22

3+
from .a2a import create_a2a_agent_tools
34
from .context_tool import create_context_tool
45
from .datafabric_tool import (
56
fetch_entity_schemas,
@@ -21,6 +22,7 @@
2122
)
2223

2324
__all__ = [
25+
"create_a2a_agent_tools",
2426
"create_tools_from_resources",
2527
"create_tool_node",
2628
"create_context_tool",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""A2A (Agent-to-Agent) tools."""
2+
3+
from .a2a_tool import create_a2a_agent_tools
4+
5+
__all__ = [
6+
"create_a2a_agent_tools",
7+
]
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
"""A2A singleton tool — one tool per remote agent.
2+
3+
Each tool maintains conversation context (task_id/context_id) across calls
4+
using deterministic persistence via LangGraph graph state (tools_storage).
5+
6+
Authentication uses the UiPath SDK Bearer token, resolved lazily on first call.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import asyncio
12+
import json
13+
from logging import getLogger
14+
from uuid import uuid4
15+
16+
import httpx
17+
from a2a.client import Client
18+
from a2a.types import (
19+
AgentCard,
20+
Message,
21+
Part,
22+
Role,
23+
Task,
24+
TaskArtifactUpdateEvent,
25+
TaskState,
26+
TextPart,
27+
)
28+
from langchain_core.messages import ToolCall, ToolMessage
29+
from langchain_core.tools import BaseTool
30+
from langgraph.types import Command
31+
from pydantic import BaseModel, Field
32+
from uipath._utils._ssl_context import get_httpx_client_kwargs
33+
from uipath.agent.models.agent import AgentA2aResourceConfig
34+
35+
from uipath_langchain.agent.react.types import AgentGraphState
36+
from uipath_langchain.agent.tools.base_uipath_structured_tool import (
37+
BaseUiPathStructuredTool,
38+
)
39+
from uipath_langchain.agent.tools.tool_node import (
40+
ToolWrapperMixin,
41+
ToolWrapperReturnType,
42+
)
43+
from uipath_langchain.agent.tools.utils import sanitize_tool_name
44+
45+
logger = getLogger(__name__)
46+
47+
48+
class A2aToolInput(BaseModel):
49+
"""Input schema for A2A agent tool."""
50+
51+
message: str = Field(description="The message to send to the remote agent.")
52+
53+
54+
class A2aStructuredToolWithWrapper(BaseUiPathStructuredTool, ToolWrapperMixin):
55+
pass
56+
57+
58+
def _extract_text(obj: Task | Message) -> str:
59+
"""Extract text content from a Task or Message response."""
60+
parts: list[Part] = []
61+
62+
if isinstance(obj, Message):
63+
parts = obj.parts or []
64+
elif isinstance(obj, Task):
65+
if obj.status and obj.status.state == TaskState.input_required:
66+
if obj.status.message:
67+
parts = obj.status.message.parts or []
68+
else:
69+
if obj.artifacts:
70+
for artifact in obj.artifacts:
71+
parts.extend(artifact.parts or [])
72+
if not parts and obj.status and obj.status.message:
73+
parts = obj.status.message.parts or []
74+
if not parts and obj.history:
75+
for msg in reversed(obj.history):
76+
if msg.role == Role.agent:
77+
parts = msg.parts or []
78+
break
79+
80+
texts = []
81+
for part in parts:
82+
if isinstance(part.root, TextPart):
83+
texts.append(part.root.text)
84+
return "\n".join(texts) if texts else ""
85+
86+
87+
def _format_response(text: str, state: str) -> str:
88+
"""Build a structured tool response the LLM can act on."""
89+
return json.dumps({"agent_response": text, "task_state": state})
90+
91+
92+
def _build_description(card: AgentCard) -> str:
93+
"""Build a tool description from an agent card."""
94+
parts = []
95+
if card.description:
96+
parts.append(card.description)
97+
if card.skills:
98+
for skill in card.skills:
99+
skill_desc = skill.name or ""
100+
if skill.description:
101+
skill_desc += f": {skill.description}"
102+
if skill_desc:
103+
parts.append(f"Skill: {skill_desc}")
104+
return " | ".join(parts) if parts else f"Remote A2A agent at {card.url}"
105+
106+
107+
def _resolve_a2a_url(config: AgentA2aResourceConfig) -> str:
108+
"""Resolve the A2A endpoint URL from config."""
109+
a2a_url = getattr(config, "a2a_url", None)
110+
if a2a_url:
111+
return a2a_url
112+
return config.agent_card_url.replace("/.well-known/agent-card.json", "")
113+
114+
115+
async def create_a2a_agent_tools(
116+
resources: list[AgentA2aResourceConfig],
117+
) -> list[BaseTool]:
118+
"""Create A2A tools from a list of A2A resource configurations.
119+
120+
Each enabled A2A resource becomes a single tool representing the remote agent.
121+
Conversation context (task_id/context_id) is persisted in LangGraph graph state.
122+
123+
Args:
124+
resources: List of A2A resource configurations from agent.json.
125+
126+
Returns:
127+
List of BaseTool instances, one per enabled A2A resource.
128+
"""
129+
tools: list[BaseTool] = []
130+
131+
for resource in resources:
132+
if resource.is_enabled is False:
133+
logger.info("Skipping disabled A2A resource '%s'", resource.name)
134+
continue
135+
if resource.is_active is False:
136+
logger.info("Skipping inactive A2A resource '%s'", resource.name)
137+
continue
138+
139+
logger.info("Creating A2A tool for resource '%s'", resource.name)
140+
tool = _create_a2a_tool(resource)
141+
tools.append(tool)
142+
143+
return tools
144+
145+
146+
async def _send_a2a_message(
147+
client: Client,
148+
a2a_url: str,
149+
*,
150+
message: str,
151+
task_id: str | None,
152+
context_id: str | None,
153+
) -> tuple[str, str, str | None, str | None]:
154+
"""Send a message to a remote A2A agent and return the response.
155+
156+
Returns:
157+
Tuple of (response_text, task_state, new_task_id, new_context_id).
158+
"""
159+
if task_id or context_id:
160+
logger.info(
161+
"A2A continue task=%s context=%s to %s", task_id, context_id, a2a_url
162+
)
163+
else:
164+
logger.info("A2A new message to %s", a2a_url)
165+
166+
a2a_message = Message(
167+
role=Role.user,
168+
parts=[Part(root=TextPart(text=message))],
169+
message_id=str(uuid4()),
170+
task_id=task_id,
171+
context_id=context_id,
172+
)
173+
174+
try:
175+
text = ""
176+
state = "unknown"
177+
new_task_id = task_id
178+
new_context_id = context_id
179+
180+
async for event in client.send_message(a2a_message):
181+
if isinstance(event, Message):
182+
text = _extract_text(event)
183+
new_context_id = event.context_id
184+
state = "completed"
185+
break
186+
else:
187+
task, update = event
188+
new_task_id = task.id
189+
new_context_id = task.context_id
190+
state = task.status.state.value if task.status else "unknown"
191+
if update is None:
192+
text = _extract_text(task)
193+
break
194+
elif isinstance(update, TaskArtifactUpdateEvent):
195+
for part in update.artifact.parts or []:
196+
if isinstance(part.root, TextPart):
197+
text += part.root.text
198+
199+
return (text or "No response received.", state, new_task_id, new_context_id)
200+
201+
except Exception as e:
202+
logger.exception("A2A request to %s failed", a2a_url)
203+
return (f"Error: {e}", "error", task_id, context_id)
204+
205+
206+
def _create_a2a_tool(config: AgentA2aResourceConfig) -> BaseTool:
207+
"""Create a single LangChain tool for A2A communication.
208+
209+
Conversation context (task_id/context_id) is persisted deterministically
210+
in LangGraph's graph state via tools_storage, ensuring reliable
211+
multi-turn conversations with the remote agent.
212+
"""
213+
if config.cached_agent_card:
214+
agent_card = AgentCard(**config.cached_agent_card)
215+
else:
216+
agent_card = AgentCard(
217+
url=config.agent_card_url,
218+
name=config.name,
219+
description=config.description or "",
220+
version="1.0.0",
221+
skills=[],
222+
capabilities={},
223+
default_input_modes=["text/plain"],
224+
default_output_modes=["text/plain"],
225+
)
226+
227+
raw_name = agent_card.name or config.name
228+
tool_name = sanitize_tool_name(raw_name)
229+
tool_description = _build_description(agent_card)
230+
a2a_url = _resolve_a2a_url(config)
231+
232+
_lock = asyncio.Lock()
233+
_client: Client | None = None
234+
_http_client: httpx.AsyncClient | None = None
235+
236+
async def _ensure_client() -> Client:
237+
nonlocal _client, _http_client
238+
if _client is None:
239+
async with _lock:
240+
if _client is None:
241+
from a2a.client import ClientConfig, ClientFactory
242+
from uipath.platform import UiPath
243+
244+
sdk = UiPath()
245+
client_kwargs = get_httpx_client_kwargs(
246+
headers={"Authorization": f"Bearer {sdk._config.secret}"},
247+
)
248+
_http_client = httpx.AsyncClient(**client_kwargs)
249+
_client = await ClientFactory.connect(
250+
a2a_url,
251+
client_config=ClientConfig(
252+
httpx_client=_http_client,
253+
streaming=False,
254+
),
255+
)
256+
return _client
257+
258+
metadata = {
259+
"tool_type": "a2a",
260+
"display_name": raw_name,
261+
"slug": config.slug,
262+
}
263+
264+
async def _send(*, message: str) -> str:
265+
client = await _ensure_client()
266+
text, state, _, _ = await _send_a2a_message(
267+
client, a2a_url, message=message, task_id=None, context_id=None
268+
)
269+
return _format_response(text, state)
270+
271+
async def _a2a_wrapper(
272+
tool: BaseTool,
273+
call: ToolCall,
274+
state: AgentGraphState,
275+
) -> ToolWrapperReturnType:
276+
prior = state.inner_state.tools_storage.get(tool.name) or {}
277+
task_id = prior.get("task_id")
278+
context_id = prior.get("context_id")
279+
280+
client = await _ensure_client()
281+
text, task_state, new_task_id, new_context_id = await _send_a2a_message(
282+
client,
283+
a2a_url,
284+
message=call["args"]["message"],
285+
task_id=task_id,
286+
context_id=context_id,
287+
)
288+
289+
return Command(
290+
update={
291+
"messages": [
292+
ToolMessage(
293+
content=_format_response(text, task_state),
294+
name=call["name"],
295+
tool_call_id=call["id"],
296+
)
297+
],
298+
"inner_state": {
299+
"tools_storage": {
300+
tool.name: {
301+
"task_id": new_task_id,
302+
"context_id": new_context_id,
303+
}
304+
}
305+
},
306+
}
307+
)
308+
309+
tool = A2aStructuredToolWithWrapper(
310+
name=tool_name,
311+
description=tool_description,
312+
coroutine=_send,
313+
args_schema=A2aToolInput,
314+
metadata=metadata,
315+
)
316+
tool.set_tool_wrappers(awrapper=_a2a_wrapper)
317+
return tool

src/uipath_langchain/agent/tools/tool_factory.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from langchain_core.language_models import BaseChatModel
66
from langchain_core.tools import BaseTool
77
from uipath.agent.models.agent import (
8+
AgentA2aResourceConfig,
89
AgentContextResourceConfig,
910
AgentEscalationResourceConfig,
1011
AgentIntegrationToolResourceConfig,
@@ -18,6 +19,7 @@
1819

1920
from uipath_langchain.chat.hitl import REQUIRE_CONVERSATIONAL_CONFIRMATION
2021

22+
from .a2a import create_a2a_agent_tools
2123
from .context_tool import create_context_tool
2224
from .escalation_tool import create_escalation_tool
2325
from .extraction_tool import create_ixp_extraction_tool
@@ -96,4 +98,7 @@ async def _build_tool_for_resource(
9698
elif isinstance(resource, AgentIxpVsEscalationResourceConfig):
9799
return create_ixp_escalation_tool(resource)
98100

101+
elif isinstance(resource, AgentA2aResourceConfig):
102+
return await create_a2a_agent_tools([resource])
103+
99104
return None

0 commit comments

Comments
 (0)