Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
flowchart TB
__start__(__start__)
pipeline(pipeline)
coordinator(coordinator)
research_agent(research_agent)
code_agent(code_agent)
formatter(formatter)
__end__(__end__)
coordinator --> research_agent
research_agent --> coordinator
coordinator --> code_agent
code_agent --> coordinator
pipeline --> coordinator
coordinator --> pipeline
pipeline --> formatter
formatter --> pipeline
__start__ --> |input|pipeline
pipeline --> |output|__end__
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"agents": {
"agent": "main.py:agent"
}
}
152 changes: 152 additions & 0 deletions packages/uipath-google-adk/samples/multi-agent-remote/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Google ADK multi-agent-remote example: same pipeline as multi-agent but with sub-agents hosted remotely via A2A.

Demonstrates how to mix local orchestration with remote agent implementations:
- Coordinator and formatter run locally (they hold the orchestration logic)
- Specialist sub-agents (research, code) are RemoteA2aAgent instances hosted elsewhere
- The remote services don't need to know about each other — coordination stays local

Compare with the multi-agent sample:
multi-agent: Agent(tools=[search_web]) Agent(tools=[run_python])
multi-agent-remote: RemoteA2aAgent(agent_card=...) for each specialist

The key insight: RemoteA2aAgent cannot have sub_agents (it's not an LlmAgent),
but a local Agent CAN have RemoteA2aAgent instances as its sub_agents. This lets
you keep orchestration logic local while moving implementations to remote services.

Graph structure:
__start__ → pipeline → coordinator → research_agent (RemoteA2aAgent)
→ code_agent (RemoteA2aAgent)
→ formatter (output_schema=ReportOutput)
→ __end__

Schema resolution (handled by the runtime recursively):
- input_schema: from FIRST sub_agent chain → coordinator.input_schema (ReportInput)
- output_schema: from LAST sub_agent chain → formatter.output_schema (ReportOutput)
- output_key: from LAST sub_agent chain → formatter.output_key ("report")
"""

import os

import httpx
from a2a.client.client import ClientConfig as A2AClientConfig
from a2a.client.client_factory import ClientFactory as A2AClientFactory
from a2a.types import TransportProtocol as A2ATransport
from google.adk.agents import Agent, SequentialAgent
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
from pydantic import BaseModel, Field


class ReportInput(BaseModel):
"""Structured input for the report generation pipeline."""

topic: str = Field(default="Natural Language Processing fundamentals", description="The topic to research and analyze")
depth: str = Field(default="standard", description="How deep the analysis should be: 'brief', 'standard', or 'detailed'")


class ReportOutput(BaseModel):
"""Structured output from the report generation pipeline."""

title: str = Field(description="Report title")
summary: str = Field(description="Executive summary of findings")
key_findings: list[str] = Field(description="Key findings as bullet points")
code_snippet: str = Field(description="A relevant Python code example")


# --- Shared HTTP client with authorization ---
#
# UIPATH_ACCESS_TOKEN is used to authenticate requests to the remote A2A endpoints.
# Set it in your .env file before running.
_access_token = os.environ.get("UIPATH_ACCESS_TOKEN", "")

async def _log_request(request: httpx.Request):
body = (request.content or b"")[:4000]
print(f">>> {request.method} {request.url}\n body={body.decode(errors='replace')}", flush=True)


async def _log_response(response: httpx.Response):
await response.aread()
print(f"<<< {response.status_code} {response.request.url}\n body={response.text[:4000]}", flush=True)


_http_client = httpx.AsyncClient(
headers={"Authorization": f"Bearer {_access_token}"},
timeout=httpx.Timeout(120.0),
event_hooks={"request": [_log_request], "response": [_log_response]},
)

_a2a_client_factory = A2AClientFactory(
config=A2AClientConfig(
httpx_client=_http_client,
supported_transports=[A2ATransport.jsonrpc],
streaming=False,
polling=False,
accepted_output_modes=["text"],
),
)

# --- Remote Sub-agents ---
#
# These replace the local Agent(tools=[...]) definitions from the multi-agent sample.
# The coordinator delegates to them exactly the same way — the only difference is
# that their implementation runs remotely via the A2A protocol.
#
# Replace the URLs with your actual deployed agent endpoints.

research_agent = RemoteA2aAgent(
name="research_agent",
agent_card="https://alpha.uipath.com/Ada/GiuliaTenant/agenthub_/a2a/a11f72b1-90fd-4b30-b733-f0285cbf4a19/718231/.well-known/agent-card.json",
description="Remote research specialist that searches the web and summarizes findings",
a2a_client_factory=_a2a_client_factory,
)

code_agent = RemoteA2aAgent(
name="code_agent",
agent_card="https://alpha.uipath.com/Ada/GiuliaTenant/agenthub_/a2a/93ef0fda-0a83-4711-bffc-66099618b381/718070/.well-known/agent-card.json",
description="Remote Python developer that writes and executes code examples",
a2a_client_factory=_a2a_client_factory,
)


# --- Coordinator (local Agent, sub_agents are remote) ---
#
# Stays local so it can reference the remote sub_agents above.
# RemoteA2aAgent cannot have sub_agents — only local Agents can.
# The coordinator LLM decides when to call each remote sub-agent;
# the remote services themselves don't need to know about each other.
coordinator = Agent(
name="coordinator",
model="gemini-2.5-flash",
instruction=(
"You are a report coordinator. Given a topic:\n"
"1. Delegate research to research_agent to gather information\n"
"2. Delegate to code_agent to write a relevant Python code example\n"
"3. Compile all findings into a comprehensive text report\n"
"Include the research findings and the code example in your response."
),
sub_agents=[research_agent, code_agent],
input_schema=ReportInput,
output_key="research_results",
)


# --- Formatter (local Agent with output_schema) ---
#
# Stays local — output_schema + structured JSON formatting is a local concern.
formatter = Agent(
name="formatter",
model="gemini-2.5-flash",
instruction=(
"You are a report formatter. Take the research results from the previous "
"step and format them into a structured report with a title, summary, "
"key findings, and a code snippet. Output valid JSON matching the schema."
),
output_schema=ReportOutput,
output_key="report",
)


# --- Root: SequentialAgent pipeline ---
agent = SequentialAgent(
name="pipeline",
sub_agents=[coordinator, formatter],
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[project]
name = "multi-agent-remote"
version = "0.0.1"
description = "Google ADK multi-agent example with RemoteA2aAgent sub-agents via A2A protocol"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"uipath-google-adk",
"google-adk[a2a]>=1.25.0",
"uipath>=2.8.18, <2.9.0",
]

[dependency-groups]
dev = [
"uipath-dev",
]

[tool.uv]
override-dependencies = ["opentelemetry-sdk>=1.39.0,<1.40.0"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# How `Agent` delegates to sub-agents: the `transfer_to_agent` mechanism

## Overview

When an `Agent` (LlmAgent) has `sub_agents`, the Google ADK framework automatically injects a `transfer_to_agent` function tool into the LLM request. The LLM decides when to delegate based on agent descriptions, and the framework handles the actual execution.

This is why a local `Agent` can have `RemoteA2aAgent` sub-agents — the coordinator only needs each sub-agent's `name` and `description` to build the tool. The actual execution (`run_async`) is handled polymorphically.

## Flow

```
Agent(coordinator, sub_agents=[research_agent, code_agent])
AutoFlow adds agent_transfer request processor
_get_transfer_targets() collects sub_agents (+ parent/peers if allowed)
Builds LLM instructions listing each agent's name + description
Creates TransferToAgentTool with enum constraint on agent_name
LLM sees: transfer_to_agent(agent_name: Enum["research_agent", "code_agent"])
LLM calls: transfer_to_agent("research_agent")
Framework runs: research_agent.run_async(ctx)
(If RemoteA2aAgent → fires A2A call to remote endpoint)
```

## Key components

### 1. AutoFlow (`auto_flow.py`)

`AutoFlow` extends `SingleFlow` and adds the agent transfer request processor:

```python
class AutoFlow(SingleFlow):
def __init__(self):
super().__init__()
self.request_processors += [agent_transfer.request_processor]
```

`LlmAgent` selects `AutoFlow` when sub-agents (or peer/parent transfers) are available:

```python
@property
def _llm_flow(self) -> BaseLlmFlow:
if (
self.disallow_transfer_to_parent
and self.disallow_transfer_to_peers
and not self.sub_agents
):
return SingleFlow()
else:
return AutoFlow()
```

### 2. Transfer targets (`agent_transfer.py`)

The processor collects all agents the current agent can transfer to:

```python
def _get_transfer_targets(agent: LlmAgent) -> list[BaseAgent]:
result = []
result.extend(agent.sub_agents) # children

if not agent.disallow_transfer_to_parent:
result.append(agent.parent_agent) # parent

if not agent.disallow_transfer_to_peers:
result.extend([ # siblings
peer for peer in agent.parent_agent.sub_agents
if peer.name != agent.name
])

return result
```

### 3. Instructions injected into the LLM

For each transfer target, the processor builds instructions:

```python
def _build_target_agents_info(target_agent: BaseAgent) -> str:
return f"""
Agent name: {target_agent.name}
Agent description: {target_agent.description}
"""
```

These are appended to the LLM system instruction so the model knows which agents are available and what they do.

### 4. TransferToAgentTool (`transfer_to_agent_tool.py`)

A `FunctionTool` wrapping the `transfer_to_agent` function, with **enum constraints** on the `agent_name` parameter to prevent the LLM from hallucinating invalid agent names:

```python
class TransferToAgentTool(FunctionTool):
def __init__(self, agent_names: list[str]):
super().__init__(func=transfer_to_agent)
self._agent_names = agent_names

def _get_declaration(self) -> FunctionDeclaration:
function_decl = super()._get_declaration()
# Restrict agent_name to valid names only
agent_name_schema = function_decl.parameters.properties.get('agent_name')
if agent_name_schema:
agent_name_schema.enum = self._agent_names
return function_decl
```

The underlying function sets the transfer action on the context:

```python
def transfer_to_agent(agent_name: str, tool_context: ToolContext) -> None:
tool_context.actions.transfer_to_agent = agent_name
```

### 5. Execution

When the LLM returns a `transfer_to_agent` function call, the framework detects it and delegates:

```python
# In LlmAgent._run_async_impl / AutoFlow
agent_to_run = root_agent.find_agent(event.actions.transfer_to_agent)
async for event in agent_to_run.run_async(ctx):
yield event
```

## Why RemoteA2aAgent cannot be a coordinator

`RemoteA2aAgent` extends `BaseAgent` directly (not `LlmAgent`). Its `_run_async_impl` simply sends an A2A message to a remote endpoint and streams back the response. It has:

- No `AutoFlow`
- No `agent_transfer` request processor
- No `transfer_to_agent` tool injection
- No local LLM to make delegation decisions

The sub-agent orchestration logic lives entirely in `LlmAgent`'s flow pipeline. A `RemoteA2aAgent` with `sub_agents` would ignore them completely.

## The working pattern

```python
# Local coordinator (LlmAgent) — orchestration logic runs locally
coordinator = Agent(
name="coordinator",
model="gemini-2.5-flash",
instruction="...",
sub_agents=[research_agent, code_agent], # RemoteA2aAgent instances
)

# Remote sub-agents — implementation runs remotely via A2A
research_agent = RemoteA2aAgent(
name="research_agent",
agent_card="https://research-agent.example.com/.well-known/agent.json",
description="Research specialist that searches the web", # used by transfer instructions
)

code_agent = RemoteA2aAgent(
name="code_agent",
agent_card="https://code-agent.example.com/.well-known/agent.json",
description="Python developer that writes code examples", # used by transfer instructions
)
```

The coordinator LLM sees:
```
You have a list of other agents to transfer to:

Agent name: research_agent
Agent description: Research specialist that searches the web

Agent name: code_agent
Agent description: Python developer that writes code examples

If another agent is better for answering the question, call `transfer_to_agent`
to transfer the question to that agent.
```

And the tool declaration:
```
transfer_to_agent(agent_name: Enum["code_agent", "research_agent"])
```
Loading
Loading