You've already forked adk-python
mirror of
https://github.com/encounter/adk-python.git
synced 2026-03-30 10:57:20 -07:00
Merge branch 'main' into patch-1
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user