Skip to content

feat(a2a): [3/5] call UiPath conversational agents as A2A tools [PRODEV-1742] - #1139

Draft
ionmincu wants to merge 1 commit into
mainfrom
feat/prodev-1742-conversational-a2a
Draft

ionmincu wants to merge 1 commit into
mainfrom
feat/prodev-1742-conversational-a2a

Conversation

@ionmincu

@ionmincu ionmincu commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

PR stack (PRODEV-1742), merge in this order

  1. [1/5] UiPath/Agents#6529: storage schema: conversationalAgent tool type, plus its process binding in the packager. Wait for the @uipath/agents-storage-schemas 1.44.0 and @uipath/tool-agent 2.2.0 (packager) releases.
  2. [2/5] feat(agent): [2/5] conversational agent tool config [PRODEV-1742] uipath-python#1931: runtime model: AgentToolType.CONVERSATIONAL_AGENT and AgentConversationalAgentToolResourceConfig. Wait for the uipath 2.14.37 PyPI release.
  3. [3/5] feat(a2a): [3/5] call UiPath conversational agents as A2A tools [PRODEV-1742] #1139: runtime tool: resolve the release and call AgentHub a2a/{folderKey}/{releaseId} over A2A. Run uv lock first, then wait for the uipath-langchain 0.18.34 release. ← this PR
  4. [4/5] UiPath/uipath-agents-python#800: runtime graph: build the A2A tool for conversationalAgent tools (never a job tool), bump dependencies. Run uv lock first, then deploy the runtime.
  5. [5/5] UiPath/flow-workbench#4734: Flow: pick conversational agents as tools, write the conversationalAgent tool resource. Can merge any time after [1/5] is released, with the flag off. First bump @uipath/agents-storage-schemas to ^1.44.0 and @uipath/tool-agent to ^2.2.0 (older packagers emit no process binding for this tool), and set the flag default back to false.

Turn on the Flow flag canvas.nodes.agent-tool-conversational-a2a only after [4/5] is deployed.

Summary

Lets the agents runtime call an existing UiPath conversational agent as an A2A tool (Jira PRODEV-1742).

Why a tool type, not an a2a variant

