Skip to content

Commit bcf034c

Browse files
authored
Merge branch 'main' into fix/litellm-streaming-content-duplication
2 parents 021378e + c6e7d6b commit bcf034c

File tree

18 files changed

+784
-94
lines changed

18 files changed

+784
-94
lines changed

contributing/samples/adk_triaging_agent/agent.py

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -78,38 +78,6 @@
7878
APPROVAL_INSTRUCTION = "Only label them when the user approves the labeling!"
7979

8080

81-
def list_unlabeled_issues(issue_count: int) -> dict[str, Any]:
82-
"""List most recent `issue_count` number of unlabeled issues in the repo.
83-
84-
Args:
85-
issue_count: number of issues to return
86-
87-
Returns:
88-
The status of this request, with a list of issues when successful.
89-
"""
90-
url = f"{GITHUB_BASE_URL}/search/issues"
91-
query = f"repo:{OWNER}/{REPO} is:open is:issue no:label"
92-
params = {
93-
"q": query,
94-
"sort": "created",
95-
"order": "desc",
96-
"per_page": issue_count,
97-
"page": 1,
98-
}
99-
100-
try:
101-
response = get_request(url, params)
102-
except requests.exceptions.RequestException as e:
103-
return error_response(f"Error: {e}")
104-
issues = response.get("items", None)
105-
106-
unlabeled_issues = []
107-
for issue in issues:
108-
if not issue.get("labels", None):
109-
unlabeled_issues.append(issue)
110-
return {"status": "success", "issues": unlabeled_issues}
111-
112-
11381
def list_planned_untriaged_issues(issue_count: int) -> dict[str, Any]:
11482
"""List planned issues without component labels (e.g., core, tools, etc.).
11583
@@ -276,7 +244,6 @@ def change_issue_type(issue_number: int, issue_type: str) -> dict[str, Any]:
276244
- the owner of the label if you assign the issue to an owner
277245
""",
278246
tools=[
279-
list_unlabeled_issues,
280247
list_planned_untriaged_issues,
281248
add_label_and_owner_to_issue,
282249
change_issue_type,

contributing/samples/adk_triaging_agent/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ async def main():
144144
f" most recent {issue_count} planned issues that haven't been"
145145
" triaged yet (i.e., issues with 'planned' label but no component"
146146
" labels like 'core', 'tools', etc.). Then triage each of them by"
147-
" applying appropriate component labels."
147+
" applying appropriate component labels. If you cannot find any planned"
148+
" issues, please don't try to triage any issues."
148149
)
149150

150151
response = await call_agent_async(runner, USER_ID, session.id, prompt)

src/google/adk/agents/base_agent.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import inspect
18+
import logging
1819
from typing import Any
1920
from typing import AsyncGenerator
2021
from typing import Awaitable
@@ -49,6 +50,8 @@
4950
if TYPE_CHECKING:
5051
from .invocation_context import InvocationContext
5152

53+
logger = logging.getLogger('google_adk.' + __name__)
54+
5255
_SingleAgentCallback: TypeAlias = Callable[
5356
[CallbackContext],
5457
Union[Awaitable[Optional[types.Content]], Optional[types.Content]],
@@ -563,6 +566,45 @@ def validate_name(cls, value: str):
563566
)
564567
return value
565568

569+
@field_validator('sub_agents', mode='after')
570+
@classmethod
571+
def validate_sub_agents_unique_names(
572+
cls, value: list[BaseAgent]
573+
) -> list[BaseAgent]:
574+
"""Validates that all sub-agents have unique names.
575+
576+
Args:
577+
value: The list of sub-agents to validate.
578+
579+
Returns:
580+
The validated list of sub-agents.
581+
582+
"""
583+
if not value:
584+
return value
585+
586+
seen_names: set[str] = set()
587+
duplicates: set[str] = set()
588+
589+
for sub_agent in value:
590+
name = sub_agent.name
591+
if name in seen_names:
592+
duplicates.add(name)
593+
else:
594+
seen_names.add(name)
595+
596+
if duplicates:
597+
duplicate_names_str = ', '.join(
598+
f'`{name}`' for name in sorted(duplicates)
599+
)
600+
logger.warning(
601+
'Found duplicate sub-agent names: %s. '
602+
'All sub-agents must have unique names.',
603+
duplicate_names_str,
604+
)
605+
606+
return value
607+
566608
def __set_parent_agent_for_sub_agents(self) -> BaseAgent:
567609
for sub_agent in self.sub_agents:
568610
if sub_agent.parent_agent is not None:

src/google/adk/agents/config_agent_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ def resolve_agent_reference(
132132
else:
133133
return from_config(
134134
os.path.join(
135-
referencing_agent_config_abs_path.rsplit("/", 1)[0],
135+
os.path.dirname(referencing_agent_config_abs_path),
136136
ref_config.config_path,
137137
)
138138
)

src/google/adk/agents/remote_a2a_agent.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,9 @@ async def _handle_a2a_response(
417417
# This is the initial response for a streaming task or the complete
418418
# response for a non-streaming task, which is the full task state.
419419
# We process this to get the initial message.
420-
event = convert_a2a_task_to_event(task, self.name, ctx)
420+
event = convert_a2a_task_to_event(
421+
task, self.name, ctx, self._a2a_part_converter
422+
)
421423
# for streaming task, we update the event with the task status.
422424
# We update the event as Thought updates.
423425
if task and task.status and task.status.state == TaskState.submitted:
@@ -429,7 +431,7 @@ async def _handle_a2a_response(
429431
):
430432
# This is a streaming task status update with a message.
431433
event = convert_a2a_message_to_event(
432-
update.status.message, self.name, ctx
434+
update.status.message, self.name, ctx, self._a2a_part_converter
433435
)
434436
if event.content and update.status.state in [
435437
TaskState.submitted,
@@ -447,7 +449,9 @@ async def _handle_a2a_response(
447449
# signals:
448450
# 1. append: True for partial updates, False for full updates.
449451
# 2. last_chunk: True for full updates, False for partial updates.
450-
event = convert_a2a_task_to_event(task, self.name, ctx)
452+
event = convert_a2a_task_to_event(
453+
task, self.name, ctx, self._a2a_part_converter
454+
)
451455
else:
452456
# This is a streaming update without a message (e.g. status change)
453457
# or a partial artifact update. We don't emit an event for these
@@ -463,7 +467,9 @@ async def _handle_a2a_response(
463467

464468
# Otherwise, it's a regular A2AMessage for non-streaming responses.
465469
elif isinstance(a2a_response, A2AMessage):
466-
event = convert_a2a_message_to_event(a2a_response, self.name, ctx)
470+
event = convert_a2a_message_to_event(
471+
a2a_response, self.name, ctx, self._a2a_part_converter
472+
)
467473
event.custom_metadata = event.custom_metadata or {}
468474

469475
if a2a_response.context_id:

src/google/adk/cli/utils/agent_loader.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ class AgentLoader(BaseAgentLoader):
5858
"""
5959

6060
def __init__(self, agents_dir: str):
61-
self.agents_dir = agents_dir.rstrip("/")
61+
self.agents_dir = str(Path(agents_dir))
6262
self._original_sys_path = None
6363
self._agent_cache: dict[str, Union[BaseAgent, App]] = {}
6464

@@ -272,12 +272,13 @@ def _perform_load(self, agent_name: str) -> Union[BaseAgent, App]:
272272
f"No root_agent found for '{agent_name}'. Searched in"
273273
f" '{actual_agent_name}.agent.root_agent',"
274274
f" '{actual_agent_name}.root_agent' and"
275-
f" '{actual_agent_name}/root_agent.yaml'.\n\nExpected directory"
276-
f" structure:\n <agents_dir>/\n {actual_agent_name}/\n "
277-
" agent.py (with root_agent) OR\n root_agent.yaml\n\nThen run:"
278-
f" adk web <agents_dir>\n\nEnsure '{agents_dir}/{actual_agent_name}' is"
279-
" structured correctly, an .env file can be loaded if present, and a"
280-
f" root_agent is exposed.{hint}"
275+
f" '{actual_agent_name}{os.sep}root_agent.yaml'.\n\nExpected directory"
276+
f" structure:\n <agents_dir>{os.sep}\n "
277+
f" {actual_agent_name}{os.sep}\n agent.py (with root_agent) OR\n "
278+
" root_agent.yaml\n\nThen run: adk web <agents_dir>\n\nEnsure"
279+
f" '{os.path.join(agents_dir, actual_agent_name)}' is structured"
280+
" correctly, an .env file can be loaded if present, and a root_agent"
281+
f" is exposed.{hint}"
281282
)
282283

283284
def _record_origin_metadata(

src/google/adk/flows/llm_flows/agent_transfer.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,8 @@
2424
from ...agents.invocation_context import InvocationContext
2525
from ...events.event import Event
2626
from ...models.llm_request import LlmRequest
27-
from ...tools.function_tool import FunctionTool
2827
from ...tools.tool_context import ToolContext
29-
from ...tools.transfer_to_agent_tool import transfer_to_agent
28+
from ...tools.transfer_to_agent_tool import TransferToAgentTool
3029
from ._base_llm_processor import BaseLlmRequestProcessor
3130

3231
if typing.TYPE_CHECKING:
@@ -50,13 +49,18 @@ async def run_async(
5049
if not transfer_targets:
5150
return
5251

52+
transfer_to_agent_tool = TransferToAgentTool(
53+
agent_names=[agent.name for agent in transfer_targets]
54+
)
55+
5356
llm_request.append_instructions([
5457
_build_target_agents_instructions(
55-
invocation_context.agent, transfer_targets
58+
transfer_to_agent_tool.name,
59+
invocation_context.agent,
60+
transfer_targets,
5661
)
5762
])
5863

59-
transfer_to_agent_tool = FunctionTool(func=transfer_to_agent)
6064
tool_context = ToolContext(invocation_context)
6165
await transfer_to_agent_tool.process_llm_request(
6266
tool_context=tool_context, llm_request=llm_request
@@ -80,10 +84,13 @@ def _build_target_agents_info(target_agent: BaseAgent) -> str:
8084

8185

8286
def _build_target_agents_instructions(
83-
agent: LlmAgent, target_agents: list[BaseAgent]
87+
tool_name: str,
88+
agent: LlmAgent,
89+
target_agents: list[BaseAgent],
8490
) -> str:
8591
# Build list of available agent names for the NOTE
86-
# target_agents already includes parent agent if applicable, so no need to add it again
92+
# target_agents already includes parent agent if applicable,
93+
# so no need to add it again
8794
available_agent_names = [target_agent.name for target_agent in target_agents]
8895

8996
# Sort for consistency
@@ -101,15 +108,16 @@ def _build_target_agents_instructions(
101108
_build_target_agents_info(target_agent) for target_agent in target_agents
102109
])}
103110
104-
If you are the best to answer the question according to your description, you
105-
can answer it.
111+
If you are the best to answer the question according to your description,
112+
you can answer it.
106113
107114
If another agent is better for answering the question according to its
108-
description, call `{_TRANSFER_TO_AGENT_FUNCTION_NAME}` function to transfer the
109-
question to that agent. When transferring, do not generate any text other than
110-
the function call.
115+
description, call `{tool_name}` function to transfer the question to that
116+
agent. When transferring, do not generate any text other than the function
117+
call.
111118
112-
**NOTE**: the only available agents for `{_TRANSFER_TO_AGENT_FUNCTION_NAME}` function are {formatted_agent_names}.
119+
**NOTE**: the only available agents for `{tool_name}` function are
120+
{formatted_agent_names}.
113121
"""
114122

115123
if agent.parent_agent and not agent.disallow_transfer_to_parent:
@@ -119,9 +127,6 @@ def _build_target_agents_instructions(
119127
return si
120128

121129

122-
_TRANSFER_TO_AGENT_FUNCTION_NAME = transfer_to_agent.__name__
123-
124-
125130
def _get_transfer_targets(agent: LlmAgent) -> list[BaseAgent]:
126131
from ...agents.llm_agent import LlmAgent
127132

src/google/adk/tools/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from .preload_memory_tool import preload_memory_tool as preload_memory
3838
from .tool_context import ToolContext
3939
from .transfer_to_agent_tool import transfer_to_agent
40+
from .transfer_to_agent_tool import TransferToAgentTool
4041
from .url_context_tool import url_context
4142
from .vertex_ai_search_tool import VertexAiSearchTool
4243

@@ -75,6 +76,10 @@
7576
'preload_memory': ('.preload_memory_tool', 'preload_memory_tool'),
7677
'ToolContext': ('.tool_context', 'ToolContext'),
7778
'transfer_to_agent': ('.transfer_to_agent_tool', 'transfer_to_agent'),
79+
'TransferToAgentTool': (
80+
'.transfer_to_agent_tool',
81+
'TransferToAgentTool',
82+
),
7883
'url_context': ('.url_context_tool', 'url_context'),
7984
'VertexAiSearchTool': ('.vertex_ai_search_tool', 'VertexAiSearchTool'),
8085
'MCPToolset': ('.mcp_tool.mcp_toolset', 'MCPToolset'),

src/google/adk/tools/transfer_to_agent_tool.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@
1414

1515
from __future__ import annotations
1616

17+
from typing import Optional
18+
19+
from google.genai import types
20+
from typing_extensions import override
21+
22+
from .function_tool import FunctionTool
1723
from .tool_context import ToolContext
1824

1925

@@ -23,7 +29,61 @@ def transfer_to_agent(agent_name: str, tool_context: ToolContext) -> None:
2329
This tool hands off control to another agent when it's more suitable to
2430
answer the user's question according to the agent's description.
2531
32+
Note:
33+
For most use cases, you should use TransferToAgentTool instead of this
34+
function directly. TransferToAgentTool provides additional enum constraints
35+
that prevent LLMs from hallucinating invalid agent names.
36+
2637
Args:
2738
agent_name: the agent name to transfer to.
2839
"""
2940
tool_context.actions.transfer_to_agent = agent_name
41+
42+
43+
class TransferToAgentTool(FunctionTool):
44+
"""A specialized FunctionTool for agent transfer with enum constraints.
45+
46+
This tool enhances the base transfer_to_agent function by adding JSON Schema
47+
enum constraints to the agent_name parameter. This prevents LLMs from
48+
hallucinating invalid agent names by restricting choices to only valid agents.
49+
50+
Attributes:
51+
agent_names: List of valid agent names that can be transferred to.
52+
"""
53+
54+
def __init__(
55+
self,
56+
agent_names: list[str],
57+
):
58+
"""Initialize the TransferToAgentTool.
59+
60+
Args:
61+
agent_names: List of valid agent names that can be transferred to.
62+
"""
63+
super().__init__(func=transfer_to_agent)
64+
self._agent_names = agent_names
65+
66+
@override
67+
def _get_declaration(self) -> Optional[types.FunctionDeclaration]:
68+
"""Add enum constraint to the agent_name parameter.
69+
70+
Returns:
71+
FunctionDeclaration with enum constraint on agent_name parameter.
72+
"""
73+
function_decl = super()._get_declaration()
74+
if not function_decl:
75+
return function_decl
76+
77+
# Handle parameters (types.Schema object)
78+
if function_decl.parameters:
79+
agent_name_schema = function_decl.parameters.properties.get('agent_name')
80+
if agent_name_schema:
81+
agent_name_schema.enum = self._agent_names
82+
83+
# Handle parameters_json_schema (dict)
84+
if function_decl.parameters_json_schema:
85+
properties = function_decl.parameters_json_schema.get('properties', {})
86+
if 'agent_name' in properties:
87+
properties['agent_name']['enum'] = self._agent_names
88+
89+
return function_decl

0 commit comments

Comments
 (0)