From 149c5fe4ff2cfa0e560999fd2620e3db9719f758 Mon Sep 17 00:00:00 2001 From: Xuan Yang Date: Thu, 10 Jul 2025 11:06:08 -0700 Subject: [PATCH 01/10] chore: update triage agent to assign the issue to owner after labeling it PiperOrigin-RevId: 781604031 --- .../adk_issue_formatting_agent/utils.py | 1 + .../samples/adk_triaging_agent/agent.py | 71 +++++++++++++------ 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/contributing/samples/adk_issue_formatting_agent/utils.py b/contributing/samples/adk_issue_formatting_agent/utils.py index 2ee735d3..c8c4561b 100644 --- a/contributing/samples/adk_issue_formatting_agent/utils.py +++ b/contributing/samples/adk_issue_formatting_agent/utils.py @@ -20,6 +20,7 @@ import requests headers = { "Authorization": f"token {GITHUB_TOKEN}", "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28", } diff --git a/contributing/samples/adk_triaging_agent/agent.py b/contributing/samples/adk_triaging_agent/agent.py index dcd9b958..b5ad1950 100644 --- a/contributing/samples/adk_triaging_agent/agent.py +++ b/contributing/samples/adk_triaging_agent/agent.py @@ -25,18 +25,18 @@ from adk_triaging_agent.utils import post_request from google.adk import Agent import requests -ALLOWED_LABELS = [ - "documentation", - "services", - "question", - "tools", - "eval", - "live", - "models", - "tracing", - "core", - "web", -] +LABEL_TO_OWNER = { + "documentation": "polong", + "services": "DeanChensj", + "question": "", + "tools": "seanzhou1023", + "eval": "ankursharmas", + "live": "hangfei", + "models": "selcukgun", + "tracing": "Jacksunwei", + "core": "Jacksunwei", + "web": "wyf7107", +} APPROVAL_INSTRUCTION = ( "Do not ask for user approval for labeling! If you can't find appropriate" @@ -78,33 +78,61 @@ def list_unlabeled_issues(issue_count: int) -> dict[str, Any]: return {"status": "success", "issues": unlabeled_issues} -def add_label_to_issue(issue_number: int, label: str) -> dict[str, Any]: - """Add the specified label to the given issue number. +def add_label_and_owner_to_issue( + issue_number: int, label: str +) -> dict[str, Any]: + """Add the specified label and owner to the given issue number. Args: issue_number: issue number of the Github issue. label: label to assign Returns: - The the status of this request, with the applied label when successful. + The the status of this request, with the applied label and assigned owner + when successful. """ print(f"Attempting to add label '{label}' to issue #{issue_number}") - if label not in ALLOWED_LABELS: + if label not in LABEL_TO_OWNER: return error_response( f"Error: Label '{label}' is not an allowed label. Will not apply." ) - url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{issue_number}/labels" - payload = [label, BOT_LABEL] + label_url = ( + f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{issue_number}/labels" + ) + label_payload = [label, BOT_LABEL] try: - response = post_request(url, payload) + response = post_request(label_url, label_payload) except requests.exceptions.RequestException as e: return error_response(f"Error: {e}") + + owner = LABEL_TO_OWNER.get(label, None) + if not owner: + return { + "status": "warning", + "message": ( + f"{response}\n\nLabel '{label}' does not have an owner. Will not" + " assign." + ), + "applied_label": label, + } + + assignee_url = ( + f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{issue_number}/assignees" + ) + assignee_payload = {"assignees": [owner]} + + try: + response = post_request(assignee_url, assignee_payload) + except requests.exceptions.RequestException as e: + return error_response(f"Error: {e}") + return { "status": "success", "message": response, "applied_label": label, + "assigned_owner": owner, } @@ -128,9 +156,12 @@ root_agent = Agent( - If it's agent orchestration, agent definition, label it with "core". - If you can't find a appropriate labels for the issue, follow the previous instruction that starts with "IMPORTANT:". + Call the `add_label_and_owner_to_issue` tool to label the issue, which will also assign the issue to the owner of the label. + Present the followings in an easy to read format highlighting issue number and your label. - the issue summary in a few sentence - your label recommendation and justification + - the owner of the label if you assign the issue to an owner """, - tools=[list_unlabeled_issues, add_label_to_issue], + tools=[list_unlabeled_issues, add_label_and_owner_to_issue], ) From 2034fbb8f161a0cfc9666b5ef493436373067271 Mon Sep 17 00:00:00 2001 From: Liang Wu Date: Thu, 10 Jul 2025 11:40:48 -0700 Subject: [PATCH 02/10] chore: remove unused line for Client() PiperOrigin-RevId: 781617898 --- contributing/samples/google_search_agent/agent.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/contributing/samples/google_search_agent/agent.py b/contributing/samples/google_search_agent/agent.py index 4056f1ef..cbf69e7b 100644 --- a/contributing/samples/google_search_agent/agent.py +++ b/contributing/samples/google_search_agent/agent.py @@ -14,10 +14,6 @@ from google.adk import Agent from google.adk.tools import google_search -from google.genai import Client - -# Only Vertex AI supports image generation for now. -client = Client() root_agent = Agent( model='gemini-2.0-flash-001', From fb2415395f3be573ead1e0728a458131d261b6c8 Mon Sep 17 00:00:00 2001 From: Xuan Yang Date: Thu, 10 Jul 2025 13:09:15 -0700 Subject: [PATCH 03/10] chore: create an initial prototype agent to answer Github discussion questions This agent will post a comment to answer questions or provide more information according to the knowledge base. PiperOrigin-RevId: 781651937 --- .../samples/adk_answering_agent/README.md | 83 ++++++++ .../samples/adk_answering_agent/__init__.py | 15 ++ .../samples/adk_answering_agent/agent.py | 192 ++++++++++++++++++ .../adk_answering_agent/answer_discussions.py | 172 ++++++++++++++++ .../samples/adk_answering_agent/main.py | 66 ++++++ .../samples/adk_answering_agent/settings.py | 36 ++++ .../samples/adk_answering_agent/utils.py | 81 ++++++++ 7 files changed, 645 insertions(+) create mode 100644 contributing/samples/adk_answering_agent/README.md create mode 100644 contributing/samples/adk_answering_agent/__init__.py create mode 100644 contributing/samples/adk_answering_agent/agent.py create mode 100644 contributing/samples/adk_answering_agent/answer_discussions.py create mode 100644 contributing/samples/adk_answering_agent/main.py create mode 100644 contributing/samples/adk_answering_agent/settings.py create mode 100644 contributing/samples/adk_answering_agent/utils.py diff --git a/contributing/samples/adk_answering_agent/README.md b/contributing/samples/adk_answering_agent/README.md new file mode 100644 index 00000000..e2af591d --- /dev/null +++ b/contributing/samples/adk_answering_agent/README.md @@ -0,0 +1,83 @@ +# ADK Answering Agent + +The ADK Answering Agent is a Python-based agent designed to help answer questions in GitHub discussions for the `google/adk-python` repository. It uses a large language model to analyze open discussions, retrieve information from document store, generate response, and post a comment in the github discussion. + +This agent can be operated in three distinct modes: an interactive mode for local use, a batch script mode for oncall use, or as a fully automated GitHub Actions workflow (TBD). + +--- + +## Interactive Mode + +This mode allows you to run the agent locally to review its recommendations in real-time before any changes are made to your repository's issues. + +### Features +* **Web Interface**: The agent's interactive mode can be rendered in a web browser using the ADK's `adk web` command. +* **User Approval**: In interactive mode, the agent is instructed to ask for your confirmation before posting a comment to a GitHub issue. +* **Question & Answer**: You can ask ADK related questions, and the agent will provide answers based on its knowledge on ADK. + +### Running in Interactive Mode +To run the agent in interactive mode, first set the required environment variables. Then, execute the following command in your terminal: + +```bash +adk web +``` +This will start a local server and provide a URL to access the agent's web interface in your browser. + +--- + +## Batch Script Mode + +The `answer_discussions.py` is created for ADK oncall team to batch process discussions. + +### Features +* **Batch Process**: Taken either a number as the count of the recent discussions or a list of discussion numbers, the script will invoke the agent to answer all the specified discussions in one single run. + +### Running in Interactive Mode +To run the agent in batch script mode, first set the required environment variables. Then, execute the following command in your terminal: + +```bash +export PYTHONPATH=contributing/samples +python -m adk_answering_agent.answer_discussions --numbers 27 36 # Answer specified discussions +``` + +Or `python -m adk_answering_agent.answer_discussions --recent 10` to answer the 10 most recent updated discussions. + +--- + +## GitHub Workflow Mode + +The `main.py` is reserved for the Github Workflow. The detailed setup for the automatic workflow is TBD. + +--- + +## Setup and Configuration + +Whether running in interactive or workflow mode, the agent requires the following setup. + +### Dependencies +The agent requires the following Python libraries. + +```bash +pip install --upgrade pip +pip install google-adk requests +``` + +The agent also requires gcloud login: + +```bash +gcloud auth application-default login +``` + +### Environment Variables +The following environment variables are required for the agent to connect to the necessary services. + +* `GITHUB_TOKEN=YOUR_GITHUB_TOKEN`: **(Required)** A GitHub Personal Access Token with `issues:write` permissions. Needed for both interactive and workflow modes. +* `GOOGLE_GENAI_USE_VERTEXAI=TRUE`: **(Required)** Use Google Vertex AI for the authentication. +* `GOOGLE_CLOUD_PROJECT=YOUR_PROJECT_ID`: **(Required)** The Google Cloud project ID. +* `GOOGLE_CLOUD_LOCATION=LOCATION`: **(Required)** The Google Cloud region. +* `VERTEXAI_DATASTORE_ID=YOUR_DATASTORE_ID`: **(Required)** The Vertex AI datastore ID for the document store (i.e. knowledge base). +* `OWNER`: The GitHub organization or username that owns the repository (e.g., `google`). Needed for both modes. +* `REPO`: The name of the GitHub repository (e.g., `adk-python`). Needed for both modes. +* `INTERACTIVE`: Controls the agent's interaction mode. For the automated workflow, this is set to `0`. For interactive mode, it should be set to `1` or left unset. + +For local execution in interactive mode, you can place these variables in a `.env` file in the project's root directory. For the GitHub workflow, they should be configured as repository secrets. \ No newline at end of file diff --git a/contributing/samples/adk_answering_agent/__init__.py b/contributing/samples/adk_answering_agent/__init__.py new file mode 100644 index 00000000..c48963cd --- /dev/null +++ b/contributing/samples/adk_answering_agent/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent diff --git a/contributing/samples/adk_answering_agent/agent.py b/contributing/samples/adk_answering_agent/agent.py new file mode 100644 index 00000000..96e30a73 --- /dev/null +++ b/contributing/samples/adk_answering_agent/agent.py @@ -0,0 +1,192 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any + +from adk_answering_agent.settings import IS_INTERACTIVE +from adk_answering_agent.settings import OWNER +from adk_answering_agent.settings import REPO +from adk_answering_agent.settings import VERTEXAI_DATASTORE_ID +from adk_answering_agent.utils import error_response +from adk_answering_agent.utils import run_graphql_query +from google.adk.agents import Agent +from google.adk.tools import VertexAiSearchTool +import requests + +if IS_INTERACTIVE: + APPROVAL_INSTRUCTION = ( + "Ask for user approval or confirmation for adding the comment." + ) +else: + APPROVAL_INSTRUCTION = ( + "**Do not** wait or ask for user approval or confirmation for adding the" + " comment." + ) + + +def get_discussion_and_comments(discussion_number: int) -> dict[str, Any]: + """Fetches a discussion and its comments using the GitHub GraphQL API. + + Args: + discussion_number: The number of the GitHub discussion. + + Returns: + A dictionary with the request status and the discussion details. + """ + print(f"Attempting to get discussion #{discussion_number} and its comments") + query = """ + query($owner: String!, $repo: String!, $discussionNumber: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $discussionNumber) { + id + title + body + createdAt + closed + author { + login + } + # For each comment, fetch the latest 100 comments. + comments(last: 100) { + nodes { + id + body + createdAt + author { + login + } + # For each comment, fetch the latest 50 replies + replies(last: 50) { + nodes { + id + body + createdAt + author { + login + } + } + } + } + } + } + } + } + """ + variables = { + "owner": OWNER, + "repo": REPO, + "discussionNumber": discussion_number, + } + try: + response = run_graphql_query(query, variables) + if "errors" in response: + return error_response(str(response["errors"])) + discussion_data = ( + response.get("data", {}).get("repository", {}).get("discussion") + ) + if not discussion_data: + return error_response(f"Discussion #{discussion_number} not found.") + return {"status": "success", "discussion": discussion_data} + except requests.exceptions.RequestException as e: + return error_response(str(e)) + + +def add_comment_to_discussion( + discussion_id: str, comment_body: str +) -> dict[str, Any]: + """Adds a comment to a specific discussion. + + Args: + discussion_id: The GraphQL node ID of the discussion. + comment_body: The content of the comment in Markdown. + + Returns: + The status of the request and the new comment's details. + """ + print(f"Adding comment to discussion {discussion_id}") + query = """ + mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: {discussionId: $discussionId, body: $body}) { + comment { + id + body + createdAt + author { + login + } + } + } + } + """ + variables = {"discussionId": discussion_id, "body": comment_body} + try: + response = run_graphql_query(query, variables) + if "errors" in response: + return error_response(str(response["errors"])) + new_comment = ( + response.get("data", {}).get("addDiscussionComment", {}).get("comment") + ) + return {"status": "success", "comment": new_comment} + except requests.exceptions.RequestException as e: + return error_response(str(e)) + + +root_agent = Agent( + model="gemini-2.5-pro", + name="adk_answering_agent", + description="Answer questions about ADK repo.", + instruction=f""" + You are a helpful assistant that responds to questions from the GitHub repository `{OWNER}/{REPO}` + based on information about Google ADK found in the document store. You can access the document store + using the `VertexAiSearchTool`. + + When user specifies a discussion number, here are the steps: + 1. Use the `get_discussion_and_comments` tool to get the details of the discussion including the comments. + 2. Focus on the latest comment but reference all comments if needed to understand the context. + * If there is no comment at all, just focus on the discussion title and body. + 3. If all the following conditions are met, try to add a comment to the discussion, otherwise, do not respond: + * The discussion is not closed. + * The latest comment is not from you or other agents (marked as "Response from XXX Agent"). + * The latest comment is asking a question or requesting information. + 4. Use the `VertexAiSearchTool` to find relevant information before answering. + + IMPORTANT: + * {APPROVAL_INSTRUCTION} + * Your response should be based on the information you found in the document store. Do not invent + information that is not in the document store. Do not invent citations which are not in the document store. + * If you can't find the answer or information in the document store, **do not** respond. + * Include a bolded note (e.g. "Response from ADK Answering Agent") in your comment + to indicate this comment was added by an ADK Answering Agent. + * Have an empty line between the note and the rest of your response. + * Inlclude a short summary of your response in the comment as a TLDR, e.g. "**TLDR**: ". + * Have a divider line between the TLDR and your detail response. + * Do not respond to any other discussion except the one specified by the user. + * Please include your justification for your decision in your output + to the user who is telling with you. + * If you uses citation from the document store, please provide a footnote + referencing the source document format it as: "[1] URL of the document". + * Replace the "gs://prefix/" part, e.g. "gs://adk-qa-bucket/", to be "https://github.com/google/" + * Add "blob/main/" after the repo name, e.g. "adk-python", "adk-docs", for example: + * If the original URL is "gs://adk-qa-bucket/adk-python/src/google/adk/version.py", + then the citation URL is "https://github.com/google/adk-python/blob/main/src/google/adk/version.py", + * If the original URL is "gs://adk-qa-bucket/adk-docs/docs/index.md", + then the citation URL is "https://github.com/google/adk-docs/blob/main/docs/index.md" + * If the file is a html file, replace the ".html" to be ".md" + """, + tools=[ + VertexAiSearchTool(data_store_id=VERTEXAI_DATASTORE_ID), + get_discussion_and_comments, + add_comment_to_discussion, + ], +) diff --git a/contributing/samples/adk_answering_agent/answer_discussions.py b/contributing/samples/adk_answering_agent/answer_discussions.py new file mode 100644 index 00000000..1aa73758 --- /dev/null +++ b/contributing/samples/adk_answering_agent/answer_discussions.py @@ -0,0 +1,172 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import asyncio +import sys +import time + +from adk_answering_agent import agent +from adk_answering_agent.settings import OWNER +from adk_answering_agent.settings import REPO +from adk_answering_agent.utils import call_agent_async +from adk_answering_agent.utils import run_graphql_query +from google.adk.runners import InMemoryRunner +import requests + +APP_NAME = "adk_discussion_answering_app" +USER_ID = "adk_discussion_answering_assistant" + + +async def list_most_recent_discussions(count: int = 1) -> list[int] | None: + """Fetches a specified number of the most recently updated discussions. + + Args: + count: The number of discussions to retrieve. Defaults to 1. + + Returns: + A list of discussion numbers. + """ + print( + f"Attempting to fetch the {count} most recently updated discussions from" + f" {OWNER}/{REPO}..." + ) + + query = """ + query($owner: String!, $repo: String!, $count: Int!) { + repository(owner: $owner, name: $repo) { + discussions( + first: $count + orderBy: {field: UPDATED_AT, direction: DESC} + ) { + nodes { + title + number + updatedAt + author { + login + } + } + } + } + } + """ + variables = {"owner": OWNER, "repo": REPO, "count": count} + + try: + response = run_graphql_query(query, variables) + + if "errors" in response: + print(f"Error from GitHub API: {response['errors']}", file=sys.stderr) + return None + + discussions = ( + response.get("data", {}) + .get("repository", {}) + .get("discussions", {}) + .get("nodes", []) + ) + return [d["number"] for d in discussions] + + except requests.exceptions.RequestException as e: + print(f"Request failed: {e}", file=sys.stderr) + return None + + +def process_arguments(): + """Parses command-line arguments.""" + parser = argparse.ArgumentParser( + description="A script that answer questions for Github discussions.", + epilog=( + "Example usage: \n" + "\tpython -m adk_answering_agent.answer_discussions --recent 10\n" + "\tpython -m adk_answering_agent.answer_discussions --numbers 21 31\n" + ), + formatter_class=argparse.RawTextHelpFormatter, + ) + + group = parser.add_mutually_exclusive_group(required=True) + + group.add_argument( + "--recent", + type=int, + metavar="COUNT", + help="Answer the N most recently updated discussion numbers.", + ) + + group.add_argument( + "--numbers", + type=int, + nargs="+", + metavar="NUM", + help="Answer a specific list of discussion numbers.", + ) + + if len(sys.argv) == 1: + parser.print_help(sys.stderr) + sys.exit(1) + + return parser.parse_args() + + +async def main(): + args = process_arguments() + discussion_numbers = [] + + if args.recent: + discussion_numbers = await list_most_recent_discussions(count=args.recent) + elif args.numbers: + discussion_numbers = args.numbers + + if not discussion_numbers: + print("No discussions specified. Exiting...", file=sys.stderr) + sys.exit(1) + + print(f"Will try to answer discussions: {discussion_numbers}...") + + runner = InMemoryRunner( + agent=agent.root_agent, + app_name=APP_NAME, + ) + + for discussion_number in discussion_numbers: + print("#" * 80) + print(f"Starting to process discussion #{discussion_number}...") + # Create a new session for each discussion to avoid interference. + session = await runner.session_service.create_session( + app_name=APP_NAME, user_id=USER_ID + ) + prompt = ( + f"Please check discussion #{discussion_number} see if you can help" + " answer the question or provide some information!" + ) + response = await call_agent_async(runner, USER_ID, session.id, prompt) + print(f"<<<< Agent Final Output: {response}\n") + + +if __name__ == "__main__": + start_time = time.time() + print( + f"Start answering discussions for {OWNER}/{REPO} at" + f" {time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(start_time))}" + ) + print("-" * 80) + asyncio.run(main()) + print("-" * 80) + end_time = time.time() + print( + "Discussion answering finished at" + f" {time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(end_time))}", + ) + print("Total script execution time:", f"{end_time - start_time:.2f} seconds") diff --git a/contributing/samples/adk_answering_agent/main.py b/contributing/samples/adk_answering_agent/main.py new file mode 100644 index 00000000..735ebae7 --- /dev/null +++ b/contributing/samples/adk_answering_agent/main.py @@ -0,0 +1,66 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import time + +from adk_answering_agent import agent +from adk_answering_agent.settings import DISCUSSION_NUMBER +from adk_answering_agent.settings import OWNER +from adk_answering_agent.settings import REPO +from adk_answering_agent.utils import call_agent_async +from adk_answering_agent.utils import parse_number_string +from google.adk.runners import InMemoryRunner + +APP_NAME = "adk_answering_app" +USER_ID = "adk_answering_user" + + +async def main(): + runner = InMemoryRunner( + agent=agent.root_agent, + app_name=APP_NAME, + ) + session = await runner.session_service.create_session( + app_name=APP_NAME, user_id=USER_ID + ) + + discussion_number = parse_number_string(DISCUSSION_NUMBER) + if not discussion_number: + print(f"Error: Invalid discussion number received: {DISCUSSION_NUMBER}.") + return + + prompt = ( + f"Please check discussion #{discussion_number} see if you can help answer" + " the question or provide some information!" + ) + response = await call_agent_async(runner, USER_ID, session.id, prompt) + print(f"<<<< Agent Final Output: {response}\n") + + +if __name__ == "__main__": + start_time = time.time() + print( + f"Start Q&A checking on {OWNER}/{REPO} discussion #{DISCUSSION_NUMBER} at" + f" {time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(start_time))}" + ) + print("-" * 80) + asyncio.run(main()) + print("-" * 80) + end_time = time.time() + print( + "Q&A checking finished at" + f" {time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(end_time))}", + ) + print("Total script execution time:", f"{end_time - start_time:.2f} seconds") diff --git a/contributing/samples/adk_answering_agent/settings.py b/contributing/samples/adk_answering_agent/settings.py new file mode 100644 index 00000000..205f9f36 --- /dev/null +++ b/contributing/samples/adk_answering_agent/settings.py @@ -0,0 +1,36 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +from dotenv import load_dotenv + +load_dotenv(override=True) + +GITHUB_BASE_URL = "https://api.github.com" +GITHUB_GRAPHQL_URL = GITHUB_BASE_URL + "/graphql" + +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") +if not GITHUB_TOKEN: + raise ValueError("GITHUB_TOKEN environment variable not set") + +VERTEXAI_DATASTORE_ID = os.getenv("VERTEXAI_DATASTORE_ID") +if not VERTEXAI_DATASTORE_ID: + raise ValueError("VERTEXAI_DATASTORE_ID environment variable not set") + +OWNER = os.getenv("OWNER", "google") +REPO = os.getenv("REPO", "adk-python") +DISCUSSION_NUMBER = os.getenv("DISCUSSION_NUMBER") + +IS_INTERACTIVE = os.getenv("INTERACTIVE", "1").lower() in ["true", "1"] diff --git a/contributing/samples/adk_answering_agent/utils.py b/contributing/samples/adk_answering_agent/utils.py new file mode 100644 index 00000000..c8321f94 --- /dev/null +++ b/contributing/samples/adk_answering_agent/utils.py @@ -0,0 +1,81 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from typing import Any + +from adk_answering_agent.settings import GITHUB_GRAPHQL_URL +from adk_answering_agent.settings import GITHUB_TOKEN +from google.adk.agents.run_config import RunConfig +from google.adk.runners import Runner +from google.genai import types +import requests + +headers = { + "Authorization": f"token {GITHUB_TOKEN}", + "Accept": "application/vnd.github.v3+json", +} + + +def error_response(error_message: str) -> dict[str, Any]: + return {"status": "error", "error_message": error_message} + + +def run_graphql_query(query: str, variables: dict[str, Any]) -> dict[str, Any]: + """Executes a GraphQL query.""" + payload = {"query": query, "variables": variables} + response = requests.post( + GITHUB_GRAPHQL_URL, headers=headers, json=payload, timeout=60 + ) + response.raise_for_status() + return response.json() + + +def parse_number_string(number_str: str | None, default_value: int = 0) -> int: + """Parse a number from the given string.""" + if not number_str: + return default_value + + try: + return int(number_str) + except ValueError: + print( + f"Warning: Invalid number string: {number_str}. Defaulting to" + f" {default_value}.", + file=sys.stderr, + ) + return default_value + + +async def call_agent_async( + runner: Runner, user_id: str, session_id: str, prompt: str +) -> str: + """Call the agent asynchronously with the user's prompt.""" + content = types.Content( + role="user", parts=[types.Part.from_text(text=prompt)] + ) + + final_response_text = "" + async for event in runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=content, + run_config=RunConfig(save_input_blobs_as_artifacts=False), + ): + if event.content and event.content.parts: + if text := "".join(part.text or "" for part in event.content.parts): + if event.author != "user": + final_response_text += text + + return final_response_text From ca396a3ab1c1c43d5bd1c82fd92e49ce7fa67aec Mon Sep 17 00:00:00 2001 From: Liang Wu Date: Thu, 10 Jul 2025 13:31:40 -0700 Subject: [PATCH 04/10] feat(config): add APIs for building config agents Including basic fields in configs, the from_config() methods and the JSON schema. Other fields will be added in following PRs. PiperOrigin-RevId: 781660569 --- src/google/adk/agents/agent_config.py | 42 ++++++++ src/google/adk/agents/base_agent.py | 50 ++++++++++ .../agents/config_schemas/AgentConfig.json | 98 +++++++++++++++++++ src/google/adk/agents/llm_agent.py | 33 +++++++ src/google/adk/agents/loop_agent.py | 28 ++++++ 5 files changed, 251 insertions(+) create mode 100644 src/google/adk/agents/agent_config.py create mode 100644 src/google/adk/agents/config_schemas/AgentConfig.json diff --git a/src/google/adk/agents/agent_config.py b/src/google/adk/agents/agent_config.py new file mode 100644 index 00000000..6ddfe450 --- /dev/null +++ b/src/google/adk/agents/agent_config.py @@ -0,0 +1,42 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Union + +from pydantic import RootModel + +from ..utils.feature_decorator import working_in_progress +from .llm_agent import LlmAgentConfig +from .loop_agent import LoopAgentConfig + +# A discriminated union of all possible agent configurations. +ConfigsUnion = Union[ + LlmAgentConfig, + LoopAgentConfig, +] + + +# Use a RootModel to represent the agent directly at the top level. +# The `discriminator` is applied to the union within the RootModel. +@working_in_progress("AgentConfig is not ready for use.") +class AgentConfig(RootModel[ConfigsUnion]): + """The config for the YAML schema to create an agent.""" + + class Config: + # Pydantic v2 requires this for discriminated unions on RootModel + # This tells the model to look at the 'agent_class' field of the input + # data to decide which model from the `ConfigsUnion` to use. + discriminator = "agent_class" diff --git a/src/google/adk/agents/base_agent.py b/src/google/adk/agents/base_agent.py index 114773ae..c474c178 100644 --- a/src/google/adk/agents/base_agent.py +++ b/src/google/adk/agents/base_agent.py @@ -19,9 +19,12 @@ from typing import Any from typing import AsyncGenerator from typing import Awaitable from typing import Callable +from typing import Dict from typing import final +from typing import Literal from typing import Mapping from typing import Optional +from typing import Type from typing import TYPE_CHECKING from typing import TypeVar from typing import Union @@ -36,6 +39,7 @@ from typing_extensions import override from typing_extensions import TypeAlias from ..events.event import Event +from ..utils.feature_decorator import working_in_progress from .callback_context import CallbackContext if TYPE_CHECKING: @@ -439,3 +443,49 @@ class BaseAgent(BaseModel): ) sub_agent.parent_agent = self return self + + @classmethod + @working_in_progress('BaseAgent.from_config is not ready for use.') + def from_config( + cls: Type[SelfAgent], + config: BaseAgentConfig, + ) -> SelfAgent: + """Creates an agent from a config. + + This method converts fields in a config to the corresponding + fields in an agent. + + Child classes should re-implement this method to support loading from their + custom config types. + + Args: + config: The config to create the agent from. + + Returns: + The created agent. + """ + kwargs: Dict[str, Any] = { + 'name': config.name, + 'description': config.description, + } + return cls(**kwargs) + + +@working_in_progress('BaseAgentConfig is not ready for use.') +class BaseAgentConfig(BaseModel): + """The config for the YAML schema of a BaseAgent. + + Do not use this class directly. It's the base class for all agent configs. + """ + + model_config = ConfigDict(extra='forbid') + + agent_class: Literal['BaseAgent'] = 'BaseAgent' + """Required. The class of the agent. The value is used to differentiate + among different agent classes.""" + + name: str + """Required. The name of the agent.""" + + description: str = '' + """Optional. The description of the agent.""" diff --git a/src/google/adk/agents/config_schemas/AgentConfig.json b/src/google/adk/agents/config_schemas/AgentConfig.json new file mode 100644 index 00000000..b89b0b9f --- /dev/null +++ b/src/google/adk/agents/config_schemas/AgentConfig.json @@ -0,0 +1,98 @@ +{ + "$defs": { + "LlmAgentConfig": { + "additionalProperties": false, + "description": "The config for the YAML schema of a LlmAgent.", + "properties": { + "agent_class": { + "default": "LlmAgent", + "enum": [ + "LlmAgent", + "" + ], + "title": "Agent Class", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Model" + }, + "instruction": { + "title": "Instruction", + "type": "string" + } + }, + "required": [ + "name", + "instruction" + ], + "title": "LlmAgentConfig", + "type": "object" + }, + "LoopAgentConfig": { + "additionalProperties": false, + "description": "The config for the YAML schema of a LoopAgent.", + "properties": { + "agent_class": { + "const": "LoopAgent", + "default": "LoopAgent", + "title": "Agent Class", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + }, + "max_iterations": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Iterations" + } + }, + "required": [ + "name" + ], + "title": "LoopAgentConfig", + "type": "object" + } + }, + "anyOf": [ + { + "$ref": "#/$defs/LlmAgentConfig" + }, + { + "$ref": "#/$defs/LoopAgentConfig" + } + ], + "description": "The config for the YAML schema to create an agent.", + "title": "AgentConfig" +} \ No newline at end of file diff --git a/src/google/adk/agents/llm_agent.py b/src/google/adk/agents/llm_agent.py index a5c859e2..12b5b7c4 100644 --- a/src/google/adk/agents/llm_agent.py +++ b/src/google/adk/agents/llm_agent.py @@ -20,8 +20,10 @@ from typing import Any from typing import AsyncGenerator from typing import Awaitable from typing import Callable +from typing import Dict from typing import Literal from typing import Optional +from typing import Type from typing import Union from google.genai import types @@ -48,7 +50,9 @@ from ..tools.base_tool import BaseTool from ..tools.base_toolset import BaseToolset from ..tools.function_tool import FunctionTool from ..tools.tool_context import ToolContext +from ..utils.feature_decorator import working_in_progress from .base_agent import BaseAgent +from .base_agent import BaseAgentConfig from .callback_context import CallbackContext from .invocation_context import InvocationContext from .readonly_context import ReadonlyContext @@ -516,5 +520,34 @@ class LlmAgent(BaseAgent): ) return generate_content_config + @classmethod + @override + @working_in_progress('LlmAgent.from_config is not ready for use.') + def from_config( + cls: Type[LlmAgent], + config: LlmAgentConfig, + ) -> LlmAgent: + agent = super().from_config(config) + if config.model: + agent.model = config.model + if config.instruction: + agent.instruction = config.instruction + return agent + Agent: TypeAlias = LlmAgent + + +class LlmAgentConfig(BaseAgentConfig): + """The config for the YAML schema of a LlmAgent.""" + + agent_class: Literal['LlmAgent', ''] = 'LlmAgent' + """The value is used to uniquely identify the LlmAgent class. If it is + empty, it is by default an LlmAgent.""" + + model: Optional[str] = None + """Optional. LlmAgent.model. If not set, the model will be inherited from + the ancestor.""" + + instruction: str + """Required. LlmAgent.instruction.""" diff --git a/src/google/adk/agents/loop_agent.py b/src/google/adk/agents/loop_agent.py index 219e0c22..7a2c2230 100644 --- a/src/google/adk/agents/loop_agent.py +++ b/src/google/adk/agents/loop_agent.py @@ -16,14 +16,20 @@ from __future__ import annotations +from typing import Any from typing import AsyncGenerator +from typing import Dict +from typing import Literal from typing import Optional +from typing import Type from typing_extensions import override from ..agents.invocation_context import InvocationContext from ..events.event import Event +from ..utils.feature_decorator import working_in_progress from .base_agent import BaseAgent +from .base_agent import BaseAgentConfig class LoopAgent(BaseAgent): @@ -60,3 +66,25 @@ class LoopAgent(BaseAgent): ) -> AsyncGenerator[Event, None]: raise NotImplementedError('This is not supported yet for LoopAgent.') yield # AsyncGenerator requires having at least one yield statement + + @classmethod + @override + @working_in_progress('LoopAgent.from_config is not ready for use.') + def from_config( + cls: Type[LoopAgent], + config: LoopAgentConfig, + ) -> LoopAgent: + agent = super().from_config(config) + if config.max_iterations: + agent.max_iterations = config.max_iterations + return agent + + +@working_in_progress('LoopAgentConfig is not ready for use.') +class LoopAgentConfig(BaseAgentConfig): + """The config for the YAML schema of a LoopAgent.""" + + agent_class: Literal['LoopAgent'] = 'LoopAgent' + + max_iterations: Optional[int] = None + """Optional. LoopAgent.max_iterations.""" From aef54f8eb7e1aaa5d3830583c876c294e0266dc3 Mon Sep 17 00:00:00 2001 From: Liang Wu Date: Thu, 10 Jul 2025 13:47:22 -0700 Subject: [PATCH 05/10] feat(config): support loading from YAML config in CLI The supported CLIs are: `adk web`, `adk run` and `adk api_server`. PiperOrigin-RevId: 781666724 --- src/google/adk/agents/config_agent_utils.py | 80 +++++++++++ src/google/adk/cli/utils/agent_loader.py | 36 ++++- .../unittests/cli/utils/test_agent_loader.py | 132 +++++++++++++++++- 3 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 src/google/adk/agents/config_agent_utils.py diff --git a/src/google/adk/agents/config_agent_utils.py b/src/google/adk/agents/config_agent_utils.py new file mode 100644 index 00000000..08991146 --- /dev/null +++ b/src/google/adk/agents/config_agent_utils.py @@ -0,0 +1,80 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import os +from pathlib import Path + +import yaml + +from ..utils.feature_decorator import working_in_progress +from .agent_config import AgentConfig +from .base_agent import BaseAgent +from .llm_agent import LlmAgent +from .llm_agent import LlmAgentConfig +from .loop_agent import LoopAgent +from .loop_agent import LoopAgentConfig + + +@working_in_progress("from_config is not ready for use.") +def from_config(config_path: str) -> BaseAgent: + """Build agent from a configfile path. + + Args: + config: the path to a YAML config file. + + Returns: + The created agent instance. + + Raises: + FileNotFoundError: If config file doesn't exist. + ValidationError: If config file's content is invalid YAML. + ValueError: If agent type is unsupported. + """ + abs_path = os.path.abspath(config_path) + config = _load_config_from_path(abs_path) + + if isinstance(config.root, LlmAgentConfig): + return LlmAgent.from_config(config.root) + elif isinstance(config.root, LoopAgentConfig): + return LoopAgent.from_config(config.root) + else: + raise ValueError("Unsupported config type") + + +@working_in_progress("_load_config_from_path is not ready for use.") +def _load_config_from_path(config_path: str) -> AgentConfig: + """Load an agent's configuration from a YAML file. + + Args: + config_path: Path to the YAML config file. Both relative and absolute + paths are accepted. + + Returns: + The loaded and validated AgentConfig object. + + Raises: + FileNotFoundError: If config file doesn't exist. + ValidationError: If config file's content is invalid YAML. + """ + config_path = Path(config_path) + + if not config_path.exists(): + raise FileNotFoundError(f"Config file not found: {config_path}") + + with open(config_path, "r", encoding="utf-8") as f: + config_data = yaml.safe_load(f) + + return AgentConfig.model_validate(config_data) diff --git a/src/google/adk/cli/utils/agent_loader.py b/src/google/adk/cli/utils/agent_loader.py index ca81bd23..1e206846 100644 --- a/src/google/adk/cli/utils/agent_loader.py +++ b/src/google/adk/cli/utils/agent_loader.py @@ -16,11 +16,16 @@ from __future__ import annotations import importlib import logging +import os import sys from typing import Optional +from pydantic import ValidationError + from . import envs +from ...agents import config_agent_utils from ...agents.base_agent import BaseAgent +from ...utils.feature_decorator import working_in_progress logger = logging.getLogger("google_adk." + __name__) @@ -34,6 +39,8 @@ class AgentLoader: agents_dir/{agent_name}.py (with root_agent defined in the module) c) {agent_name} as a package name agents_dir/{agent_name}/__init__.py (with root_agent in the package) + d) {agent_name} as a YAML config folder: + agents_dir/{agent_name}/root_agent.yaml defines the root agent """ @@ -128,6 +135,29 @@ class AgentLoader: return None + @working_in_progress("_load_from_yaml_config is not ready for use.") + def _load_from_yaml_config(self, agent_name: str) -> Optional[BaseAgent]: + # Load from the config file at agents_dir/{agent_name}/root_agent.yaml + config_path = os.path.join(self.agents_dir, agent_name, "root_agent.yaml") + try: + agent = config_agent_utils.from_config(config_path) + logger.info("Loaded root agent for %s from %s", agent_name, config_path) + return agent + except FileNotFoundError: + logger.debug("Config file %s not found.", config_path) + return None + except ValidationError as e: + logger.error("Config file %s is invalid YAML.", config_path) + raise e + except Exception as e: + if hasattr(e, "msg"): + e.msg = f"Fail to load '{config_path}' config. " + e.msg + raise e + e.args = ( + f"Fail to load '{config_path}' config. {e.args[0] if e.args else ''}", + ) + e.args[1:] + raise e + def _perform_load(self, agent_name: str) -> BaseAgent: """Internal logic to load an agent""" # Add self.agents_dir to sys.path @@ -145,10 +175,14 @@ class AgentLoader: if root_agent := self._load_from_submodule(agent_name): return root_agent + if root_agent := self._load_from_yaml_config(agent_name): + return root_agent + # If no root_agent was found by any pattern raise ValueError( f"No root_agent found for '{agent_name}'. Searched in" - f" '{agent_name}.agent.root_agent', '{agent_name}.root_agent'." + f" '{agent_name}.agent.root_agent', '{agent_name}.root_agent' and" + f" '{agent_name}/root_agent.yaml'." f" Ensure '{self.agents_dir}/{agent_name}' is structured correctly," " an .env file can be loaded if present, and a root_agent is" " exposed." diff --git a/tests/unittests/cli/utils/test_agent_loader.py b/tests/unittests/cli/utils/test_agent_loader.py index dafac921..2b68f3cc 100644 --- a/tests/unittests/cli/utils/test_agent_loader.py +++ b/tests/unittests/cli/utils/test_agent_loader.py @@ -19,6 +19,7 @@ import tempfile from textwrap import dedent from google.adk.cli.utils.agent_loader import AgentLoader +from pydantic import ValidationError import pytest @@ -30,6 +31,8 @@ class TestAgentLoader: """Ensure sys.path is restored after each test.""" original_path = sys.path.copy() original_env = os.environ.copy() + # Enable WIP features for YAML agent loading tests + os.environ["ADK_ALLOW_WIP_FEATURES"] = "true" yield sys.path[:] = original_path # Restore environment variables @@ -292,7 +295,8 @@ class TestAgentLoader: expected_msg_part_1 = "No root_agent found for 'nonexistent_agent'." expected_msg_part_2 = ( "Searched in 'nonexistent_agent.agent.root_agent'," - " 'nonexistent_agent.root_agent'." + " 'nonexistent_agent.root_agent' and" + " 'nonexistent_agent/root_agent.yaml'." ) expected_msg_part_3 = ( f"Ensure '{agents_dir}/nonexistent_agent' is structured correctly" @@ -443,3 +447,129 @@ class TestAgentLoader: # Now assert path was added assert str(temp_path) in sys.path assert agent.name == "path_agent" + + def create_yaml_agent_structure( + self, temp_dir: Path, agent_name: str, yaml_content: str + ): + """Create an agent structure with YAML configuration. + + Args: + temp_dir: The temporary directory to create the agent in + agent_name: Name of the agent + yaml_content: YAML content for the root_agent.yaml file + """ + agent_dir = temp_dir / agent_name + agent_dir.mkdir() + + # Create root_agent.yaml file + yaml_file = agent_dir / "root_agent.yaml" + yaml_file.write_text(yaml_content) + + def test_load_agent_from_yaml_config(self): + """Test loading an agent from YAML configuration.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + agent_name = "yaml_agent" + + # Create YAML configuration + yaml_content = dedent(""" + agent_class: LlmAgent + name: yaml_test_agent + model: gemini-2.0-flash + instruction: You are a test agent loaded from YAML configuration. + description: A test agent created from YAML config + """) + + self.create_yaml_agent_structure(temp_path, agent_name, yaml_content) + + # Load the agent + loader = AgentLoader(str(temp_path)) + agent = loader.load_agent(agent_name) + + # Assert agent was loaded correctly + assert agent.name == "yaml_test_agent" + # Check if it's an LlmAgent before accessing model and instruction + from google.adk.agents.llm_agent import LlmAgent + + if isinstance(agent, LlmAgent): + assert agent.model == "gemini-2.0-flash" + # Handle instruction which can be string or InstructionProvider + instruction_text = str(agent.instruction) + assert "test agent loaded from YAML" in instruction_text + + def test_yaml_agent_caching_returns_same_instance(self): + """Test that loading the same YAML agent twice returns the same instance.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + agent_name = "cached_yaml_agent" + + # Create YAML configuration + yaml_content = dedent(""" + agent_class: LlmAgent + name: cached_yaml_test_agent + model: gemini-2.0-flash + instruction: You are a cached test agent. + """) + + self.create_yaml_agent_structure(temp_path, agent_name, yaml_content) + + # Load the agent twice + loader = AgentLoader(str(temp_path)) + agent1 = loader.load_agent(agent_name) + agent2 = loader.load_agent(agent_name) + + # Assert same instance is returned + assert agent1 is agent2 + assert agent1.name == agent2.name + + def test_yaml_agent_not_found_error(self): + """Test that appropriate error is raised when YAML agent is not found.""" + with tempfile.TemporaryDirectory() as temp_dir: + loader = AgentLoader(temp_dir) + agents_dir = temp_dir # For use in the expected message string + + # Try to load non-existent YAML agent + with pytest.raises(ValueError) as exc_info: + loader.load_agent("nonexistent_yaml_agent") + + expected_msg_part_1 = "No root_agent found for 'nonexistent_yaml_agent'." + expected_msg_part_2 = ( + "Searched in 'nonexistent_yaml_agent.agent.root_agent'," + " 'nonexistent_yaml_agent.root_agent' and" + " 'nonexistent_yaml_agent/root_agent.yaml'." + ) + expected_msg_part_3 = ( + f"Ensure '{agents_dir}/nonexistent_yaml_agent' is structured" + " correctly" + ) + + assert expected_msg_part_1 in str(exc_info.value) + assert expected_msg_part_2 in str(exc_info.value) + assert expected_msg_part_3 in str(exc_info.value) + + def test_yaml_agent_invalid_yaml_error(self): + """Test that appropriate error is raised when YAML is invalid.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + agent_name = "invalid_yaml_agent" + + # Create invalid YAML content with wrong field name + invalid_yaml_content = dedent(""" + agent_type: LlmAgent + name: invalid_yaml_test_agent + model: gemini-2.0-flash + instruction: You are a test agent with invalid YAML + """) + + self.create_yaml_agent_structure( + temp_path, agent_name, invalid_yaml_content + ) + + loader = AgentLoader(str(temp_path)) + + # Try to load agent with invalid YAML + with pytest.raises(ValidationError) as exc_info: + loader.load_agent(agent_name) + + # Should raise some form of YAML parsing error + assert "Extra inputs are not permitted" in str(exc_info.value) From d83362725d08150832565668d8335db672526d97 Mon Sep 17 00:00:00 2001 From: Liang Wu Date: Thu, 10 Jul 2025 14:44:27 -0700 Subject: [PATCH 06/10] feat(config): add disallow_transfer_to_parent and disallow_transfer_to_peers to LlmAgentConfig PiperOrigin-RevId: 781690247 --- .../agents/config_schemas/AgentConfig.json | 24 +++++++++++++++++++ src/google/adk/agents/llm_agent.py | 10 ++++++++ 2 files changed, 34 insertions(+) diff --git a/src/google/adk/agents/config_schemas/AgentConfig.json b/src/google/adk/agents/config_schemas/AgentConfig.json index b89b0b9f..4a91f9f3 100644 --- a/src/google/adk/agents/config_schemas/AgentConfig.json +++ b/src/google/adk/agents/config_schemas/AgentConfig.json @@ -37,6 +37,30 @@ "instruction": { "title": "Instruction", "type": "string" + }, + "disallow_transfer_to_parent": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Disallow Transfer To Parent" + }, + "disallow_transfer_to_peers": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Disallow Transfer To Peers" } }, "required": [ diff --git a/src/google/adk/agents/llm_agent.py b/src/google/adk/agents/llm_agent.py index 12b5b7c4..6c5e109a 100644 --- a/src/google/adk/agents/llm_agent.py +++ b/src/google/adk/agents/llm_agent.py @@ -532,6 +532,10 @@ class LlmAgent(BaseAgent): agent.model = config.model if config.instruction: agent.instruction = config.instruction + if config.disallow_transfer_to_parent: + agent.disallow_transfer_to_parent = config.disallow_transfer_to_parent + if config.disallow_transfer_to_peers: + agent.disallow_transfer_to_peers = config.disallow_transfer_to_peers return agent @@ -551,3 +555,9 @@ class LlmAgentConfig(BaseAgentConfig): instruction: str """Required. LlmAgent.instruction.""" + + disallow_transfer_to_parent: Optional[bool] = None + """Optional. LlmAgent.disallow_transfer_to_parent.""" + + disallow_transfer_to_peers: Optional[bool] = None + """Optional. LlmAgent.disallow_transfer_to_peers.""" From a313c2c1afb00b67dd64c57c4ffde952a28f29b4 Mon Sep 17 00:00:00 2001 From: Liang Wu Date: Thu, 10 Jul 2025 15:23:44 -0700 Subject: [PATCH 07/10] feat(config): add configs for ParallelAgent and SequentialAgent PiperOrigin-RevId: 781704814 --- src/google/adk/agents/agent_config.py | 4 ++ .../agents/config_schemas/AgentConfig.json | 58 +++++++++++++++++++ src/google/adk/agents/parallel_agent.py | 26 ++++++++- src/google/adk/agents/sequential_agent.py | 22 ++++++- 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/src/google/adk/agents/agent_config.py b/src/google/adk/agents/agent_config.py index 6ddfe450..f32f0f96 100644 --- a/src/google/adk/agents/agent_config.py +++ b/src/google/adk/agents/agent_config.py @@ -21,11 +21,15 @@ from pydantic import RootModel from ..utils.feature_decorator import working_in_progress from .llm_agent import LlmAgentConfig from .loop_agent import LoopAgentConfig +from .parallel_agent import ParallelAgentConfig +from .sequential_agent import SequentialAgentConfig # A discriminated union of all possible agent configurations. ConfigsUnion = Union[ LlmAgentConfig, LoopAgentConfig, + ParallelAgentConfig, + SequentialAgentConfig, ] diff --git a/src/google/adk/agents/config_schemas/AgentConfig.json b/src/google/adk/agents/config_schemas/AgentConfig.json index 4a91f9f3..b353ba11 100644 --- a/src/google/adk/agents/config_schemas/AgentConfig.json +++ b/src/google/adk/agents/config_schemas/AgentConfig.json @@ -107,6 +107,58 @@ ], "title": "LoopAgentConfig", "type": "object" + }, + "ParallelAgentConfig": { + "additionalProperties": false, + "description": "The config for the YAML schema of a ParallelAgent.", + "properties": { + "agent_class": { + "const": "ParallelAgent", + "default": "ParallelAgent", + "title": "Agent Class", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ParallelAgentConfig", + "type": "object" + }, + "SequentialAgentConfig": { + "additionalProperties": false, + "description": "The config for the YAML schema of a SequentialAgent.", + "properties": { + "agent_class": { + "const": "SequentialAgent", + "default": "SequentialAgent", + "title": "Agent Class", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "description": { + "default": "", + "title": "Description", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "SequentialAgentConfig", + "type": "object" } }, "anyOf": [ @@ -115,6 +167,12 @@ }, { "$ref": "#/$defs/LoopAgentConfig" + }, + { + "$ref": "#/$defs/ParallelAgentConfig" + }, + { + "$ref": "#/$defs/SequentialAgentConfig" } ], "description": "The config for the YAML schema to create an agent.", diff --git a/src/google/adk/agents/parallel_agent.py b/src/google/adk/agents/parallel_agent.py index 427128ce..10b573bb 100644 --- a/src/google/adk/agents/parallel_agent.py +++ b/src/google/adk/agents/parallel_agent.py @@ -18,9 +18,13 @@ from __future__ import annotations import asyncio from typing import AsyncGenerator +from typing import Literal +from typing import Type from typing_extensions import override +from ..agents.base_agent import BaseAgentConfig +from ..agents.base_agent import working_in_progress from ..agents.invocation_context import InvocationContext from ..events.event import Event from .base_agent import BaseAgent @@ -33,9 +37,9 @@ def _create_branch_ctx_for_sub_agent( ) -> InvocationContext: """Create isolated branch for every sub-agent.""" invocation_context = invocation_context.model_copy() - branch_suffix = f"{agent.name}.{sub_agent.name}" + branch_suffix = f'{agent.name}.{sub_agent.name}' invocation_context.branch = ( - f"{invocation_context.branch}.{branch_suffix}" + f'{invocation_context.branch}.{branch_suffix}' if invocation_context.branch else branch_suffix ) @@ -109,5 +113,21 @@ class ParallelAgent(BaseAgent): async def _run_live_impl( self, ctx: InvocationContext ) -> AsyncGenerator[Event, None]: - raise NotImplementedError("This is not supported yet for ParallelAgent.") + raise NotImplementedError('This is not supported yet for ParallelAgent.') yield # AsyncGenerator requires having at least one yield statement + + @classmethod + @override + @working_in_progress('ParallelAgent.from_config is not ready for use.') + def from_config( + cls: Type[ParallelAgent], + config: ParallelAgentConfig, + ) -> ParallelAgent: + return super().from_config(config) + + +@working_in_progress('ParallelAgentConfig is not ready for use.') +class ParallelAgentConfig(BaseAgentConfig): + """The config for the YAML schema of a ParallelAgent.""" + + agent_class: Literal['ParallelAgent'] = 'ParallelAgent' diff --git a/src/google/adk/agents/sequential_agent.py b/src/google/adk/agents/sequential_agent.py index 845dd5ac..e094f8ae 100644 --- a/src/google/adk/agents/sequential_agent.py +++ b/src/google/adk/agents/sequential_agent.py @@ -17,9 +17,13 @@ from __future__ import annotations from typing import AsyncGenerator +from typing import Literal +from typing import Type from typing_extensions import override +from ..agents.base_agent import BaseAgentConfig +from ..agents.base_agent import working_in_progress from ..agents.invocation_context import InvocationContext from ..events.event import Event from .base_agent import BaseAgent @@ -60,7 +64,7 @@ class SequentialAgent(BaseAgent): Signals that the model has successfully completed the user's question or task. """ - return "Task completion signaled." + return 'Task completion signaled.' if isinstance(sub_agent, LlmAgent): # Use function name to dedupe. @@ -74,3 +78,19 @@ class SequentialAgent(BaseAgent): for sub_agent in self.sub_agents: async for event in sub_agent.run_live(ctx): yield event + + @classmethod + @override + @working_in_progress('SequentialAgent.from_config is not ready for use.') + def from_config( + cls: Type[SequentialAgent], + config: SequentialAgentConfig, + ) -> SequentialAgent: + return super().from_config(config) + + +@working_in_progress('SequentialAgentConfig is not ready for use.') +class SequentialAgentConfig(BaseAgentConfig): + """The config for the YAML schema of a SequentialAgent.""" + + agent_class: Literal['SequentialAgent'] = 'SequentialAgent' From 68f34135fef7aed16f17691276377df6712c751f Mon Sep 17 00:00:00 2001 From: Liang Wu Date: Thu, 10 Jul 2025 15:42:06 -0700 Subject: [PATCH 08/10] feat(config): add ParallelAgent and SequentialAgent to the loader PiperOrigin-RevId: 781712004 --- src/google/adk/agents/config_agent_utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/google/adk/agents/config_agent_utils.py b/src/google/adk/agents/config_agent_utils.py index 08991146..a9609b68 100644 --- a/src/google/adk/agents/config_agent_utils.py +++ b/src/google/adk/agents/config_agent_utils.py @@ -26,6 +26,10 @@ from .llm_agent import LlmAgent from .llm_agent import LlmAgentConfig from .loop_agent import LoopAgent from .loop_agent import LoopAgentConfig +from .parallel_agent import ParallelAgent +from .parallel_agent import ParallelAgentConfig +from .sequential_agent import SequentialAgent +from .sequential_agent import SequentialAgentConfig @working_in_progress("from_config is not ready for use.") @@ -50,6 +54,10 @@ def from_config(config_path: str) -> BaseAgent: return LlmAgent.from_config(config.root) elif isinstance(config.root, LoopAgentConfig): return LoopAgent.from_config(config.root) + elif isinstance(config.root, ParallelAgentConfig): + return ParallelAgent.from_config(config.root) + elif isinstance(config.root, SequentialAgentConfig): + return SequentialAgent.from_config(config.root) else: raise ValueError("Unsupported config type") From 2f655f0c67940a924b5818cebcb83fb4a3565dcb Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Thu, 10 Jul 2025 16:16:56 -0700 Subject: [PATCH 09/10] fix: Add support for code execution result and skip inline data in anthropic llm ``` blaze run assistant/lamda/bard/scrape/vertex/tools:use_vertex_agent -- --prompt_collection_id=cab_v0p7@3 --agent_name=vertex_1p_agent --kernel_id=vertex_agent:claude-sonnet-4@20250514 --dynamically_inject_tools=True --add_final_answer_tag=True --example_ids=plot-line-004_0.5_new_1 ``` TODO: https://evalhub.corp.google.com/runs/Uf5en14gqmK-R/links PiperOrigin-RevId: 781724544 --- src/google/adk/models/anthropic_llm.py | 49 +++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/src/google/adk/models/anthropic_llm.py b/src/google/adk/models/anthropic_llm.py index a3a0e096..15d78d72 100644 --- a/src/google/adk/models/anthropic_llm.py +++ b/src/google/adk/models/anthropic_llm.py @@ -16,6 +16,7 @@ from __future__ import annotations +import base64 from functools import cached_property import logging import os @@ -45,7 +46,7 @@ __all__ = ["Claude"] logger = logging.getLogger("google_adk." + __name__) -MAX_TOKEN = 1024 +MAX_TOKEN = 8192 class ClaudeRequest(BaseModel): @@ -70,6 +71,14 @@ def to_google_genai_finish_reason( return "FINISH_REASON_UNSPECIFIED" +def _is_image_part(part: types.Part) -> bool: + return ( + part.inline_data + and part.inline_data.mime_type + and part.inline_data.mime_type.startswith("image") + ) + + def part_to_message_block( part: types.Part, ) -> Union[ @@ -80,7 +89,7 @@ def part_to_message_block( ]: if part.text: return anthropic_types.TextBlockParam(text=part.text, type="text") - if part.function_call: + elif part.function_call: assert part.function_call.name return anthropic_types.ToolUseBlockParam( @@ -89,7 +98,7 @@ def part_to_message_block( input=part.function_call.args, type="tool_use", ) - if part.function_response: + elif part.function_response: content = "" if ( "result" in part.function_response.response @@ -105,15 +114,45 @@ def part_to_message_block( content=content, is_error=False, ) - raise NotImplementedError("Not supported yet.") + elif _is_image_part(part): + data = base64.b64encode(part.inline_data.data).decode() + return anthropic_types.ImageBlockParam( + type="image", + source=dict( + type="base64", media_type=part.inline_data.mime_type, data=data + ), + ) + elif part.executable_code: + return anthropic_types.TextBlockParam( + type="text", + text="Code:```python\n" + part.executable_code.code + "\n```", + ) + elif part.code_execution_result: + return anthropic_types.TextBlockParam( + text="Execution Result:```code_output\n" + + part.code_execution_result.output + + "\n```", + type="text", + ) + + raise NotImplementedError(f"Not supported yet: {part}") def content_to_message_param( content: types.Content, ) -> anthropic_types.MessageParam: + message_block = [] + for part in content.parts or []: + # Image data is not supported in Claude for model turns. + if _is_image_part(part): + logger.warning("Image data is not supported in Claude for model turns.") + continue + + message_block.append(part_to_message_block(part)) + return { "role": to_claude_role(content.role), - "content": [part_to_message_block(part) for part in content.parts or []], + "content": message_block, } From 5a721d99a7da29d4eea65facbaa5f1e3d3c0e5af Mon Sep 17 00:00:00 2001 From: Yanfei Chen Date: Thu, 10 Jul 2025 17:15:17 -0700 Subject: [PATCH 10/10] test: add the unit test for EnterpriseWebSearchTool PiperOrigin-RevId: 781743222 --- .../tools/test_enterprise_web_search_tool.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/unittests/tools/test_enterprise_web_search_tool.py diff --git a/tests/unittests/tools/test_enterprise_web_search_tool.py b/tests/unittests/tools/test_enterprise_web_search_tool.py new file mode 100644 index 00000000..390da4a7 --- /dev/null +++ b/tests/unittests/tools/test_enterprise_web_search_tool.py @@ -0,0 +1,98 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from google.adk.agents.invocation_context import InvocationContext +from google.adk.agents.sequential_agent import SequentialAgent +from google.adk.models.llm_request import LlmRequest +from google.adk.sessions.in_memory_session_service import InMemorySessionService +from google.adk.tools.enterprise_search_tool import EnterpriseWebSearchTool +from google.adk.tools.tool_context import ToolContext +from google.genai import types +import pytest + + +async def _create_tool_context() -> ToolContext: + """Creates a ToolContext for testing.""" + session_service = InMemorySessionService() + session = await session_service.create_session( + app_name='test_app', user_id='test_user' + ) + agent = SequentialAgent(name='test_agent') + invocation_context = InvocationContext( + invocation_id='invocation_id', + agent=agent, + session=session, + session_service=session_service, + ) + return ToolContext(invocation_context) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + 'model_name', + [ + 'gemini-2.5-flash', + 'projects/test-project/locations/global/publishers/google/models/gemini-2.5-flash', + ], +) +async def test_process_llm_request_success_with_gemini_models(model_name): + tool = EnterpriseWebSearchTool() + llm_request = LlmRequest( + model=model_name, config=types.GenerateContentConfig() + ) + tool_context = await _create_tool_context() + + await tool.process_llm_request( + tool_context=tool_context, llm_request=llm_request + ) + + assert ( + llm_request.config.tools[0].enterprise_web_search + == types.EnterpriseWebSearch() + ) + + +@pytest.mark.asyncio +async def test_process_llm_request_failure_with_non_gemini_models(): + tool = EnterpriseWebSearchTool() + llm_request = LlmRequest(model='gpt-4o', config=types.GenerateContentConfig()) + tool_context = await _create_tool_context() + + with pytest.raises(ValueError) as exc_info: + await tool.process_llm_request( + tool_context=tool_context, llm_request=llm_request + ) + assert 'is not supported for model' in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_process_llm_request_failure_with_multiple_tools_gemini_1_models(): + tool = EnterpriseWebSearchTool() + llm_request = LlmRequest( + model='gemini-1.5-flash', + config=types.GenerateContentConfig( + tools=[ + types.Tool(google_search=types.GoogleSearch()), + ] + ), + ) + tool_context = await _create_tool_context() + + with pytest.raises(ValueError) as exc_info: + await tool.process_llm_request( + tool_context=tool_context, llm_request=llm_request + ) + assert 'can not be used with other tools in Gemini 1.x.' in str( + exc_info.value + )