Merge branch 'main' into patch-1

This commit is contained in:
seanzhou1023
2025-07-10 17:19:59 -07:00
committed by GitHub
22 changed files with 1484 additions and 35 deletions
@@ -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.
@@ -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
@@ -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**: <your summary>".
* 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,
],
)
@@ -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")
@@ -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")
@@ -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"]
@@ -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
@@ -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",
}
@@ -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],
)
@@ -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',
+46
View File
@@ -0,0 +1,46 @@
# 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
from .parallel_agent import ParallelAgentConfig
from .sequential_agent import SequentialAgentConfig
# A discriminated union of all possible agent configurations.
ConfigsUnion = Union[
LlmAgentConfig,
LoopAgentConfig,
ParallelAgentConfig,
SequentialAgentConfig,
]
# 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"
+50
View File
@@ -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."""
@@ -0,0 +1,88 @@
# 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
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.")
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)
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")
@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)
@@ -0,0 +1,180 @@
{
"$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"
},
"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": [
"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"
},
"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": [
{
"$ref": "#/$defs/LlmAgentConfig"
},
{
"$ref": "#/$defs/LoopAgentConfig"
},
{
"$ref": "#/$defs/ParallelAgentConfig"
},
{
"$ref": "#/$defs/SequentialAgentConfig"
}
],
"description": "The config for the YAML schema to create an agent.",
"title": "AgentConfig"
}
+43
View File
@@ -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,44 @@ 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
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
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."""
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."""
+28
View File
@@ -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."""
+23 -3
View File
@@ -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'
+21 -1
View File
@@ -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'
+35 -1
View File
@@ -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."
+44 -5
View File
@@ -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,
}

Some files were not shown because too many files have changed in this diff Show More