An a2a resource without a slug makes older runtimes reject the whole agent (AgentA2aResourceConfig requires slug, and _normalize_resources keeps a2a). An unknown tool type goes through TOOL_MAP.get(t.lower(), "Unknown"), so older runtimes skip just that tool. A tool type also gets the solution process.<processName> binding from the packager, so @resource_override(process, process_name) takes effect. Precedent: AgentToolType.FLOW / FUNCTION.

  • Remote A2A is untouched. a2a_tool.py and test_a2a_tool.py are identical to main (git diff origin/main on both is empty). The conversational agent tool is its own module, agent/tools/a2a/conversational_agent_tool.py, which takes AgentConversationalAgentToolResourceConfig (from uipath-python#1931) and reads properties.process_name and cached_agent_card:
    • ConversationalAgentA2aClient: same get()/dispose() lifecycle as A2aClient. get() resolves the URL with _resolve_conversational_a2a_url(sdk, process_name, get_execution_folder_path()), then builds the A2A client; that block mirrors A2aClient.get() (a short duplicate, so A2aClient needs no hooks).
    • _send_message: one send function that raises. It reports input_required for a plain message reply (AgentHub's conversational reply shape).
    • _invoke: opens the trace span (the Remote A2A attributes plus conversation_id), resolves the client, sends, and maps failures, including resolving the client, to _A2aRequestError with the typed cause kept for terminal-state detection. No mode flag and no try/except wrapper functions.
    • Entry points create_conversational_agent_tools_and_clients(resources, *, is_conversational) and open_conversational_agent_tools, exported from uipath_langchain.agent.tools.a2a and uipath_langchain.agent.tools.
    • Read-only imports from a2a_tool: A2aStructuredToolWithWrapper, _build_description, _select_protocol_version, _extract_text, _task_state_name, _TERMINAL_TASK_STATES. The cached-card builder is duplicated because the Remote one reads the cached card from the Remote resource, not from properties.
  • A conversational agent tool without processName loads, then errors at call time ("conversational agent tool '' has no processName") as an error ToolMessage.
  • requireConversationalConfirmation is applied per tool inside create_conversational_agent_tools_and_clients(..., is_conversational=...) from the tool's own config, gated on the agent being conversational, like tool_factory does for other tools.
  • tool_factory._build_tool_for_resource returns None for the new resource (test added), so it never reaches the process/job path; the new module builds it.
  • URL resolution (lazy, first call of ConversationalAgentA2aClient.get(), like A2aClient.get()): the folder is the execution folder (get_execution_folder_path(), i.e. UIPATH_FOLDER_PATH), as for process and agent tools and the Remote A2A path; properties.folderPath is not used. A solution process.<processName> binding takes precedence: @resource_override remaps both the process name and the folder, the same way it does for client.processes.invoke_async in process_tool.py. The folder key comes from sdk.folders.retrieve_folder_key_async for that one folder, with no fallback. The release is read with GET /orchestrator_/odata/Releases?$filter=Name eq '...'&$select=Id,Name,ProcessType,IsConversational (quotes doubled) via sdk.api_client, and filtered client-side to ProcessType == 'Agent' and IsConversational is True, since I could not confirm server-side filtering. The SDK has no releases service, so this is a small private helper decorated with @resource_override(resource_type="process", resource_identifier="process_name"); as a tool resource it gets the solution process.<processName> binding from the packager. The URL goes through resolve_service_url("agenthub_/a2a/{folderKey}/{releaseId}"), so UIPATH_SERVICE_URL_AGENTHUB is honored; otherwise it is {sdk base url}/agenthub_/a2a/{folderKey}/{releaseId}.
  • Errors are plain ValueErrors so the tool node turns them into error ToolMessages (an AgentRuntimeError would terminate the agent); a test drives this through the real tool-node error handling. Cases: no execution folder set; folder not found (names the process and the folder); "'' in folder '' is not a published conversational agent". Graph construction never touches the network.
  • Labels: tool name is sanitize_tool_name(resource.name), description from the cached card or the resource description; metadata carries process_name instead of slug. Protocol version comes from the cached card, defaulting to 1.0 (AgentHub serves 0.3 and 1.0).
  • Bumps uipath to >=2.14.37, <2.15.0 and the package to 0.18.34 (main is at 0.18.33).

Conversation handles (conversational agent tool only)

Remote A2A keeps A2aToolInput and its {task_id, context_id} state, because its code is not touched.

Why: the tool used to hold one hidden conversation, so the model could not start a fresh one or run two in parallel, and in advanced (deepagents) mode, where tools run without a UiPathToolNode wrapper, every call opened a new child conversation. It also removes the terminal-task dead end: AgentHub's conversational endpoint has contextId = conversationId = taskId and rejects messages to a terminal task, which used to fail every later call.

Input (A2aConversationInput): message (required), conversation_id (continue that conversation), new_conversation (start a separate one; null means false). Both together returns invalid_arguments. The tool description says what omitting both does: the most recent open conversation may continue or a new one starts, so pass conversation_id to continue a specific one.

Result (ToolMessage content, JSON):

  • success: {"agent_response", "conversation_id", "task_state"}, plus "note" ("The previous conversation was not found; a new conversation was started.") when AgentHub answers with a different id than the one sent.
  • error: {"error": "conversation_closed" | "invalid_arguments" | "request_failed", "conversation_id": <id or null>, "message": <hint>}; the closed hint says to use new_conversation=true. Failures to resolve the process, folder or release (including a missing processName) are request_failed with the clear message, so they never abort an advanced-mode run.
  • A "terminal state" reply closes the conversation only when it is an A2A InvalidRequestError; any other error stays request_failed and keeps the state.

State (ReAct wrapper): tools_storage[tool.name] = {"current": cid | None, "conversations": {cid: {"task_id", "state": "open" | "closed", "last_used": <counter>}}}. The whole entry is replaced per call (not merged by conversation), which is what lets eviction and closing propagate; merge_dicts replaces per top-level key, so this is safe. ReAct dispatches one tool call per node visit, so each call sees the previously committed entry and nothing is lost. Capped at 20, evicting the least recently used, closed ones first (and clearing current if it is evicted). The earlier {task_id, context_id} entry is migrated into a one-conversation registry.

Resolution: new_conversation sends no IDs and becomes current. An explicit conversation_id is sent as the A2A context_id (with its task_id when known); a known closed one returns conversation_closed without a call, an unknown one is still sent (handles come from replayed history), and it becomes current only when there is none. With neither, current is used, or a new conversation starts. A terminal task closes the conversation and clears current.

Advanced mode: tools there run without a wrapper and tools_storage is not used. Tool instances outlive a run (the runtime factory caches compiled graphs), so nothing is stored: a call with neither argument always starts a new conversation, and the model continues one by passing the conversation_id from a previous result (CAS replays tool results as ToolMessages, so the id survives across turns).

Concurrency: sends are serialized per conversation with an asyncio.Lock per conversation id (held per tool, so effectively (tool, cid)). The locks live in a WeakValueDictionary, so ids the model invents do not accumulate. Different conversations run in parallel.

The A2A span carries conversation_id when one is known.

Lock

uv.lock is refreshed now that uipath 2.14.37 is on PyPI (uipath 2.14.35 -> 2.14.37 and the package version only); uv sync --locked passes.

Test plan

  • uv run pytest tests/agent/tools/test_conversational_agent_tool.py tests/agent/tools/test_a2a_tool.py tests/agent/tools/test_a2a_sdk_v1_migration.py tests/agent/tools/test_tool_factory.py tests/test_no_circular_imports.py: 313 passed. The conversational tests moved to test_conversational_agent_tool.py, with new ones for _send_message, client disposal, open_conversational_agent_tools, a failed request marking the span as an error, and a name-only process.<name> binding resolving without an execution folder.
  • uv run pytest tests/agent/tools/ tests/test_no_circular_imports.py: 1401 passed. Four internal_tools tests fail on my Windows machine only (symlink privilege, uip .cmd shim); they do not touch this change.
  • ruff check ., ruff format --check ., python scripts/lint_httpx_client.py, mypy --config-file pyproject.toml .: clean
  • uv sync --locked and uv sync --locked --all-extras: pass

🤖 Generated with Claude Code

@ionmincu ionmincu changed the title feat(a2a): call UiPath conversational agents as A2A tools [PRODEV-1742] feat(a2a): [3/5] call UiPath conversational agents as A2A tools [PRODEV-1742] Oct 7, 2026
@ionmincu
ionmincu force-pushed the feat/prodev-1742-conversational-a2a branch 2 times, most recently from caf3bca to 6e9c29a Compare October 9, 2026 15:40
Tuple of (response_text, task_state, new_task_id, new_context_id).
"""
try:
return await _send_a2a_message_or_raise(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's confusing to have both _send_a2a_message_or_raise and _send_a2a_message, especially since _send_a2a_message seems to just wrap the first one in a try-except block to catch the errors.

@ionmincu
ionmincu force-pushed the feat/prodev-1742-conversational-a2a branch from 6e9c29a to 122bc72 Compare October 12, 2026 15:00
- New conversational_agent_tool module next to a2a_tool: call an existing
  UiPath conversational agent as an A2A tool. ConversationalAgentA2aClient
  (same get()/dispose() lifecycle as A2aClient) resolves the conversational
  release by process name in the execution folder, like process tools
  (properties.folderPath is not used; a solution process binding overrides the
  name and the folder), then talks to AgentHub a2a/{folderKey}/{releaseId}.
- The Remote A2A code in a2a_tool.py is untouched; the new module only imports
  its read-only helpers (card description, protocol version, text and task
  state).
- create_conversational_agent_tools_and_clients and
  open_conversational_agent_tools take the conversationalAgent tool resources;
  a missing processName errors at call time instead of rejecting the agent, and
  requireConversationalConfirmation is applied per tool.
- Address conversations by conversation_id / new_conversation with a capped
  per-tool registry, per-conversation locks and terminal-state handling;
  advanced mode honors the same arguments without a tool wrapper. Request
  errors, including resolving the client, come back as a request_failed result.
- tool_factory leaves the new resource to this module (no job tool).
- Bump uipath to >=2.14.37 and the package to 0.18.34, and refresh uv.lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ionmincu
ionmincu force-pushed the feat/prodev-1742-conversational-a2a branch from 122bc72 to 4594e3b Compare October 12, 2026 15:59
@sonarqubecloud

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants