chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# Contributing Resources
|
||||
|
||||
This folder hosts resources for ADK contributors, for example, testing samples etc.
|
||||
|
||||
## Samples
|
||||
|
||||
Samples folder host samples to test different features. The samples are usually minimal and simplistic to test one or a few scenarios.
|
||||
|
||||
**Note**: This is different from the [google/adk-samples](https://github.com/google/adk-samples) repo, which hosts more complex e2e samples for customers to use or modify directly.
|
||||
|
||||
## ADK project and architecture overview
|
||||
|
||||
The [adk_project_overview_and_architecture.md](adk_project_overview_and_architecture.md) describes the ADK project overview and its technical architecture from high-level.
|
||||
|
||||
This is helpful for contributors to understand the project and design philosophy.
|
||||
It can also be fed into LLMs for vibe-coding.
|
||||
@@ -0,0 +1,129 @@
|
||||
# ADK Project Overview and Architecture
|
||||
|
||||
Google Agent Development Kit (ADK) for Python
|
||||
|
||||
## Core Philosophy & Architecture
|
||||
|
||||
- Code-First: Everything is defined in Python code for versioning, testing, and IDE support. Avoid GUI-based logic.
|
||||
|
||||
- Modularity & Composition: We build complex multi-agent systems by composing multiple, smaller, specialized agents.
|
||||
|
||||
- Deployment-Agnostic: The agent's core logic is separate from its deployment environment. The same agent.py can be run locally for testing, served via an API, or deployed to the cloud.
|
||||
|
||||
## Foundational Abstractions (Our Vocabulary)
|
||||
|
||||
- Agent: The blueprint. It defines an agent's identity, instructions, and tools. It's a declarative configuration object.
|
||||
|
||||
- Tool: A capability. A Python function an agent can call to interact with the world (e.g., search, API call).
|
||||
|
||||
- Runner: The engine. It orchestrates the "Reason-Act" loop, manages LLM calls, and executes tools.
|
||||
|
||||
- Session: The conversation state. It holds the history for a single, continuous dialogue.
|
||||
|
||||
- Memory: Long-term recall across different sessions.
|
||||
|
||||
- Artifact Service: Manages non-textual data like files.
|
||||
|
||||
## Canonical Project Structure
|
||||
|
||||
Adhere to this structure for compatibility with ADK tooling.
|
||||
|
||||
```
|
||||
my_adk_project/
|
||||
└── src/
|
||||
└── my_app/
|
||||
├── agents/
|
||||
│ ├── my_agent/
|
||||
│ │ ├── __init__.py # Must contain: from . import agent \
|
||||
│ │ └── agent.py # Must contain: root_agent = Agent(...) \
|
||||
│ └── another_agent/
|
||||
│ ├── __init__.py
|
||||
│ └── agent.py\
|
||||
```
|
||||
|
||||
agent.py: Must define the agent and assign it to a variable named root_agent. This is how ADK's tools find it.
|
||||
|
||||
`__init__.py`: In each agent directory, it must contain `from . import agent` to make the agent discoverable.
|
||||
|
||||
### Nested Agent Directories (Dev Mode / `adk web`)
|
||||
|
||||
In the local development server (`adk web` / `dev_server`), ADK supports deeply nested agent directories (e.g., sub-packages or structured folders).
|
||||
|
||||
- **Recursive Discovery**: The loader recursively walks directories to discover all valid agent applications containing an `agent.py`, `root_agent.yaml`, or `__init__.py` file.
|
||||
- **Dot Naming Convention**: Nested agents are represented in the system and referenced inside the Web UI using a standard dot-separated namespace notation (e.g., `agent_samples.empty_agent` or `workflow_samples.fan_out_fan_in`).
|
||||
- **Isolation**: Production environments (`adk api_server`) only support flat single-level agent directories for maximum security and isolation.
|
||||
|
||||
## Local Development & Debugging
|
||||
|
||||
Interactive UI (adk web): This is our primary debugging tool. It's a decoupled system:
|
||||
|
||||
Backend: A FastAPI server started with adk api_server.
|
||||
|
||||
Frontend: An Angular app that connects to the backend.
|
||||
|
||||
Use the "Events" tab to inspect the full execution trace (prompts, tool calls, responses).
|
||||
|
||||
CLI (adk run): For quick, stateless functional checks in the terminal.
|
||||
|
||||
Programmatic (pytest): For writing automated unit and integration tests.
|
||||
|
||||
## The API Layer (FastAPI)
|
||||
|
||||
We expose agents as production APIs using FastAPI.
|
||||
|
||||
- get_fast_api_app: This is the key helper function from google.adk.cli.fast_api that creates a FastAPI app from our agent directory.
|
||||
|
||||
- Standard Endpoints: The generated app includes standard routes like /list-apps and /run_sse for streaming responses. The wire format is camelCase.
|
||||
|
||||
- Custom Endpoints: We can add our own routes (e.g., /health) to the app object returned by the helper.
|
||||
|
||||
```Python
|
||||
|
||||
from google.adk.cli.fast_api import get_fast_api_app
|
||||
app = get_fast_api_app(agent_dir="./agents")
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "ok"}
|
||||
```
|
||||
|
||||
### Default Application Resolution (`ADK_DEFAULT_APP_NAME`)
|
||||
|
||||
By default, the ADK API server expects an explicit application context in all requests (e.g., via the `/apps/{app_name}/...` path or in the payload body).
|
||||
|
||||
However, if the environment variable `ADK_DEFAULT_APP_NAME` is set, or if the server is running in **single agent mode** (when pointing directly to a directory containing an agent instead of a directory of agents), the server will automatically resolve and fall back to that agent as the default application whenever a request lacks an explicit app name. In single agent mode, the local agent takes precedence over the `ADK_DEFAULT_APP_NAME` environment variable.
|
||||
|
||||
- **URL Path-Rewriting (Production Endpoints)**: Requests to production endpoints that omit the `/apps/{app_name}` prefix (such as `/users/{user_id}/sessions` or `/app-info`) are automatically rewritten by an internal ASGI middleware to target the default application. (Note: `/dev` and `/builder` endpoints are excluded from rewriting).
|
||||
- **Agent Execution & Streaming**: Requests to `/run`, `/run_sse`, or `/run_live` that omit the `app_name` parameter in their payload body or query string will automatically resolve to the default application.
|
||||
|
||||
## Deployment to Production
|
||||
|
||||
The adk cli provides the "adk deploy" command to deploy to Google Vertex Agent Engine, Google CloudRun, Google GKE.
|
||||
|
||||
## Testing & Evaluation Strategy
|
||||
|
||||
Testing is layered, like a pyramid.
|
||||
|
||||
### Layer 1: Unit Tests (Base)
|
||||
|
||||
What: Test individual Tool functions in isolation.
|
||||
|
||||
How: Use pytest in tests/test_tools.py. Verify deterministic logic.
|
||||
|
||||
### Layer 2: Integration Tests (Middle)
|
||||
|
||||
What: Test the agent's internal logic and interaction with tools.
|
||||
|
||||
How: Use pytest in tests/test_agent.py, often with mocked LLMs or services.
|
||||
|
||||
### Layer 3: Evaluation Tests (Top)
|
||||
|
||||
What: Assess end-to-end performance with a live LLM. This is about quality, not just pass/fail.
|
||||
|
||||
How: Use the ADK Evaluation Framework.
|
||||
|
||||
Test Cases: Create JSON files with input and a reference (expected tool calls and final response).
|
||||
|
||||
Metrics: tool_trajectory_avg_score (does it use tools correctly?) and response_match_score (is the final answer good?).
|
||||
|
||||
Run via: adk web (UI), pytest (for CI/CD), or adk eval (CLI).
|
||||
@@ -0,0 +1,234 @@
|
||||
# A2A OAuth Authentication Sample Agent
|
||||
|
||||
This sample demonstrates the **Agent-to-Agent (A2A)** architecture with **OAuth Authentication** workflows in the Agent Development Kit (ADK). The sample implements a multi-agent system where a remote agent can surface OAuth authentication requests to the local agent, which then guides the end user through the OAuth flow before returning the authentication credentials to the remote agent for API access.
|
||||
|
||||
## Overview
|
||||
|
||||
The A2A OAuth Authentication sample consists of:
|
||||
|
||||
- **Root Agent** (`root_agent`): The main orchestrator that handles user requests and delegates tasks to specialized agents
|
||||
- **YouTube Search Agent** (`youtube_search_agent`): A local agent that handles YouTube video searches using LangChain tools
|
||||
- **BigQuery Agent** (`bigquery_agent`): A remote A2A agent that manages BigQuery operations and requires OAuth authentication for Google Cloud access
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌────────────────────┐ ┌──────────────────┐
|
||||
│ End User │───▶│ Root Agent │───▶│ BigQuery Agent │
|
||||
│ (OAuth Flow) │ │ (Local) │ │ (Remote A2A) │
|
||||
│ │ │ │ │ (localhost:8001) │
|
||||
│ OAuth UI │◀───│ │◀───│ OAuth Request │
|
||||
└─────────────────┘ └────────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. **Multi-Agent Architecture**
|
||||
|
||||
- Root agent coordinates between local YouTube search and remote BigQuery operations
|
||||
- Demonstrates hybrid local/remote agent workflows
|
||||
- Seamless task delegation based on user request types
|
||||
|
||||
### 2. **OAuth Authentication Workflow**
|
||||
|
||||
- Remote BigQuery agent surfaces OAuth authentication requests to the root agent
|
||||
- Root agent guides end users through Google OAuth flow for BigQuery access
|
||||
- Secure token exchange between agents for authenticated API calls
|
||||
|
||||
### 3. **Google Cloud Integration**
|
||||
|
||||
- BigQuery toolset with comprehensive dataset and table management capabilities
|
||||
- OAuth-protected access to user's Google Cloud BigQuery resources
|
||||
- Support for listing, creating, and managing datasets and tables
|
||||
|
||||
### 4. **LangChain Tool Integration**
|
||||
|
||||
- YouTube search functionality using LangChain community tools
|
||||
- Demonstrates integration of third-party tools in agent workflows
|
||||
|
||||
## Setup and Usage
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Set up OAuth Credentials**:
|
||||
|
||||
```bash
|
||||
export OAUTH_CLIENT_ID=your_google_oauth_client_id
|
||||
export OAUTH_CLIENT_SECRET=your_google_oauth_client_secret
|
||||
```
|
||||
|
||||
1. **Start the Remote BigQuery Agent server**:
|
||||
|
||||
```bash
|
||||
# Start the remote a2a server that serves the BigQuery agent on port 8001
|
||||
adk api_server --a2a --port 8001 contributing/samples/a2a_auth/remote_a2a
|
||||
```
|
||||
|
||||
1. **Run the Main Agent**:
|
||||
|
||||
```bash
|
||||
# In a separate terminal, run the adk web server
|
||||
adk web contributing/samples/
|
||||
```
|
||||
|
||||
### Example Interactions
|
||||
|
||||
Once both services are running, you can interact with the root agent:
|
||||
|
||||
**YouTube Search (No Authentication Required):**
|
||||
|
||||
```
|
||||
User: Search for 3 Taylor Swift music videos
|
||||
Agent: I'll help you search for Taylor Swift music videos on YouTube.
|
||||
[Agent delegates to YouTube Search Agent]
|
||||
Agent: I found 3 Taylor Swift music videos:
|
||||
1. "Anti-Hero" - Official Music Video
|
||||
2. "Shake It Off" - Official Music Video
|
||||
3. "Blank Space" - Official Music Video
|
||||
```
|
||||
|
||||
**BigQuery Operations (OAuth Required):**
|
||||
|
||||
```
|
||||
User: List my BigQuery datasets
|
||||
Agent: I'll help you access your BigQuery datasets. This requires authentication with your Google account.
|
||||
[Agent delegates to BigQuery Agent]
|
||||
Agent: To access your BigQuery data, please complete the OAuth authentication.
|
||||
[OAuth flow initiated - user redirected to Google authentication]
|
||||
User: [Completes OAuth flow in browser]
|
||||
Agent: Authentication successful! Here are your BigQuery datasets:
|
||||
- dataset_1: Customer Analytics
|
||||
- dataset_2: Sales Data
|
||||
- dataset_3: Marketing Metrics
|
||||
```
|
||||
|
||||
**Dataset Management:**
|
||||
|
||||
```
|
||||
User: Show me details for my Customer Analytics dataset
|
||||
Agent: I'll get the details for your Customer Analytics dataset.
|
||||
[Using existing OAuth token]
|
||||
Agent: Customer Analytics Dataset Details:
|
||||
- Created: 2024-01-15
|
||||
- Location: US
|
||||
- Tables: 5
|
||||
- Description: Customer behavior and analytics data
|
||||
```
|
||||
|
||||
## Code Structure
|
||||
|
||||
### Main Agent (`agent.py`)
|
||||
|
||||
- **`youtube_search_agent`**: Local agent with LangChain YouTube search tool
|
||||
- **`bigquery_agent`**: Remote A2A agent configuration for BigQuery operations
|
||||
- **`root_agent`**: Main orchestrator with task delegation logic
|
||||
|
||||
### Remote BigQuery Agent (`remote_a2a/bigquery_agent/`)
|
||||
|
||||
- **`agent.py`**: Implementation of the BigQuery agent with OAuth toolset
|
||||
- **`agent.json`**: Agent card of the A2A agent
|
||||
- **`BigQueryToolset`**: OAuth-enabled tools for BigQuery dataset and table management
|
||||
|
||||
## OAuth Authentication Workflow
|
||||
|
||||
The OAuth authentication process follows this pattern:
|
||||
|
||||
1. **Initial Request**: User requests BigQuery operation through root agent
|
||||
1. **Delegation**: Root agent delegates to remote BigQuery agent
|
||||
1. **Auth Check**: BigQuery agent checks for valid OAuth token
|
||||
1. **Auth Request**: If no token, agent surfaces OAuth request to root agent
|
||||
1. **User OAuth**: Root agent guides user through Google OAuth flow
|
||||
1. **Token Exchange**: Root agent sends OAuth token to BigQuery agent
|
||||
1. **API Call**: BigQuery agent uses token to make authenticated API calls
|
||||
1. **Result Return**: BigQuery agent returns results through root agent to user
|
||||
|
||||
## Supported BigQuery Operations
|
||||
|
||||
The BigQuery agent supports the following operations:
|
||||
|
||||
### Dataset Operations:
|
||||
|
||||
- **List Datasets**: `bigquery_datasets_list` - Get all user's datasets
|
||||
- **Get Dataset**: `bigquery_datasets_get` - Get specific dataset details
|
||||
- **Create Dataset**: `bigquery_datasets_insert` - Create new dataset
|
||||
|
||||
### Table Operations:
|
||||
|
||||
- **List Tables**: `bigquery_tables_list` - Get tables in a dataset
|
||||
- **Get Table**: `bigquery_tables_get` - Get specific table details
|
||||
- **Create Table**: `bigquery_tables_insert` - Create new table in dataset
|
||||
|
||||
## Extending the Sample
|
||||
|
||||
You can extend this sample by:
|
||||
|
||||
- Adding more Google Cloud services (Cloud Storage, Compute Engine, etc.)
|
||||
- Implementing token refresh and expiration handling
|
||||
- Adding role-based access control for different BigQuery operations
|
||||
- Creating OAuth flows for other providers (Microsoft, Facebook, etc.)
|
||||
- Adding audit logging for authentication events
|
||||
- Implementing multi-tenant OAuth token management
|
||||
|
||||
## Deployment to Other Environments
|
||||
|
||||
When deploying the remote BigQuery A2A agent to different environments (e.g., Cloud Run, different hosts/ports), you **must** update the `url` field in the agent card JSON file:
|
||||
|
||||
### Local Development
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "http://localhost:8001/a2a/bigquery_agent",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Cloud Run Example
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://your-bigquery-service-abc123-uc.a.run.app/a2a/bigquery_agent",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Host/Port Example
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://your-domain.com:9000/a2a/bigquery_agent",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** The `url` field in `remote_a2a/bigquery_agent/agent.json` must point to the actual RPC endpoint where your remote BigQuery A2A agent is deployed and accessible.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Connection Issues:**
|
||||
|
||||
- Ensure the local ADK web server is running on port 8000
|
||||
- Ensure the remote A2A server is running on port 8001
|
||||
- Check that no firewall is blocking localhost connections
|
||||
- **Verify the `url` field in `remote_a2a/bigquery_agent/agent.json` matches the actual deployed location of your remote A2A server**
|
||||
- Verify the agent card URL passed to RemoteA2AAgent constructor matches the running A2A server
|
||||
|
||||
**OAuth Issues:**
|
||||
|
||||
- Verify OAuth client ID and secret are correctly set in .env file
|
||||
- Ensure OAuth redirect URIs are properly configured in Google Cloud Console
|
||||
- Check that the OAuth scopes include BigQuery access permissions
|
||||
- Verify the user has access to the BigQuery projects/datasets
|
||||
|
||||
**BigQuery Access Issues:**
|
||||
|
||||
- Ensure the authenticated user has BigQuery permissions
|
||||
- Check that the Google Cloud project has BigQuery API enabled
|
||||
- Verify dataset and table names are correct and accessible
|
||||
- Check for quota limits on BigQuery API calls
|
||||
|
||||
**Agent Communication Issues:**
|
||||
|
||||
- Check the logs for both the local ADK web server and remote A2A server
|
||||
- Verify OAuth tokens are properly passed between agents
|
||||
- Ensure agent instructions are clear about authentication requirements
|
||||
- **Double-check that the RPC URL in the agent.json file is correct and accessible**
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,61 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
from google.adk.agents.remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH
|
||||
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
|
||||
from google.adk.tools.langchain_tool import LangchainTool
|
||||
from langchain_community.tools.youtube.search import YouTubeSearchTool
|
||||
|
||||
# Instantiate the tool
|
||||
langchain_yt_tool = YouTubeSearchTool()
|
||||
|
||||
# Wrap the tool in the LangchainTool class from ADK
|
||||
adk_yt_tool = LangchainTool(
|
||||
tool=langchain_yt_tool,
|
||||
)
|
||||
|
||||
youtube_search_agent = Agent(
|
||||
name="youtube_search_agent",
|
||||
instruction="""
|
||||
Ask customer to provide singer name, and the number of videos to search.
|
||||
""",
|
||||
description="Help customer to search for a video on Youtube.",
|
||||
tools=[adk_yt_tool],
|
||||
output_key="youtube_search_output",
|
||||
)
|
||||
|
||||
bigquery_agent = RemoteA2aAgent(
|
||||
name="bigquery_agent",
|
||||
description="Help customer to manage notion workspace.",
|
||||
agent_card=(
|
||||
f"http://localhost:8001/a2a/bigquery_agent{AGENT_CARD_WELL_KNOWN_PATH}"
|
||||
),
|
||||
)
|
||||
|
||||
root_agent = Agent(
|
||||
name="root_agent",
|
||||
instruction="""
|
||||
You are a helpful assistant that can help search youtube videos, look up BigQuery datasets and tables.
|
||||
You delegate youtube search tasks to the youtube_search_agent.
|
||||
You delegate BigQuery tasks to the bigquery_agent.
|
||||
Always clarify the results before proceeding.
|
||||
""",
|
||||
global_instruction=(
|
||||
"You are a helpful assistant that can help search youtube videos, look"
|
||||
" up BigQuery datasets and tables."
|
||||
),
|
||||
sub_agents=[youtube_search_agent, bigquery_agent],
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,45 @@
|
||||
{
|
||||
"capabilities": {},
|
||||
"defaultInputModes": [
|
||||
"text/plain"
|
||||
],
|
||||
"defaultOutputModes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "A Google BigQuery agent that helps manage users' data on Google BigQuery. Can list, get, and create datasets, as well as manage tables within datasets. Supports OAuth authentication for secure access to BigQuery resources.",
|
||||
"name": "bigquery_agent",
|
||||
"skills": [
|
||||
{
|
||||
"id": "dataset_management",
|
||||
"name": "Dataset Management",
|
||||
"description": "List, get details, and create BigQuery datasets",
|
||||
"tags": [
|
||||
"bigquery",
|
||||
"datasets",
|
||||
"google-cloud"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "table_management",
|
||||
"name": "Table Management",
|
||||
"description": "List, get details, and create BigQuery tables within datasets",
|
||||
"tags": [
|
||||
"bigquery",
|
||||
"tables",
|
||||
"google-cloud"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "oauth_authentication",
|
||||
"name": "OAuth Authentication",
|
||||
"description": "Secure authentication with Google BigQuery using OAuth",
|
||||
"tags": [
|
||||
"authentication",
|
||||
"oauth",
|
||||
"security"
|
||||
]
|
||||
}
|
||||
],
|
||||
"url": "http://localhost:8001/a2a/bigquery_agent",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# Copyright 2026 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
|
||||
from google.adk import Agent
|
||||
from google.adk.tools.google_api_tool import BigQueryToolset
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
# Access the variable
|
||||
oauth_client_id = os.getenv("OAUTH_CLIENT_ID")
|
||||
oauth_client_secret = os.getenv("OAUTH_CLIENT_SECRET")
|
||||
tools_to_expose = [
|
||||
"bigquery_datasets_list",
|
||||
"bigquery_datasets_get",
|
||||
"bigquery_datasets_insert",
|
||||
"bigquery_tables_list",
|
||||
"bigquery_tables_get",
|
||||
"bigquery_tables_insert",
|
||||
]
|
||||
bigquery_toolset = BigQueryToolset(
|
||||
client_id=oauth_client_id,
|
||||
client_secret=oauth_client_secret,
|
||||
tool_filter=tools_to_expose,
|
||||
)
|
||||
|
||||
root_agent = Agent(
|
||||
name="bigquery_agent",
|
||||
instruction="""
|
||||
You are a helpful Google BigQuery agent that help to manage users' data on Google BigQuery.
|
||||
Use the provided tools to conduct various operations on users' data in Google BigQuery.
|
||||
|
||||
Scenario 1:
|
||||
The user wants to query their bigquery datasets
|
||||
Use bigquery_datasets_list to query user's datasets
|
||||
|
||||
Scenario 2:
|
||||
The user wants to query the details of a specific dataset
|
||||
Use bigquery_datasets_get to get a dataset's details
|
||||
|
||||
Scenario 3:
|
||||
The user wants to create a new dataset
|
||||
Use bigquery_datasets_insert to create a new dataset
|
||||
|
||||
Scenario 4:
|
||||
The user wants to query their tables in a specific dataset
|
||||
Use bigquery_tables_list to list all tables in a dataset
|
||||
|
||||
Scenario 5:
|
||||
The user wants to query the details of a specific table
|
||||
Use bigquery_tables_get to get a table's details
|
||||
|
||||
Scenario 6:
|
||||
The user wants to insert a new table into a dataset
|
||||
Use bigquery_tables_insert to insert a new table into a dataset
|
||||
|
||||
Current user:
|
||||
<User>
|
||||
{userInfo?}
|
||||
</User>
|
||||
""",
|
||||
tools=[bigquery_toolset],
|
||||
)
|
||||
@@ -0,0 +1,165 @@
|
||||
# A2A Basic Sample Agent
|
||||
|
||||
This sample demonstrates the **Agent-to-Agent (A2A)** architecture in the Agent Development Kit (ADK), showcasing how multiple agents can work together to handle complex tasks. The sample implements an agent that can roll dice and check if numbers are prime.
|
||||
|
||||
## Overview
|
||||
|
||||
The A2A Basic sample consists of:
|
||||
|
||||
- **Root Agent** (`root_agent`): The main orchestrator that delegates tasks to specialized sub-agents
|
||||
- **Roll Agent** (`roll_agent`): A local sub-agent that handles dice rolling operations
|
||||
- **Prime Agent** (`prime_agent`): A remote A2A agent that checks if numbers are prime, this agent is running on a separate A2A server
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌────────────────────┐
|
||||
│ Root Agent │───▶│ Roll Agent │ │ Remote Prime │
|
||||
│ (Local) │ │ (Local) │ │ Agent │
|
||||
│ │ │ │ │ (localhost:8001) │
|
||||
│ │───▶│ │◀───│ │
|
||||
└─────────────────┘ └──────────────────┘ └────────────────────┘
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. **Local Sub-Agent Integration**
|
||||
|
||||
- The `roll_agent` demonstrates how to create and integrate local sub-agents
|
||||
- Handles dice rolling with configurable number of sides
|
||||
- Uses a simple function tool (`roll_die`) for random number generation
|
||||
|
||||
### 2. **Remote A2A Agent Integration**
|
||||
|
||||
- The `prime_agent` shows how to connect to remote agent services
|
||||
- Communicates with a separate service via HTTP at `http://localhost:8001/a2a/check_prime_agent`
|
||||
- Demonstrates cross-service agent communication
|
||||
|
||||
### 3. **Agent Orchestration**
|
||||
|
||||
- The root agent intelligently delegates tasks based on user requests
|
||||
- Can chain operations (e.g., "roll a die and check if it's prime")
|
||||
- Provides clear workflow coordination between multiple agents
|
||||
|
||||
### 4. **Example Tool Integration**
|
||||
|
||||
- Includes an `ExampleTool` with sample interactions for context
|
||||
- Helps the agent understand expected behavior patterns
|
||||
|
||||
## Setup and Usage
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Start the Remote Prime Agent server**:
|
||||
|
||||
```bash
|
||||
# Start the remote a2a server that serves the check prime agent on port 8001
|
||||
adk api_server --a2a --port 8001 contributing/samples/a2a_basic/remote_a2a
|
||||
```
|
||||
|
||||
1. **Run the Main Agent**:
|
||||
|
||||
```bash
|
||||
# In a separate terminal, run the adk web server
|
||||
adk web contributing/samples/
|
||||
```
|
||||
|
||||
### Example Interactions
|
||||
|
||||
Once both services are running, you can interact with the root agent:
|
||||
|
||||
**Simple Dice Rolling:**
|
||||
|
||||
```
|
||||
User: Roll a 6-sided die
|
||||
Bot: I rolled a 4 for you.
|
||||
```
|
||||
|
||||
**Prime Number Checking:**
|
||||
|
||||
```
|
||||
User: Is 7 a prime number?
|
||||
Bot: Yes, 7 is a prime number.
|
||||
```
|
||||
|
||||
**Combined Operations:**
|
||||
|
||||
```
|
||||
User: Roll a 10-sided die and check if it's prime
|
||||
Bot: I rolled an 8 for you.
|
||||
Bot: 8 is not a prime number.
|
||||
```
|
||||
|
||||
## Code Structure
|
||||
|
||||
### Main Agent (`agent.py`)
|
||||
|
||||
- **`roll_die(sides: int)`**: Function tool for rolling dice
|
||||
- **`roll_agent`**: Local agent specialized in dice rolling
|
||||
- **`prime_agent`**: Remote A2A agent configuration
|
||||
- **`root_agent`**: Main orchestrator with delegation logic
|
||||
|
||||
### Remote Prime Agent (`remote_a2a/check_prime_agent/`)
|
||||
|
||||
- **`agent.py`**: Implementation of the prime checking service
|
||||
- **`agent.json`**: Agent card of the A2A agent
|
||||
- **`check_prime(nums: list[int])`**: Prime number checking algorithm
|
||||
|
||||
## Extending the Sample
|
||||
|
||||
You can extend this sample by:
|
||||
|
||||
- Adding more mathematical operations (factorization, square roots, etc.)
|
||||
- Creating additional remote agent
|
||||
- Implementing more complex delegation logic
|
||||
- Adding persistent state management
|
||||
- Integrating with external APIs or databases
|
||||
|
||||
## Deployment to Other Environments
|
||||
|
||||
When deploying the remote A2A agent to different environments (e.g., Cloud Run, different hosts/ports), you **must** update the `url` field in the agent card JSON file:
|
||||
|
||||
### Local Development
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "http://localhost:8001/a2a/check_prime_agent",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Cloud Run Example
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://your-service-abc123-uc.a.run.app/a2a/check_prime_agent",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Host/Port Example
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://your-domain.com:9000/a2a/check_prime_agent",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** The `url` field in `remote_a2a/check_prime_agent/agent.json` must point to the actual RPC endpoint where your remote A2A agent is deployed and accessible.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Connection Issues:**
|
||||
|
||||
- Ensure the local ADK web server is running on port 8000
|
||||
- Ensure the remote A2A server is running on port 8001
|
||||
- Check that no firewall is blocking localhost connections
|
||||
- **Verify the `url` field in `remote_a2a/check_prime_agent/agent.json` matches the actual deployed location of your remote A2A server**
|
||||
- Verify the agent card URL passed to RemoteA2AAgent constructor matches the running A2A server
|
||||
|
||||
**Agent Not Responding:**
|
||||
|
||||
- Check the logs for both the local ADK web server on port 8000 and remote A2A server on port 8001
|
||||
- Verify the agent instructions are clear and unambiguous
|
||||
- **Double-check that the RPC URL in the agent.json file is correct and accessible**
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
# Copyright 2026 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 random
|
||||
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
from google.adk.agents.remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH
|
||||
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
|
||||
from google.adk.tools.example_tool import ExampleTool
|
||||
from google.genai import types
|
||||
|
||||
|
||||
# --- Roll Die Sub-Agent ---
|
||||
def roll_die(sides: int) -> int:
|
||||
"""Roll a die and return the rolled result."""
|
||||
return random.randint(1, sides)
|
||||
|
||||
|
||||
roll_agent = Agent(
|
||||
name="roll_agent",
|
||||
description="Handles rolling dice of different sizes.",
|
||||
instruction="""
|
||||
You are responsible for rolling dice based on the user's request.
|
||||
When asked to roll a die, you must call the roll_die tool with the number of sides as an integer.
|
||||
""",
|
||||
tools=[roll_die],
|
||||
generate_content_config=types.GenerateContentConfig(
|
||||
safety_settings=[
|
||||
types.SafetySetting( # avoid false alarm about rolling dice.
|
||||
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||
threshold=types.HarmBlockThreshold.OFF,
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
example_tool = ExampleTool([
|
||||
{
|
||||
"input": {
|
||||
"role": "user",
|
||||
"parts": [{"text": "Roll a 6-sided die."}],
|
||||
},
|
||||
"output": [
|
||||
{"role": "model", "parts": [{"text": "I rolled a 4 for you."}]}
|
||||
],
|
||||
},
|
||||
{
|
||||
"input": {
|
||||
"role": "user",
|
||||
"parts": [{"text": "Is 7 a prime number?"}],
|
||||
},
|
||||
"output": [{
|
||||
"role": "model",
|
||||
"parts": [{"text": "Yes, 7 is a prime number."}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
"input": {
|
||||
"role": "user",
|
||||
"parts": [{"text": "Roll a 10-sided die and check if it's prime."}],
|
||||
},
|
||||
"output": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [{"text": "I rolled an 8 for you."}],
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [{"text": "8 is not a prime number."}],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
prime_agent = RemoteA2aAgent(
|
||||
name="prime_agent",
|
||||
description="Agent that handles checking if numbers are prime.",
|
||||
agent_card=(
|
||||
f"http://localhost:8001/a2a/check_prime_agent{AGENT_CARD_WELL_KNOWN_PATH}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
name="root_agent",
|
||||
instruction="""
|
||||
You are a helpful assistant that can roll dice and check if numbers are prime.
|
||||
You delegate rolling dice tasks to the roll_agent and prime checking tasks to the prime_agent.
|
||||
Follow these steps:
|
||||
1. If the user asks to roll a die, delegate to the roll_agent.
|
||||
2. If the user asks to check primes, delegate to the prime_agent.
|
||||
3. If the user asks to roll a die and then check if the result is prime, call roll_agent first, then pass the result to prime_agent.
|
||||
Always clarify the results before proceeding.
|
||||
""",
|
||||
global_instruction=(
|
||||
"You are DicePrimeBot, ready to roll dice and check prime numbers."
|
||||
),
|
||||
sub_agents=[roll_agent, prime_agent],
|
||||
tools=[example_tool],
|
||||
generate_content_config=types.GenerateContentConfig(
|
||||
safety_settings=[
|
||||
types.SafetySetting( # avoid false alarm about rolling dice.
|
||||
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||
threshold=types.HarmBlockThreshold.OFF,
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,26 @@
|
||||
{
|
||||
"capabilities": {},
|
||||
"defaultInputModes": [
|
||||
"text/plain"
|
||||
],
|
||||
"defaultOutputModes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "An agent specialized in checking whether numbers are prime. It can efficiently determine the primality of individual numbers or lists of numbers.",
|
||||
"name": "check_prime_agent",
|
||||
"skills": [
|
||||
{
|
||||
"id": "prime_checking",
|
||||
"name": "Prime Number Checking",
|
||||
"description": "Check if numbers in a list are prime using efficient mathematical algorithms",
|
||||
"tags": [
|
||||
"mathematical",
|
||||
"computation",
|
||||
"prime",
|
||||
"numbers"
|
||||
]
|
||||
}
|
||||
],
|
||||
"url": "http://localhost:8001/a2a/check_prime_agent",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# Copyright 2026 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 random
|
||||
|
||||
from google.adk import Agent
|
||||
from google.adk.tools.tool_context import ToolContext
|
||||
from google.genai import types
|
||||
|
||||
|
||||
async def check_prime(nums: list[int]) -> str:
|
||||
"""Check if a given list of numbers are prime.
|
||||
|
||||
Args:
|
||||
nums: The list of numbers to check.
|
||||
|
||||
Returns:
|
||||
A str indicating which number is prime.
|
||||
"""
|
||||
primes = set()
|
||||
for number in nums:
|
||||
number = int(number)
|
||||
if number <= 1:
|
||||
continue
|
||||
is_prime = True
|
||||
for i in range(2, int(number**0.5) + 1):
|
||||
if number % i == 0:
|
||||
is_prime = False
|
||||
break
|
||||
if is_prime:
|
||||
primes.add(number)
|
||||
return (
|
||||
'No prime numbers found.'
|
||||
if not primes
|
||||
else f"{', '.join(str(num) for num in primes)} are prime numbers."
|
||||
)
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
name='check_prime_agent',
|
||||
description='check prime agent that can check whether numbers are prime.',
|
||||
instruction="""
|
||||
You check whether numbers are prime.
|
||||
When checking prime numbers, call the check_prime tool with a list of integers. Be sure to pass in a list of integers. You should never pass in a string.
|
||||
You should not rely on the previous history on prime results.
|
||||
""",
|
||||
tools=[
|
||||
check_prime,
|
||||
],
|
||||
# planner=BuiltInPlanner(
|
||||
# thinking_config=types.ThinkingConfig(
|
||||
# include_thoughts=True,
|
||||
# ),
|
||||
# ),
|
||||
generate_content_config=types.GenerateContentConfig(
|
||||
safety_settings=[
|
||||
types.SafetySetting( # avoid false alarm about rolling dice.
|
||||
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||
threshold=types.HarmBlockThreshold.OFF,
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
# A2A Human-in-the-Loop Sample Agent
|
||||
|
||||
This sample demonstrates the **Agent-to-Agent (A2A)** architecture with **Human-in-the-Loop** workflows in the Agent Development Kit (ADK). The sample implements a reimbursement processing agent that automatically handles small expenses while requiring remote agent to process for larger amounts. The remote agent will require a human approval for large amounts, thus surface this request to local agent and human interacting with local agent can approve the request.
|
||||
|
||||
## Overview
|
||||
|
||||
The A2A Human-in-the-Loop sample consists of:
|
||||
|
||||
- **Root Agent** (`root_agent`): The main reimbursement agent that handles expense requests and delegates approval to remote Approval Agent for large amounts
|
||||
- **Approval Agent** (`approval_agent`): A remote A2A agent that handles the human approval process via long-running tools (which implements asynchronous approval workflows that can pause execution and wait for human input), this agent is running on a separate A2A server
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌────────────────────┐ ┌──────────────────┐
|
||||
│ Human Manager │───▶│ Root Agent │───▶│ Approval Agent │
|
||||
│ (External) │ │ (Local) │ │ (Remote A2A) │
|
||||
│ │ │ │ │ (localhost:8001) │
|
||||
│ Approval UI │◀───│ │◀───│ │
|
||||
└─────────────────┘ └────────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. **Automated Decision Making**
|
||||
|
||||
- Automatically approves reimbursements under $100
|
||||
- Uses business logic to determine when human intervention is required
|
||||
- Provides immediate responses for simple cases
|
||||
|
||||
### 2. **Human-in-the-Loop Workflow**
|
||||
|
||||
- Seamlessly escalates high-value requests (>$100) to remote approval agent
|
||||
- Remote approval agent uses long-running tools to surface approval requests back to the root agent
|
||||
- Human managers interact directly with the root agent to approve/reject requests
|
||||
|
||||
### 3. **Long-Running Tool Integration**
|
||||
|
||||
- Demonstrates `LongRunningFunctionTool` for asynchronous operations
|
||||
- Shows how to handle pending states and external updates
|
||||
- Implements proper tool response handling for delayed approvals
|
||||
|
||||
### 4. **Remote A2A Agent Communication**
|
||||
|
||||
- The approval agent runs as a separate service that processes approval workflows
|
||||
- Communicates via HTTP at `http://localhost:8001/a2a/human_in_loop`
|
||||
- Surfaces approval requests back to the root agent for human interaction
|
||||
|
||||
## Setup and Usage
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Start the Remote Approval Agent server**:
|
||||
|
||||
```bash
|
||||
# Start the remote a2a server that serves the human-in-the-loop approval agent on port 8001
|
||||
adk api_server --a2a --port 8001 contributing/samples/a2a/a2a_human_in_loop/remote_a2a
|
||||
```
|
||||
|
||||
1. **Run the Main Agent**:
|
||||
|
||||
```bash
|
||||
# In a separate terminal, run the adk web server
|
||||
adk web contributing/samples/a2a
|
||||
```
|
||||
|
||||
### Example Interactions
|
||||
|
||||
Once both services are running, you can interact with the root agent through the approval workflow:
|
||||
|
||||
**Automatic Approval (Under $100):**
|
||||
|
||||
```
|
||||
User: Please reimburse $50 for meals
|
||||
Agent: I'll process your reimbursement request for $50 for meals. Since this amount is under $100, I can approve it automatically.
|
||||
Agent: ✅ Reimbursement approved and processed: $50 for meals
|
||||
```
|
||||
|
||||
**Human Approval Required (Over $100):**
|
||||
|
||||
```
|
||||
User: Please reimburse $200 for conference travel
|
||||
Agent: I'll process your reimbursement request for $200 for conference travel. Since this amount exceeds $100, I need to get manager approval.
|
||||
Agent: 🔄 Request submitted for approval (Ticket: reimbursement-ticket-001). Please wait for manager review.
|
||||
[Human manager approves the pending request from the ADK Web UI]
|
||||
Agent: ✅ Great news! Your reimbursement has been approved by the manager. Processing $200 for conference travel.
|
||||
```
|
||||
|
||||
> **Approving from the ADK Web UI:** The approval is a *long-running tool* call
|
||||
> that runs on the remote approval agent. The pending call is surfaced in the
|
||||
> Web UI as a function call awaiting a response. To approve (or reject), hover
|
||||
> over the pending `ask_for_approval` function response in the UI and use
|
||||
> **"Send another response"** to send back an updated response such as
|
||||
> `{"status": "approved", "ticketId": "reimbursement-ticket-001"}`. Simply
|
||||
> typing "I approve" as a chat message will **not** resume the pending request,
|
||||
> because the framework needs a `FunctionResponse` that carries the same call
|
||||
> `id` to resume the long-running tool.
|
||||
>
|
||||
> For this resume to be routed back to the remote approval agent (rather than
|
||||
> restarting at the root agent), the sample is exposed as an `App` with
|
||||
> `ResumabilityConfig(is_resumable=True)` in `agent.py`.
|
||||
|
||||
## Code Structure
|
||||
|
||||
### Main Agent (`agent.py`)
|
||||
|
||||
- **`reimburse(purpose: str, amount: float)`**: Function tool for processing reimbursements
|
||||
- **`approval_agent`**: Remote A2A agent configuration for human approval workflows
|
||||
- **`root_agent`**: Main reimbursement agent with automatic/manual approval logic
|
||||
|
||||
### Remote Approval Agent (`remote_a2a/human_in_loop/`)
|
||||
|
||||
- **`agent.py`**: Implementation of the approval agent with long-running tools
|
||||
|
||||
- **`agent.json`**: Agent card of the A2A agent
|
||||
|
||||
- **`ask_for_approval()`**: Long-running tool that handles approval requests
|
||||
|
||||
## Long-Running Tool Workflow
|
||||
|
||||
The human-in-the-loop process follows this pattern:
|
||||
|
||||
1. **Initial Call**: Root agent delegates approval request to remote approval agent for amounts >$100
|
||||
1. **Pending Response**: Remote approval agent returns immediate response with `status: "pending"` and ticket ID and surface the approval request to root agent
|
||||
1. **Agent Acknowledgment**: Root agent informs user about pending approval status
|
||||
1. **Human Interaction**: Human manager interacts with root agent to review and approve/reject the request
|
||||
1. **Updated Response**: Root agent receives updated tool response with approval decision and send it to remote agent
|
||||
1. **Final Action**: Remote agent processes the approval and completes the reimbursement and send the result to root_agent
|
||||
|
||||
## Extending the Sample
|
||||
|
||||
You can extend this sample by:
|
||||
|
||||
- Adding more complex approval hierarchies (multiple approval levels)
|
||||
- Implementing different approval rules based on expense categories
|
||||
- Creating additional remote agent for budget checking or policy validation
|
||||
- Adding notification systems for approval status updates
|
||||
- Integrating with external approval systems or databases
|
||||
- Implementing approval timeouts and escalation procedures
|
||||
|
||||
## Deployment to Other Environments
|
||||
|
||||
When deploying the remote approval A2A agent to different environments (e.g., Cloud Run, different hosts/ports), you **must** update the `url` field in the agent card JSON file:
|
||||
|
||||
### Local Development
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "http://localhost:8001/a2a/human_in_loop",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Cloud Run Example
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://your-approval-service-abc123-uc.a.run.app/a2a/human_in_loop",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Host/Port Example
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://your-domain.com:9000/a2a/human_in_loop",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** The `url` field in `remote_a2a/human_in_loop/agent.json` must point to the actual RPC endpoint where your remote approval A2A agent is deployed and accessible.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Connection Issues:**
|
||||
|
||||
- Ensure the local ADK web server is running on port 8000
|
||||
- Ensure the remote A2A server is running on port 8001
|
||||
- Check that no firewall is blocking localhost connections
|
||||
- **Verify the `url` field in `remote_a2a/human_in_loop/agent.json` matches the actual deployed location of your remote A2A server**
|
||||
- Verify the agent card URL passed to RemoteA2AAgent constructor matches the running A2A server
|
||||
|
||||
**Agent Not Responding:**
|
||||
|
||||
- Check the logs for both the local ADK web server on port 8000 and remote A2A server on port 8001
|
||||
- Verify the agent instructions are clear and unambiguous
|
||||
- Ensure long-running tool responses are properly formatted with matching IDs
|
||||
- **Double-check that the RPC URL in the agent.json file is correct and accessible**
|
||||
|
||||
**Approval Workflow Issues:**
|
||||
|
||||
- Verify that updated tool responses use the same `id` and `name` as the original function call
|
||||
- Check that the approval status is correctly updated in the tool response
|
||||
- Ensure the human approval process is properly simulated or integrated
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,68 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
from google.adk.agents.remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH
|
||||
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
|
||||
from google.adk.apps import App
|
||||
from google.adk.apps import ResumabilityConfig
|
||||
from google.genai import types
|
||||
|
||||
|
||||
def reimburse(purpose: str, amount: float) -> str:
|
||||
"""Reimburse the amount of money to the employee."""
|
||||
return {
|
||||
'status': 'ok',
|
||||
}
|
||||
|
||||
|
||||
approval_agent = RemoteA2aAgent(
|
||||
name='approval_agent',
|
||||
description='Help approve the reimburse if the amount is greater than 100.',
|
||||
agent_card=(
|
||||
f'http://localhost:8001/a2a/human_in_loop{AGENT_CARD_WELL_KNOWN_PATH}'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
name='reimbursement_agent',
|
||||
instruction="""
|
||||
You are an agent whose job is to handle the reimbursement process for
|
||||
the employees. If the amount is less than $100, you will automatically
|
||||
approve the reimbursement. And call reimburse() to reimburse the amount to the employee.
|
||||
|
||||
If the amount is greater than $100. You will hand over the request to
|
||||
approval_agent to handle the reimburse.
|
||||
""",
|
||||
tools=[reimburse],
|
||||
sub_agents=[approval_agent],
|
||||
generate_content_config=types.GenerateContentConfig(temperature=0.1),
|
||||
)
|
||||
|
||||
# The human-in-the-loop approval runs as a long-running tool on the remote
|
||||
# approval_agent. When the manager approves (or rejects) the request, the ADK
|
||||
# Web UI sends back a FunctionResponse for that pending long-running call. For
|
||||
# the next turn to be routed back to the (remote) approval_agent so it can
|
||||
# resume the paused tool instead of restarting at the root reimbursement_agent,
|
||||
# the app must be resumable. Without this, the confirmation is delivered to the
|
||||
# root agent, which has no pending call, and nothing happens (see issue #5871).
|
||||
app = App(
|
||||
name='a2a_human_in_loop',
|
||||
root_agent=root_agent,
|
||||
resumability_config=ResumabilityConfig(
|
||||
is_resumable=True,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,45 @@
|
||||
{
|
||||
"capabilities": {},
|
||||
"defaultInputModes": [
|
||||
"text/plain"
|
||||
],
|
||||
"defaultOutputModes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "A reimbursement agent that handles employee expense reimbursement requests. Automatically approves amounts under $100 and requires manager approval for larger amounts using long-running tools for human-in-the-loop workflows.",
|
||||
"name": "reimbursement_agent",
|
||||
"skills": [
|
||||
{
|
||||
"id": "automatic_reimbursement",
|
||||
"name": "Automatic Reimbursement",
|
||||
"description": "Automatically process and approve reimbursements under $100",
|
||||
"tags": [
|
||||
"reimbursement",
|
||||
"automation",
|
||||
"finance"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "approval_workflow",
|
||||
"name": "Approval Workflow",
|
||||
"description": "Request manager approval for reimbursements over $100 using long-running tools",
|
||||
"tags": [
|
||||
"approval",
|
||||
"workflow",
|
||||
"human-in-loop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "expense_processing",
|
||||
"name": "Expense Processing",
|
||||
"description": "Process employee expense claims and handle reimbursement logic",
|
||||
"tags": [
|
||||
"expenses",
|
||||
"processing",
|
||||
"employee-services"
|
||||
]
|
||||
}
|
||||
],
|
||||
"url": "http://localhost:8001/a2a/human_in_loop",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Copyright 2026 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 google.adk import Agent
|
||||
from google.adk.tools.long_running_tool import LongRunningFunctionTool
|
||||
from google.adk.tools.tool_context import ToolContext
|
||||
from google.genai import types
|
||||
|
||||
|
||||
def reimburse(purpose: str, amount: float) -> str:
|
||||
"""Reimburse the amount of money to the employee."""
|
||||
return {
|
||||
'status': 'ok',
|
||||
}
|
||||
|
||||
|
||||
def ask_for_approval(
|
||||
purpose: str, amount: float, tool_context: ToolContext
|
||||
) -> dict[str, Any]:
|
||||
"""Ask for approval for the reimbursement."""
|
||||
return {
|
||||
'status': 'pending',
|
||||
'amount': amount,
|
||||
'ticketId': 'reimbursement-ticket-001',
|
||||
}
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
name='reimbursement_agent',
|
||||
instruction="""
|
||||
You are an agent whose job is to handle the reimbursement process for
|
||||
the employees. If the amount is less than $100, you will automatically
|
||||
approve the reimbursement.
|
||||
|
||||
If the amount is greater than $100, you will
|
||||
ask for approval from the manager. If the manager approves, you will
|
||||
call reimburse() to reimburse the amount to the employee. If the manager
|
||||
rejects, you will inform the employee of the rejection.
|
||||
""",
|
||||
tools=[reimburse, LongRunningFunctionTool(func=ask_for_approval)],
|
||||
generate_content_config=types.GenerateContentConfig(temperature=0.1),
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
# A2A Root Sample Agent
|
||||
|
||||
This sample demonstrates how to use a **remote Agent-to-Agent (A2A) agent as the root agent** in the Agent Development Kit (ADK). This is a simplified approach where the main agent is actually a remote A2A service, also showcasing how to run remote agents using uvicorn command.
|
||||
|
||||
## Overview
|
||||
|
||||
The A2A Root sample consists of:
|
||||
|
||||
- **Root Agent** (`agent.py`): A remote A2A agent proxy as root agent that talks to a remote a2a agent running on a separate server
|
||||
- **Remote Hello World Agent** (`remote_a2a/hello_world/agent.py`): The actual agent implementation that handles dice rolling and prime number checking running on remote server
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌────────────────────┐
|
||||
│ Root Agent │───▶│ Remote Hello │
|
||||
│ (RemoteA2aAgent)│ │ World Agent │
|
||||
│ (localhost:8000)│ │ (localhost:8001) │
|
||||
└─────────────────┘ └────────────────────┘
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. **Remote A2A as Root Agent**
|
||||
|
||||
- The `root_agent` is a `RemoteA2aAgent` that connects to a remote A2A service
|
||||
- Demonstrates how to use remote agents as the primary agent instead of local agents
|
||||
- Shows the flexibility of the A2A architecture for distributed agent deployment
|
||||
|
||||
### 2. **Uvicorn Server Deployment**
|
||||
|
||||
- The remote agent is served using uvicorn, a lightweight ASGI server
|
||||
- Demonstrates a simple way to deploy A2A agents without using the ADK CLI
|
||||
- Shows how to expose A2A agents as standalone web services
|
||||
|
||||
### 3. **Agent Functionality**
|
||||
|
||||
- **Dice Rolling**: Can roll dice with configurable number of sides
|
||||
- **Prime Number Checking**: Can check if numbers are prime
|
||||
- **State Management**: Maintains roll history in tool context
|
||||
- **Parallel Tool Execution**: Can use multiple tools in parallel
|
||||
|
||||
### 4. **Simple Deployment Pattern**
|
||||
|
||||
- Uses the `to_a2a()` utility to convert a standard ADK agent to an A2A service
|
||||
- Minimal configuration required for remote agent deployment
|
||||
|
||||
## Setup and Usage
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Start the Remote A2A Agent server**:
|
||||
|
||||
```bash
|
||||
# Start the remote agent using uvicorn
|
||||
uvicorn contributing.samples.a2a_root.remote_a2a.hello_world.agent:a2a_app --host localhost --port 8001
|
||||
```
|
||||
|
||||
1. **Run the Main Agent**:
|
||||
|
||||
```bash
|
||||
# In a separate terminal, run the adk web server
|
||||
adk web contributing/samples/
|
||||
```
|
||||
|
||||
### Example Interactions
|
||||
|
||||
Once both services are running, you can interact with the root agent:
|
||||
|
||||
**Simple Dice Rolling:**
|
||||
|
||||
```
|
||||
User: Roll a 6-sided die
|
||||
Bot: I rolled a 4 for you.
|
||||
```
|
||||
|
||||
**Prime Number Checking:**
|
||||
|
||||
```
|
||||
User: Is 7 a prime number?
|
||||
Bot: Yes, 7 is a prime number.
|
||||
```
|
||||
|
||||
**Combined Operations:**
|
||||
|
||||
```
|
||||
User: Roll a 10-sided die and check if it's prime
|
||||
Bot: I rolled an 8 for you.
|
||||
Bot: 8 is not a prime number.
|
||||
```
|
||||
|
||||
**Multiple Rolls with Prime Checking:**
|
||||
|
||||
```
|
||||
User: Roll a die 3 times and check which results are prime
|
||||
Bot: I rolled a 3 for you.
|
||||
Bot: I rolled a 7 for you.
|
||||
Bot: I rolled a 4 for you.
|
||||
Bot: 3, 7 are prime numbers.
|
||||
```
|
||||
|
||||
## Code Structure
|
||||
|
||||
### Root Agent (`agent.py`)
|
||||
|
||||
- **`root_agent`**: A `RemoteA2aAgent` that connects to the remote A2A service
|
||||
- **Agent Card URL**: Points to the well-known agent card endpoint on the remote server
|
||||
|
||||
### Remote Hello World Agent (`remote_a2a/hello_world/agent.py`)
|
||||
|
||||
- **`roll_die(sides: int)`**: Function tool for rolling dice with state management
|
||||
- **`check_prime(nums: list[int])`**: Async function for prime number checking
|
||||
- **`root_agent`**: The main agent with comprehensive instructions
|
||||
- **`a2a_app`**: The A2A application created using `to_a2a()` utility
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Connection Issues:**
|
||||
|
||||
- Ensure the uvicorn server is running on port 8001
|
||||
- Check that no firewall is blocking localhost connections
|
||||
- Verify the agent card URL in the root agent configuration
|
||||
- Check uvicorn logs for any startup errors
|
||||
|
||||
**Agent Not Responding:**
|
||||
|
||||
- Check the uvicorn server logs for errors
|
||||
- Verify the agent instructions are clear and unambiguous
|
||||
- Ensure the A2A app is properly configured with the correct port
|
||||
|
||||
**Uvicorn Issues:**
|
||||
|
||||
- Make sure the module path is correct: `contributing.samples.a2a_root.remote_a2a.hello_world.agent:a2a_app`
|
||||
- Check that all dependencies are installed
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from google.adk.agents.remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH
|
||||
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
|
||||
|
||||
root_agent = RemoteA2aAgent(
|
||||
name="hello_world_agent",
|
||||
description=(
|
||||
"Helpful assistant that can roll dice and check if numbers are prime."
|
||||
),
|
||||
agent_card=f"http://localhost:8001/{AGENT_CARD_WELL_KNOWN_PATH}",
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,110 @@
|
||||
# Copyright 2026 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 random
|
||||
|
||||
from google.adk import Agent
|
||||
from google.adk.a2a.utils.agent_to_a2a import to_a2a
|
||||
from google.adk.tools.tool_context import ToolContext
|
||||
from google.genai import types
|
||||
|
||||
|
||||
def roll_die(sides: int, tool_context: ToolContext) -> int:
|
||||
"""Roll a die and return the rolled result.
|
||||
|
||||
Args:
|
||||
sides: The integer number of sides the die has.
|
||||
tool_context: the tool context
|
||||
Returns:
|
||||
An integer of the result of rolling the die.
|
||||
"""
|
||||
result = random.randint(1, sides)
|
||||
if not 'rolls' in tool_context.state:
|
||||
tool_context.state['rolls'] = []
|
||||
|
||||
tool_context.state['rolls'] = tool_context.state['rolls'] + [result]
|
||||
return result
|
||||
|
||||
|
||||
async def check_prime(nums: list[int]) -> str:
|
||||
"""Check if a given list of numbers are prime.
|
||||
|
||||
Args:
|
||||
nums: The list of numbers to check.
|
||||
|
||||
Returns:
|
||||
A str indicating which number is prime.
|
||||
"""
|
||||
primes = set()
|
||||
for number in nums:
|
||||
number = int(number)
|
||||
if number <= 1:
|
||||
continue
|
||||
is_prime = True
|
||||
for i in range(2, int(number**0.5) + 1):
|
||||
if number % i == 0:
|
||||
is_prime = False
|
||||
break
|
||||
if is_prime:
|
||||
primes.add(number)
|
||||
return (
|
||||
'No prime numbers found.'
|
||||
if not primes
|
||||
else f"{', '.join(str(num) for num in primes)} are prime numbers."
|
||||
)
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
name='hello_world_agent',
|
||||
description=(
|
||||
'hello world agent that can roll a dice of 8 sides and check prime'
|
||||
' numbers.'
|
||||
),
|
||||
instruction="""
|
||||
You roll dice and answer questions about the outcome of the dice rolls.
|
||||
You can roll dice of different sizes.
|
||||
You can use multiple tools in parallel by calling functions in parallel(in one request and in one round).
|
||||
It is ok to discuss previous dice roles, and comment on the dice rolls.
|
||||
When you are asked to roll a die, you must call the roll_die tool with the number of sides. Be sure to pass in an integer. Do not pass in a string.
|
||||
You should never roll a die on your own.
|
||||
When checking prime numbers, call the check_prime tool with a list of integers. Be sure to pass in a list of integers. You should never pass in a string.
|
||||
You should not check prime numbers before calling the tool.
|
||||
When you are asked to roll a die and check prime numbers, you should always make the following two function calls:
|
||||
1. You should first call the roll_die tool to get a roll. Wait for the function response before calling the check_prime tool.
|
||||
2. After you get the function response from roll_die tool, you should call the check_prime tool with the roll_die result.
|
||||
2.1 If user asks you to check primes based on previous rolls, make sure you include the previous rolls in the list.
|
||||
3. When you respond, you must include the roll_die result from step 1.
|
||||
You should always perform the previous 3 steps when asking for a roll and checking prime numbers.
|
||||
You should not rely on the previous history on prime results.
|
||||
""",
|
||||
tools=[
|
||||
roll_die,
|
||||
check_prime,
|
||||
],
|
||||
# planner=BuiltInPlanner(
|
||||
# thinking_config=types.ThinkingConfig(
|
||||
# include_thoughts=True,
|
||||
# ),
|
||||
# ),
|
||||
generate_content_config=types.GenerateContentConfig(
|
||||
safety_settings=[
|
||||
types.SafetySetting( # avoid false alarm about rolling dice.
|
||||
category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||
threshold=types.HarmBlockThreshold.OFF,
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
a2a_app = to_a2a(root_agent, port=8001)
|
||||
@@ -0,0 +1,127 @@
|
||||
# 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.
|
||||
- A fully automated GitHub Actions workflow.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## 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 `main.py` script supports batch processing for ADK oncall team to process discussions.
|
||||
|
||||
### Features
|
||||
|
||||
- **Single Discussion**: Process a specific discussion by providing its number.
|
||||
- **Batch Process**: Process the N most recently updated discussions.
|
||||
- **Direct Discussion Data**: Process a discussion using JSON data directly (optimized for GitHub Actions).
|
||||
|
||||
### Running in Batch Script Mode
|
||||
|
||||
To run the agent in batch script mode, first set the required environment variables. Then, execute one of the following commands:
|
||||
|
||||
```bash
|
||||
export PYTHONPATH=contributing/samples
|
||||
|
||||
# Answer a specific discussion
|
||||
python -m adk_answering_agent.main --discussion_number 27
|
||||
|
||||
# Answer the 10 most recent updated discussions
|
||||
python -m adk_answering_agent.main --recent 10
|
||||
|
||||
# Answer a discussion using direct JSON data (saves API calls)
|
||||
python -m adk_answering_agent.main --discussion '{"number": 27, "title": "How to...", "body": "I need help with...", "author": {"login": "username"}}'
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## GitHub Workflow Mode
|
||||
|
||||
The `main.py` script is automatically triggered by GitHub Actions when new discussions are created in the Q&A category. The workflow is configured in `.github/workflows/discussion_answering.yml` and automatically processes discussions using the `--discussion` flag with JSON data from the GitHub event payload.
|
||||
|
||||
### Optimization
|
||||
|
||||
The GitHub Actions workflow passes discussion data directly from `github.event.discussion` using `toJson()`, eliminating the need for additional API calls to fetch discussion information that's already available in the event payload. This makes the workflow faster and more reliable.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Update the Knowledge Base
|
||||
|
||||
The `upload_docs_to_vertex_ai_search.py` is a script to upload ADK related docs to Vertex AI Search datastore to update the knowledge base. It can be executed with the following command in your terminal:
|
||||
|
||||
```bash
|
||||
export PYTHONPATH=contributing/samples # If not already exported
|
||||
python -m adk_answering_agent.upload_docs_to_vertex_ai_search
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
The agent also requires gcloud login:
|
||||
|
||||
```bash
|
||||
gcloud auth application-default login
|
||||
```
|
||||
|
||||
The upload script requires the following additional Python libraries.
|
||||
|
||||
```bash
|
||||
pip install google-cloud-storage google-cloud-discoveryengine
|
||||
```
|
||||
|
||||
### 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_ENTERPRISE=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 full Vertex AI datastore ID for the document store (i.e. knowledge base), with the format of `projects/{project_number}/locations/{location}/collections/{collection}/dataStores/{datastore_id}`.
|
||||
- `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.
|
||||
|
||||
The following environment variables are required to upload the docs to update the knowledge base.
|
||||
|
||||
- `GCS_BUCKET_NAME=YOUR_GCS_BUCKET_NAME`: **(Required)** The name of the GCS bucket to store the documents.
|
||||
- `ADK_DOCS_ROOT_PATH=YOUR_ADK_DOCS_ROOT_PATH`: **(Required)** Path to the root of the downloaded adk-docs repo.
|
||||
- `ADK_PYTHON_ROOT_PATH=YOUR_ADK_PYTHON_ROOT_PATH`: **(Required)** Path to the root of the downloaded adk-python repo.
|
||||
|
||||
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 2026 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,118 @@
|
||||
# Copyright 2026 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 adk_answering_agent.gemini_assistant.agent import root_agent as gemini_assistant_agent
|
||||
from adk_answering_agent.settings import BOT_RESPONSE_LABEL
|
||||
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.tools import add_comment_to_discussion
|
||||
from adk_answering_agent.tools import add_label_to_discussion
|
||||
from adk_answering_agent.tools import convert_gcs_links_to_https
|
||||
from adk_answering_agent.tools import get_discussion_and_comments
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
from google.adk.tools.agent_tool import AgentTool
|
||||
from google.adk.tools.vertex_ai_search_tool import VertexAiSearchTool
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
model="gemini-3.5-flash",
|
||||
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`.
|
||||
|
||||
Here are the steps to help answer GitHub discussions:
|
||||
|
||||
1. **Determine data source**:
|
||||
* If the user has provided complete discussion JSON data in the prompt,
|
||||
use that data directly.
|
||||
* If the user only provided a discussion number, use the
|
||||
`get_discussion_and_comments` tool to fetch the discussion details.
|
||||
|
||||
2. **Analyze the discussion**:
|
||||
* Focus on the latest comment but reference all comments if needed to
|
||||
understand the context.
|
||||
* If there is no comment at all, focus on the discussion title and body.
|
||||
|
||||
3. **Decide whether to respond**:
|
||||
* 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 discussion is asking a question or requesting information.
|
||||
- The discussion is about ADK or related topics.
|
||||
|
||||
4. **Research the answer**:
|
||||
* Use the `VertexAiSearchTool` to find relevant information before answering.
|
||||
* If you need information about Gemini API, ask the `gemini_assistant` agent
|
||||
to provide the information and references.
|
||||
* You can call the `gemini_assistant` agent with multiple queries to find
|
||||
all the relevant information.
|
||||
|
||||
5. **Post the response**:
|
||||
* If you can find relevant information, use the `add_comment_to_discussion`
|
||||
tool to add a comment to the discussion.
|
||||
* If you post a comment, add the label "{BOT_RESPONSE_LABEL}" to the discussion
|
||||
using the `add_label_to_discussion` tool.
|
||||
|
||||
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.
|
||||
* **Be Objective**: your answer should be based on the facts you found in the
|
||||
document store, do not be misled by user's assumptions or user's
|
||||
understanding of ADK.
|
||||
* If you can't find the answer or information in the document store,
|
||||
**do not** respond.
|
||||
* Start with 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.
|
||||
* Please include your justification for your decision in your output
|
||||
to the user who is telling with you.
|
||||
* If you use citation from the document store, please provide a footnote
|
||||
referencing the source document format it as: "[1] publicly accessible
|
||||
HTTPS URL of the document".
|
||||
* You **should always** use the `convert_gcs_links_to_https` tool to convert
|
||||
GCS links (e.g. "gs://...") to HTTPS links.
|
||||
* **Do not** use the `convert_gcs_links_to_https` tool for non-GCS links.
|
||||
* Make sure the citation URL is valid. Otherwise, do not list this specific
|
||||
citation.
|
||||
* Do not respond to any other discussion except the one specified by the user.
|
||||
|
||||
""",
|
||||
tools=[
|
||||
VertexAiSearchTool(data_store_id=VERTEXAI_DATASTORE_ID),
|
||||
AgentTool(gemini_assistant_agent),
|
||||
get_discussion_and_comments,
|
||||
add_comment_to_discussion,
|
||||
add_label_to_discussion,
|
||||
convert_gcs_links_to_https,
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,94 @@
|
||||
# Copyright 2026 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 json
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
|
||||
from adk_answering_agent.settings import ADK_GCP_SA_KEY
|
||||
from adk_answering_agent.settings import GEMINI_API_DATASTORE_ID
|
||||
from adk_answering_agent.utils import error_response
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
from google.api_core.exceptions import GoogleAPICallError
|
||||
from google.cloud import discoveryengine_v1beta as discoveryengine
|
||||
from google.oauth2 import service_account
|
||||
|
||||
|
||||
def search_gemini_api_docs(queries: List[str]) -> Dict[str, Any]:
|
||||
"""Searches Gemini API docs using Vertex AI Search.
|
||||
|
||||
Args:
|
||||
queries: The list of queries to search.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status of the request and the list of search
|
||||
results, which contains the title, url and snippets.
|
||||
"""
|
||||
try:
|
||||
adk_gcp_sa_key_info = json.loads(ADK_GCP_SA_KEY)
|
||||
client = discoveryengine.SearchServiceClient(
|
||||
credentials=service_account.Credentials.from_service_account_info(
|
||||
adk_gcp_sa_key_info
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError) as e:
|
||||
return error_response(f"Error creating Vertex AI Search client: {e}")
|
||||
|
||||
serving_config = f"{GEMINI_API_DATASTORE_ID}/servingConfigs/default_config"
|
||||
results = []
|
||||
try:
|
||||
for query in queries:
|
||||
request = discoveryengine.SearchRequest(
|
||||
serving_config=serving_config,
|
||||
query=query,
|
||||
page_size=20,
|
||||
)
|
||||
response = client.search(request=request)
|
||||
for item in response.results:
|
||||
snippets = []
|
||||
for snippet in item.document.derived_struct_data.get("snippets", []):
|
||||
snippets.append(snippet.get("snippet"))
|
||||
|
||||
results.append({
|
||||
"title": item.document.derived_struct_data.get("title"),
|
||||
"url": item.document.derived_struct_data.get("link"),
|
||||
"snippets": snippets,
|
||||
})
|
||||
except GoogleAPICallError as e:
|
||||
return error_response(f"Error from Vertex AI Search: {e}")
|
||||
return {"status": "success", "results": results}
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
model="gemini-3.5-flash",
|
||||
name="gemini_assistant",
|
||||
description="Answer questions about Gemini API.",
|
||||
instruction="""
|
||||
You are a helpful assistant that responds to questions about Gemini API based on information
|
||||
found in the document store. You can access the document store using the `search_gemini_api_docs` tool.
|
||||
|
||||
When user asks a question, here are the steps:
|
||||
1. Use the `search_gemini_api_docs` tool to find relevant information before answering.
|
||||
* You can call the tool with multiple queries to find all the relevant information.
|
||||
2. Provide a response based on the information you found in the document store. Reference the source document in the response.
|
||||
|
||||
IMPORTANT:
|
||||
* 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, just respond with "I can't find the answer or information in the document store".
|
||||
* If you uses citation from the document store, please always provide a footnote referencing the source document format it as: "[1] URL of the document".
|
||||
""",
|
||||
tools=[search_gemini_api_docs],
|
||||
)
|
||||
@@ -0,0 +1,243 @@
|
||||
"""ADK Answering Agent main script."""
|
||||
|
||||
# Copyright 2026 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 json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from typing import Union
|
||||
|
||||
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 parse_number_string
|
||||
from adk_answering_agent.utils import run_graphql_query
|
||||
from google.adk.cli.utils import logs
|
||||
from google.adk.runners import InMemoryRunner
|
||||
import requests
|
||||
|
||||
APP_NAME = "adk_answering_app"
|
||||
USER_ID = "adk_answering_user"
|
||||
|
||||
logs.setup_adk_logger(level=logging.DEBUG)
|
||||
|
||||
|
||||
async def list_most_recent_discussions(
|
||||
count: int = 1,
|
||||
) -> Union[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 answers questions for GitHub discussions.",
|
||||
epilog=(
|
||||
"Example usage: \n"
|
||||
"\tpython -m adk_answering_agent.main --recent 10\n"
|
||||
"\tpython -m adk_answering_agent.main --discussion_number 21\n"
|
||||
"\tpython -m adk_answering_agent.main --discussion "
|
||||
'\'{"number": 21, "title": "...", "body": "..."}\'\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(
|
||||
"--discussion_number",
|
||||
type=str,
|
||||
metavar="NUM",
|
||||
help="Answer a specific discussion number.",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--discussion",
|
||||
type=str,
|
||||
metavar="JSON",
|
||||
help="Answer a discussion using provided JSON data from GitHub event.",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--discussion-file",
|
||||
type=str,
|
||||
metavar="FILE",
|
||||
help="Answer a discussion using JSON data from a file.",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def main():
|
||||
args = process_arguments()
|
||||
discussion_numbers = []
|
||||
discussion_json_data = None
|
||||
|
||||
if args.recent:
|
||||
fetched_numbers = await list_most_recent_discussions(count=args.recent)
|
||||
if not fetched_numbers:
|
||||
print("No discussions found. Exiting...", file=sys.stderr)
|
||||
return
|
||||
discussion_numbers = fetched_numbers
|
||||
elif args.discussion_number:
|
||||
discussion_number = parse_number_string(args.discussion_number)
|
||||
if not discussion_number:
|
||||
print(
|
||||
"Error: Invalid discussion number received:"
|
||||
f" {args.discussion_number}."
|
||||
)
|
||||
return
|
||||
discussion_numbers = [discussion_number]
|
||||
elif args.discussion or args.discussion_file:
|
||||
try:
|
||||
# Load discussion data from either argument or file
|
||||
if args.discussion:
|
||||
discussion_data = json.loads(args.discussion)
|
||||
source_desc = "--discussion argument"
|
||||
else: # args.discussion_file
|
||||
with open(args.discussion_file, "r", encoding="utf-8") as f:
|
||||
discussion_data = json.load(f)
|
||||
source_desc = f"file {args.discussion_file}"
|
||||
|
||||
# Common validation and processing
|
||||
discussion_number = discussion_data.get("number")
|
||||
if not discussion_number:
|
||||
print("Error: Discussion JSON missing 'number' field.", file=sys.stderr)
|
||||
return
|
||||
discussion_numbers = [discussion_number]
|
||||
# Store the discussion data for later use
|
||||
discussion_json_data = discussion_data
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found: {args.discussion_file}", file=sys.stderr)
|
||||
return
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"Error: Invalid JSON in {source_desc}: {e}", file=sys.stderr)
|
||||
return
|
||||
|
||||
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:
|
||||
if len(discussion_numbers) > 1:
|
||||
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
|
||||
)
|
||||
|
||||
# If we have discussion JSON data, include it in the prompt
|
||||
# to avoid API call
|
||||
if discussion_json_data:
|
||||
discussion_json_str = json.dumps(discussion_json_data, indent=2)
|
||||
prompt = (
|
||||
f"Please help answer this GitHub discussion #{discussion_number}."
|
||||
" Here is the complete discussion"
|
||||
f" data:\n\n```json\n{discussion_json_str}\n```\n\nPlease analyze"
|
||||
" this discussion and provide a helpful response based on your"
|
||||
" knowledge of ADK."
|
||||
)
|
||||
else:
|
||||
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} 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,45 @@
|
||||
# Copyright 2026 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")
|
||||
|
||||
GOOGLE_CLOUD_PROJECT = os.getenv("GOOGLE_CLOUD_PROJECT")
|
||||
GCS_BUCKET_NAME = os.getenv("GCS_BUCKET_NAME")
|
||||
GEMINI_API_DATASTORE_ID = os.getenv("GEMINI_API_DATASTORE_ID")
|
||||
ADK_GCP_SA_KEY = os.getenv("ADK_GCP_SA_KEY")
|
||||
|
||||
ADK_DOCS_ROOT_PATH = os.getenv("ADK_DOCS_ROOT_PATH")
|
||||
ADK_PYTHON_ROOT_PATH = os.getenv("ADK_PYTHON_ROOT_PATH")
|
||||
|
||||
OWNER = os.getenv("OWNER", "google")
|
||||
REPO = os.getenv("REPO", "adk-python")
|
||||
BOT_RESPONSE_LABEL = os.getenv("BOT_RESPONSE_LABEL", "bot responded")
|
||||
DISCUSSION_NUMBER = os.getenv("DISCUSSION_NUMBER")
|
||||
|
||||
IS_INTERACTIVE = os.getenv("INTERACTIVE", "1").lower() in ["true", "1"]
|
||||
@@ -0,0 +1,230 @@
|
||||
# Copyright 2026 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 typing import Dict
|
||||
from typing import Optional
|
||||
|
||||
from adk_answering_agent.settings import OWNER
|
||||
from adk_answering_agent.settings import REPO
|
||||
from adk_answering_agent.utils import convert_gcs_to_https
|
||||
from adk_answering_agent.utils import error_response
|
||||
from adk_answering_agent.utils import run_graphql_query
|
||||
import requests
|
||||
|
||||
|
||||
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 discussion, fetch the latest 20 labels.
|
||||
labels(last: 20) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
# For each discussion, fetch the latest 100 comments.
|
||||
comments(last: 100) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
createdAt
|
||||
author {
|
||||
login
|
||||
}
|
||||
# For each discussion, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
if not comment_body.startswith("**Response from ADK Answering Agent"):
|
||||
comment_body = (
|
||||
"**Response from ADK Answering Agent (experimental, answer may be"
|
||||
" inaccurate)**\n\n"
|
||||
+ comment_body
|
||||
)
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def get_label_id(label_name: str) -> str | None:
|
||||
"""Helper function to find the GraphQL node ID for a given label name."""
|
||||
print(f"Finding ID for label '{label_name}'...")
|
||||
query = """
|
||||
query($owner: String!, $repo: String!, $labelName: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
label(name: $labelName) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {"owner": OWNER, "repo": REPO, "labelName": label_name}
|
||||
|
||||
try:
|
||||
response = run_graphql_query(query, variables)
|
||||
if "errors" in response:
|
||||
print(
|
||||
f"[Warning] Error from GitHub API response for label '{label_name}':"
|
||||
f" {response['errors']}"
|
||||
)
|
||||
return None
|
||||
label_info = response["data"].get("repository", {}).get("label")
|
||||
if label_info:
|
||||
return label_info.get("id")
|
||||
print(f"[Warning] Label information for '{label_name}' not found.")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[Warning] Error from GitHub API: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def add_label_to_discussion(
|
||||
discussion_id: str, label_name: str
|
||||
) -> dict[str, Any]:
|
||||
"""Adds a label to a specific discussion.
|
||||
|
||||
Args:
|
||||
discussion_id: The GraphQL node ID of the discussion.
|
||||
label_name: The name of the label to add (e.g., "bug").
|
||||
|
||||
Returns:
|
||||
The status of the request and the label details.
|
||||
"""
|
||||
print(
|
||||
f"Attempting to add label '{label_name}' to discussion {discussion_id}..."
|
||||
)
|
||||
# First, get the GraphQL ID of the label by its name
|
||||
label_id = get_label_id(label_name)
|
||||
if not label_id:
|
||||
return error_response(f"Label '{label_name}' not found.")
|
||||
|
||||
# Then, perform the mutation to add the label to the discussion
|
||||
mutation = """
|
||||
mutation AddLabel($discussionId: ID!, $labelId: ID!) {
|
||||
addLabelsToLabelable(input: {labelableId: $discussionId, labelIds: [$labelId]}) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {"discussionId": discussion_id, "labelId": label_id}
|
||||
try:
|
||||
response = run_graphql_query(mutation, variables)
|
||||
if "errors" in response:
|
||||
return error_response(str(response["errors"]))
|
||||
return {"status": "success", "label_id": label_id, "label_name": label_name}
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
def convert_gcs_links_to_https(gcs_uris: list[str]) -> Dict[str, Optional[str]]:
|
||||
"""Converts GCS files link into publicly accessible HTTPS links.
|
||||
|
||||
Args:
|
||||
gcs_uris: A list of GCS files links, in the format
|
||||
'gs://bucket_name/prefix/relative_path'.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping the original GCS files links to the converted HTTPS
|
||||
links. If a GCS link is invalid, the corresponding value in the dictionary
|
||||
will be None.
|
||||
"""
|
||||
return {gcs_uri: convert_gcs_to_https(gcs_uri) for gcs_uri in gcs_uris}
|
||||
@@ -0,0 +1,235 @@
|
||||
# Copyright 2026 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
|
||||
import sys
|
||||
|
||||
from adk_answering_agent.settings import ADK_DOCS_ROOT_PATH
|
||||
from adk_answering_agent.settings import ADK_PYTHON_ROOT_PATH
|
||||
from adk_answering_agent.settings import GCS_BUCKET_NAME
|
||||
from adk_answering_agent.settings import GOOGLE_CLOUD_PROJECT
|
||||
from adk_answering_agent.settings import VERTEXAI_DATASTORE_ID
|
||||
from google.api_core.exceptions import GoogleAPICallError
|
||||
from google.cloud import discoveryengine_v1beta as discoveryengine
|
||||
from google.cloud import storage
|
||||
import markdown
|
||||
|
||||
GCS_PREFIX_TO_ROOT_PATH = {
|
||||
"adk-docs": ADK_DOCS_ROOT_PATH,
|
||||
"adk-python": ADK_PYTHON_ROOT_PATH,
|
||||
}
|
||||
|
||||
|
||||
def cleanup_gcs_prefix(project_id: str, bucket_name: str, prefix: str) -> bool:
|
||||
"""Delete all the objects with the given prefix in the bucket."""
|
||||
print(f"Start cleaning up GCS: gs://{bucket_name}/{prefix}...")
|
||||
try:
|
||||
storage_client = storage.Client(project=project_id)
|
||||
bucket = storage_client.bucket(bucket_name)
|
||||
blobs = list(bucket.list_blobs(prefix=prefix))
|
||||
|
||||
if not blobs:
|
||||
print("GCS target location is already empty, no need to clean up.")
|
||||
return True
|
||||
|
||||
bucket.delete_blobs(blobs)
|
||||
print(f"Successfully deleted {len(blobs)} objects.")
|
||||
return True
|
||||
except GoogleAPICallError as e:
|
||||
print(f"[ERROR] Failed to clean up GCS: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def upload_directory_to_gcs(
|
||||
source_directory: str, project_id: str, bucket_name: str, prefix: str
|
||||
) -> bool:
|
||||
"""Upload the whole directory into GCS."""
|
||||
print(
|
||||
f"Start uploading directory {source_directory} to GCS:"
|
||||
f" gs://{bucket_name}/{prefix}..."
|
||||
)
|
||||
|
||||
if not os.path.isdir(source_directory):
|
||||
print(f"[Error] {source_directory} is not a directory or does not exist.")
|
||||
return False
|
||||
|
||||
storage_client = storage.Client(project=project_id)
|
||||
bucket = storage_client.bucket(bucket_name)
|
||||
file_count = 0
|
||||
for root, dirs, files in os.walk(source_directory):
|
||||
# Modify the 'dirs' list in-place to prevent os.walk from descending
|
||||
# into hidden directories.
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
|
||||
# Keep only .md, .py and .yaml files.
|
||||
files = [f for f in files if f.endswith((".md", ".py", ".yaml"))]
|
||||
|
||||
for filename in files:
|
||||
local_path = os.path.join(root, filename)
|
||||
|
||||
relative_path = os.path.relpath(local_path, source_directory)
|
||||
gcs_path = os.path.join(prefix, relative_path)
|
||||
|
||||
try:
|
||||
content_type = None
|
||||
if filename.lower().endswith(".md"):
|
||||
# Vertex AI search doesn't recognize text/markdown,
|
||||
# convert it to html and use text/html instead
|
||||
content_type = "text/html"
|
||||
with open(local_path, "r", encoding="utf-8") as f:
|
||||
md_content = f.read()
|
||||
html_content = markdown.markdown(
|
||||
md_content, output_format="html5", encoding="utf-8"
|
||||
)
|
||||
if not html_content:
|
||||
print(" - Skipped empty file: " + local_path)
|
||||
continue
|
||||
gcs_path = gcs_path.removesuffix(".md") + ".html"
|
||||
bucket.blob(gcs_path).upload_from_string(
|
||||
html_content, content_type=content_type
|
||||
)
|
||||
elif filename.lower().endswith(".yaml"):
|
||||
# Vertex AI search doesn't recognize yaml,
|
||||
# convert it to text and use text/plain instead
|
||||
content_type = "text/plain"
|
||||
with open(local_path, "r", encoding="utf-8") as f:
|
||||
yaml_content = f.read()
|
||||
if not yaml_content:
|
||||
print(" - Skipped empty file: " + local_path)
|
||||
continue
|
||||
gcs_path = gcs_path.removesuffix(".yaml") + ".txt"
|
||||
bucket.blob(gcs_path).upload_from_string(
|
||||
yaml_content, content_type=content_type
|
||||
)
|
||||
else: # Python files
|
||||
bucket.blob(gcs_path).upload_from_filename(
|
||||
local_path, content_type=content_type
|
||||
)
|
||||
type_msg = (
|
||||
f"(type {content_type})" if content_type else "(type auto-detect)"
|
||||
)
|
||||
print(
|
||||
f" - Uploaded {type_msg}: {local_path} ->"
|
||||
f" gs://{bucket_name}/{gcs_path}"
|
||||
)
|
||||
file_count += 1
|
||||
except GoogleAPICallError as e:
|
||||
print(
|
||||
f"[ERROR] Error uploading file {local_path}: {e}", file=sys.stderr
|
||||
)
|
||||
return False
|
||||
|
||||
print(f"Successfully uploaded {file_count} files to GCS.")
|
||||
return True
|
||||
|
||||
|
||||
def import_from_gcs_to_vertex_ai(
|
||||
full_datastore_id: str,
|
||||
gcs_bucket: str,
|
||||
) -> bool:
|
||||
"""Triggers a bulk import task from a GCS folder to Vertex AI Search."""
|
||||
print(f"Triggering FULL SYNC import from gs://{gcs_bucket}/**...")
|
||||
|
||||
try:
|
||||
client = discoveryengine.DocumentServiceClient()
|
||||
gcs_uri = f"gs://{gcs_bucket}/**"
|
||||
request = discoveryengine.ImportDocumentsRequest(
|
||||
# parent has the format of
|
||||
# "projects/{project_number}/locations/{location}/collections/{collection}/dataStores/{datastore_id}/branches/default_branch"
|
||||
parent=full_datastore_id + "/branches/default_branch",
|
||||
# Specify the GCS source and use "content" for unstructured data.
|
||||
gcs_source=discoveryengine.GcsSource(
|
||||
input_uris=[gcs_uri], data_schema="content"
|
||||
),
|
||||
reconciliation_mode=discoveryengine.ImportDocumentsRequest.ReconciliationMode.FULL,
|
||||
)
|
||||
operation = client.import_documents(request=request)
|
||||
print(
|
||||
"Successfully started full sync import operation."
|
||||
f"Operation Name: {operation.operation.name}"
|
||||
)
|
||||
return True
|
||||
|
||||
except GoogleAPICallError as e:
|
||||
print(f"[ERROR] Error triggering import: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
# Check required environment variables.
|
||||
if not GOOGLE_CLOUD_PROJECT:
|
||||
print(
|
||||
"[ERROR] GOOGLE_CLOUD_PROJECT environment variable not set. Exiting...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if not GCS_BUCKET_NAME:
|
||||
print(
|
||||
"[ERROR] GCS_BUCKET_NAME environment variable not set. Exiting...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if not VERTEXAI_DATASTORE_ID:
|
||||
print(
|
||||
"[ERROR] VERTEXAI_DATASTORE_ID environment variable not set."
|
||||
" Exiting...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if not ADK_DOCS_ROOT_PATH:
|
||||
print(
|
||||
"[ERROR] ADK_DOCS_ROOT_PATH environment variable not set. Exiting...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if not ADK_PYTHON_ROOT_PATH:
|
||||
print(
|
||||
"[ERROR] ADK_PYTHON_ROOT_PATH environment variable not set. Exiting...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
for gcs_prefix in GCS_PREFIX_TO_ROOT_PATH:
|
||||
# 1. Cleanup the GSC for a clean start.
|
||||
if not cleanup_gcs_prefix(
|
||||
GOOGLE_CLOUD_PROJECT, GCS_BUCKET_NAME, gcs_prefix
|
||||
):
|
||||
print("[ERROR] Failed to clean up GCS. Exiting...", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 2. Upload the docs to GCS.
|
||||
if not upload_directory_to_gcs(
|
||||
GCS_PREFIX_TO_ROOT_PATH[gcs_prefix],
|
||||
GOOGLE_CLOUD_PROJECT,
|
||||
GCS_BUCKET_NAME,
|
||||
gcs_prefix,
|
||||
):
|
||||
print("[ERROR] Failed to upload docs to GCS. Exiting...", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 3. Import the docs from GCS to Vertex AI Search.
|
||||
if not import_from_gcs_to_vertex_ai(VERTEXAI_DATASTORE_ID, GCS_BUCKET_NAME):
|
||||
print(
|
||||
"[ERROR] Failed to import docs from GCS to Vertex AI Search."
|
||||
" Exiting...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print("--- Sync task has been successfully initiated ---")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,174 @@
|
||||
# Copyright 2026 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
|
||||
import sys
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _check_url_exists(url: str) -> bool:
|
||||
"""Checks if a URL exists and is accessible."""
|
||||
try:
|
||||
# Set a timeout to prevent the program from waiting indefinitely.
|
||||
# allow_redirects=True ensures we correctly handle valid links
|
||||
# after redirection.
|
||||
response = requests.head(url, timeout=5, allow_redirects=True)
|
||||
# Status codes 2xx (Success) or 3xx (Redirection) are considered valid.
|
||||
return response.ok
|
||||
except requests.RequestException:
|
||||
# Catch all possible exceptions from the requests library
|
||||
# (e.g., connection errors, timeouts).
|
||||
return False
|
||||
|
||||
|
||||
def _generate_github_url(repo_name: str, relative_path: str) -> str:
|
||||
"""Generates a standard GitHub URL for a repo file."""
|
||||
return f"https://github.com/google/{repo_name}/blob/main/{relative_path}"
|
||||
|
||||
|
||||
def convert_gcs_to_https(gcs_uri: str) -> Optional[str]:
|
||||
"""Converts a GCS file link into a publicly accessible HTTPS link.
|
||||
|
||||
Args:
|
||||
gcs_uri: The Google Cloud Storage link, in the format
|
||||
'gs://bucket_name/prefix/relative_path'.
|
||||
|
||||
Returns:
|
||||
The converted HTTPS link as a string, or None if the input format is
|
||||
incorrect.
|
||||
"""
|
||||
# Parse the GCS link
|
||||
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
||||
print(f"Error: Invalid GCS link format: {gcs_uri}")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Strip 'gs://' and split by '/', requiring at least 3 parts
|
||||
# (bucket, prefix, path)
|
||||
parts = gcs_uri[5:].split("/", 2)
|
||||
if len(parts) < 3:
|
||||
raise ValueError(
|
||||
"GCS link must contain a bucket, prefix, and relative_path."
|
||||
)
|
||||
|
||||
_, prefix, relative_path = parts
|
||||
except (ValueError, IndexError) as e:
|
||||
print(f"Error: Failed to parse GCS link '{gcs_uri}': {e}")
|
||||
return None
|
||||
|
||||
# Replace .html with .md
|
||||
if relative_path.endswith(".html"):
|
||||
relative_path = relative_path.removesuffix(".html") + ".md"
|
||||
|
||||
# Replace .txt with .yaml
|
||||
if relative_path.endswith(".txt"):
|
||||
relative_path = relative_path.removesuffix(".txt") + ".yaml"
|
||||
|
||||
# Convert the links for adk-docs
|
||||
if prefix == "adk-docs" and relative_path.startswith("docs/"):
|
||||
path_after_docs = relative_path[len("docs/") :]
|
||||
if not path_after_docs.endswith(".md"):
|
||||
# Use the regular github url
|
||||
return _generate_github_url(prefix, relative_path)
|
||||
|
||||
base_url = "https://google.github.io/adk-docs/"
|
||||
if os.path.basename(path_after_docs) == "index.md":
|
||||
# Use the directory path if it is an index file
|
||||
final_path_segment = os.path.dirname(path_after_docs)
|
||||
else:
|
||||
# Otherwise, use the file name without extension
|
||||
final_path_segment = path_after_docs.removesuffix(".md")
|
||||
|
||||
if final_path_segment and not final_path_segment.endswith("/"):
|
||||
final_path_segment += "/"
|
||||
|
||||
potential_url = urljoin(base_url, final_path_segment)
|
||||
|
||||
# Check if the generated link exists
|
||||
if _check_url_exists(potential_url):
|
||||
return potential_url
|
||||
else:
|
||||
# If it doesn't exist, fall back to the regular github url
|
||||
return _generate_github_url(prefix, relative_path)
|
||||
|
||||
# Convert the links for other cases, e.g. adk-python
|
||||
else:
|
||||
return _generate_github_url(prefix, relative_path)
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 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.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,122 @@
|
||||
# Copyright 2026 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
|
||||
import sys
|
||||
|
||||
SAMPLES_DIR = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..")
|
||||
)
|
||||
if SAMPLES_DIR not in sys.path:
|
||||
sys.path.append(SAMPLES_DIR)
|
||||
|
||||
from adk_documentation.settings import CODE_OWNER
|
||||
from adk_documentation.settings import CODE_REPO
|
||||
from adk_documentation.settings import DOC_OWNER
|
||||
from adk_documentation.settings import DOC_REPO
|
||||
from adk_documentation.settings import IS_INTERACTIVE
|
||||
from adk_documentation.settings import LOCAL_REPOS_DIR_PATH
|
||||
from adk_documentation.tools import clone_or_pull_repo
|
||||
from adk_documentation.tools import create_pull_request_from_changes
|
||||
from adk_documentation.tools import get_issue
|
||||
from adk_documentation.tools import list_directory_contents
|
||||
from adk_documentation.tools import read_local_git_repo_file_content
|
||||
from adk_documentation.tools import search_local_git_repo
|
||||
from google.adk import Agent
|
||||
|
||||
if IS_INTERACTIVE:
|
||||
APPROVAL_INSTRUCTION = (
|
||||
"Ask for user approval or confirmation for creating the pull request."
|
||||
)
|
||||
else:
|
||||
APPROVAL_INSTRUCTION = (
|
||||
"**Do not** wait or ask for user approval or confirmation for creating"
|
||||
" the pull request."
|
||||
)
|
||||
|
||||
root_agent = Agent(
|
||||
model="gemini-3.5-flash",
|
||||
name="adk_docs_updater",
|
||||
description=(
|
||||
"Update the ADK docs based on the code in the ADK Python codebase"
|
||||
" according to the instructions in the ADK docs issues."
|
||||
),
|
||||
instruction=f"""
|
||||
# 1. Identity
|
||||
You are a helper bot that updates ADK docs in GitHub Repository {DOC_OWNER}/{DOC_REPO}
|
||||
based on the code in the ADK Python codebase in GitHub Repository {CODE_OWNER}/{CODE_REPO} according to the instructions in the ADK docs issues.
|
||||
|
||||
You are very familiar with GitHub, especially how to search for files in a GitHub repository using git grep.
|
||||
|
||||
# 2. Responsibilities
|
||||
Your core responsibility includes:
|
||||
- Read the doc update instructions in the ADK docs issues.
|
||||
- Find **all** the related Python files in ADK Python codebase.
|
||||
- Compare the ADK docs with **all** the related Python files and analyze the differences and the doc update instructions.
|
||||
- Create a pull request to update the ADK docs.
|
||||
|
||||
# 3. Workflow
|
||||
1. Always call the `clone_or_pull_repo` tool to make sure the ADK docs and codebase repos exist in the local folder {LOCAL_REPOS_DIR_PATH}/repo_name and are the latest version.
|
||||
2. Read and analyze the issue specified by user.
|
||||
- If user only specified the issue number, call the `get_issue` tool to get the issue details; otherwise, use the issue details provided by user directly.
|
||||
3. If the issue contains instructions about how to update the ADK docs, follow the instructions to update the ADK docs.
|
||||
4. Understand the doc update instructions.
|
||||
- Ignore and skip the instructions about updating API reference docs, since it will be automatically generated by the ADK team.
|
||||
5. Read the doc to update using the `read_local_git_repo_file_content` tool from the local ADK docs repo under {LOCAL_REPOS_DIR_PATH}/{DOC_REPO}.
|
||||
6. Find the related Python files in the ADK Python codebase.
|
||||
- If the doc update instructions specify paths to the Python files, use them directly; otherwise, use a list of regex search patterns to find the related Python files through the `search_local_git_repo` tool.
|
||||
- You should focus on the main ADK Python codebase, ignore the changes in tests or other auxiliary files.
|
||||
- You should find all the related Python files, not only the most relevant one.
|
||||
7. Read the specified or found Python files using the `read_local_git_repo_file_content` tool to find all the related code.
|
||||
- You can ignore unit test files, unless you are sure that the test code is useful to understand the related concepts.
|
||||
- You should read all the found files to find all the related code, unless you already know the content of the file or you are sure that the file is not related to the ADK doc.
|
||||
8. Update the ADK doc file according to the doc update instructions and the related code.
|
||||
- Use active voice phrasing in your doc updates.
|
||||
- Use second person "you" form of address in your doc updates.
|
||||
9. Create pull requests to update the ADK doc file using the `create_pull_request_from_changes` tool.
|
||||
- For each recommended change, create a separate pull request. Make sure the recommended change has exactly one pull request.
|
||||
For example, if the ADK doc issue contains the following 2 recommended changes:
|
||||
```
|
||||
1. Title of recommended change 1
|
||||
<content of recommended change 1>
|
||||
2. Title of recommended change 2
|
||||
<content of recommended change 2>
|
||||
```
|
||||
Then you should create 2 pull requests, one for each recommended change, even if each recommended change needs to update multiple ADK doc files.
|
||||
- The title of the pull request should be "Update ADK doc according to issue #<issue number> - <change id>", where <issue number> is the number of the ADK docs issue and <change id> is the id of the recommended change (e.g. "1", "2", etc.).
|
||||
- The body of the pull request should be the instructions about how to update the ADK docs.
|
||||
- **{APPROVAL_INSTRUCTION}**
|
||||
|
||||
# 4. Guidelines & Rules
|
||||
- **File Paths:** Always use absolute paths when calling the tools to read files, list directories, or search the codebase.
|
||||
- **Tool Call Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Avoid deletion:** Do not delete any existing content unless specifically directed to do so.
|
||||
- **Explanation:** Provide concise explanations for your actions and reasoning for each step.
|
||||
- **Minimize changes:** When making updates to documentation pages, make the minimum amount of changes to achieve the communication goal. Only make changes that are necessary, and leave everything else as-is.
|
||||
- **Avoid trivial code sample changes:** Update code samples only when adding or modifying functionality. Do not reformat code samples, change variable names, or change code syntax unless you are specifically directed to make those updates.
|
||||
|
||||
# 5. Output
|
||||
Present the following in an easy to read format as the final output to the user.
|
||||
- The actions you took and the reasoning
|
||||
- The summary of the pull request created
|
||||
""",
|
||||
tools=[
|
||||
clone_or_pull_repo,
|
||||
list_directory_contents,
|
||||
search_local_git_repo,
|
||||
read_local_git_repo_file_content,
|
||||
create_pull_request_from_changes,
|
||||
get_issue,
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
# Copyright 2026 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 logging
|
||||
import time
|
||||
|
||||
from adk_documentation.adk_docs_updater import agent
|
||||
from adk_documentation.settings import CODE_OWNER
|
||||
from adk_documentation.settings import CODE_REPO
|
||||
from adk_documentation.settings import DOC_OWNER
|
||||
from adk_documentation.settings import DOC_REPO
|
||||
from adk_documentation.tools import get_issue
|
||||
from adk_documentation.utils import call_agent_async
|
||||
from adk_documentation.utils import parse_suggestions
|
||||
from google.adk.cli.utils import logs
|
||||
from google.adk.runners import InMemoryRunner
|
||||
|
||||
APP_NAME = "adk_docs_updater"
|
||||
USER_ID = "adk_docs_updater_user"
|
||||
|
||||
logs.setup_adk_logger(level=logging.INFO)
|
||||
|
||||
|
||||
def process_arguments():
|
||||
"""Parses command-line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="A script that creates pull requests to update ADK docs.",
|
||||
epilog=(
|
||||
"Example usage: \n"
|
||||
"\tpython -m adk_docs_updater.main --issue_number 123\n"
|
||||
),
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
)
|
||||
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
|
||||
group.add_argument(
|
||||
"--issue_number",
|
||||
type=int,
|
||||
metavar="NUM",
|
||||
help="Answer a specific issue number.",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def main():
|
||||
args = process_arguments()
|
||||
if not args.issue_number:
|
||||
print("Please specify an issue number using --issue_number flag")
|
||||
return
|
||||
issue_number = args.issue_number
|
||||
|
||||
get_issue_response = get_issue(DOC_OWNER, DOC_REPO, issue_number)
|
||||
if get_issue_response["status"] != "success":
|
||||
print(f"Failed to get issue {issue_number}: {get_issue_response}\n")
|
||||
return
|
||||
issue = get_issue_response["issue"]
|
||||
issue_title = issue.get("title", "")
|
||||
issue_body = issue.get("body", "")
|
||||
|
||||
# Parse numbered suggestions from issue body
|
||||
suggestions = parse_suggestions(issue_body)
|
||||
|
||||
if not suggestions:
|
||||
print(f"No numbered suggestions found in issue #{issue_number}.")
|
||||
print("Falling back to processing the entire issue as a single task.")
|
||||
suggestions = [(1, issue_body)]
|
||||
|
||||
print(f"Found {len(suggestions)} suggestion(s) in issue #{issue_number}.")
|
||||
print("=" * 80)
|
||||
|
||||
runner = InMemoryRunner(
|
||||
agent=agent.root_agent,
|
||||
app_name=APP_NAME,
|
||||
)
|
||||
|
||||
results = []
|
||||
for suggestion_num, suggestion_text in suggestions:
|
||||
print(f"\n>>> Processing suggestion #{suggestion_num}...")
|
||||
print("-" * 80)
|
||||
|
||||
# Create a new session for each suggestion to avoid context interference
|
||||
session = await runner.session_service.create_session(
|
||||
app_name=APP_NAME,
|
||||
user_id=USER_ID,
|
||||
)
|
||||
|
||||
prompt = f"""
|
||||
Please update the ADK docs according to suggestion #{suggestion_num} from issue #{issue_number}.
|
||||
|
||||
Issue title: {issue_title}
|
||||
|
||||
Suggestion to process:
|
||||
{suggestion_text}
|
||||
|
||||
Note: Focus only on this specific suggestion. Create exactly one pull request for this suggestion.
|
||||
"""
|
||||
|
||||
try:
|
||||
response = await call_agent_async(
|
||||
runner,
|
||||
USER_ID,
|
||||
session.id,
|
||||
prompt,
|
||||
)
|
||||
results.append({
|
||||
"suggestion_num": suggestion_num,
|
||||
"status": "success",
|
||||
"response": response,
|
||||
})
|
||||
print(f"<<<< Suggestion #{suggestion_num} completed.")
|
||||
except Exception as e:
|
||||
results.append({
|
||||
"suggestion_num": suggestion_num,
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
})
|
||||
print(f"<<<< Suggestion #{suggestion_num} failed: {e}")
|
||||
|
||||
print("-" * 80)
|
||||
|
||||
# Print summary
|
||||
print("\n" + "=" * 80)
|
||||
print("SUMMARY")
|
||||
print("=" * 80)
|
||||
successful = [r for r in results if r["status"] == "success"]
|
||||
failed = [r for r in results if r["status"] == "error"]
|
||||
print(
|
||||
f"Total: {len(results)}, Success: {len(successful)}, Failed:"
|
||||
f" {len(failed)}"
|
||||
)
|
||||
if failed:
|
||||
print("\nFailed suggestions:")
|
||||
for r in failed:
|
||||
print(f" - Suggestion #{r['suggestion_num']}: {r['error']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start_time = time.time()
|
||||
print(
|
||||
f"Start creating pull requests to update {DOC_OWNER}/{DOC_REPO} docs"
|
||||
f" according the {CODE_OWNER}/{CODE_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(
|
||||
"Updating 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,102 @@
|
||||
# ADK Release Analyzer Agent
|
||||
|
||||
The ADK Release Analyzer Agent is a Python-based agent designed to help keep
|
||||
documentation up-to-date with code changes. It analyzes the differences between
|
||||
two releases of the `google/adk-python` repository, identifies required updates
|
||||
in the `google/adk-docs` repository, and automatically generates a GitHub issue
|
||||
with detailed instructions for documentation changes.
|
||||
|
||||
This agent can be operated in two distinct modes:
|
||||
|
||||
- an interactive mode for local use
|
||||
- a fully automated mode for integration into workflows.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Interactive Mode
|
||||
|
||||
This mode allows you to run the agent locally to review its recommendations in
|
||||
real-time before any changes are made.
|
||||
|
||||
### 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 creating an issue on GitHub with the documentation
|
||||
update instructions.
|
||||
- **Question & Answer**: You ask questions about the releases and code changes.
|
||||
The agent will provide answers based on related information.
|
||||
|
||||
### Running in Interactive Mode
|
||||
|
||||
To run the agent in interactive mode, first set the required environment
|
||||
variables, ensuring `INTERACTIVE` is set to `1` or is unset. Then, execute the
|
||||
following command in your terminal:
|
||||
|
||||
```bash
|
||||
adk web contributing/samples/adk_documentation
|
||||
```
|
||||
|
||||
This will start a local server and provide a URL to access the agent's web
|
||||
interface in your browser.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Automated Mode
|
||||
|
||||
For automated, hands-off analysis, the agent can be run as a script (`main.py`),
|
||||
for example as part of a CI/CD pipeline. The workflow is configured in
|
||||
`.github/workflows/analyze-releases-for-adk-docs-updates.yml` and automatically
|
||||
checks the most recent two releases for docs updates.
|
||||
|
||||
### Workflow Triggers
|
||||
|
||||
The GitHub workflow is configured to run on specific triggers:
|
||||
|
||||
- **Release Events**: The workflow executes automatically whenever a new release
|
||||
is `published`.
|
||||
|
||||
- **Manual Dispatch**: The workflow also runs when manually triggered for
|
||||
testing and retrying.
|
||||
|
||||
### Automated Issue Creation
|
||||
|
||||
When running in automated mode, the agent operates non-interactively. It creates
|
||||
a GitHub issue with the documentation update instructions directly without
|
||||
requiring user approval. This behavior is configured by setting the
|
||||
`INTERACTIVE` environment variable to `0`.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Setup and Configuration
|
||||
|
||||
Whether running in interactive or automated mode, the agent requires the
|
||||
following setup.
|
||||
|
||||
### Dependencies
|
||||
|
||||
The agent requires the following Python libraries.
|
||||
|
||||
```bash
|
||||
pip install --upgrade pip
|
||||
pip install google-adk
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The following environment variables are required for the agent to connect to
|
||||
the necessary services.
|
||||
|
||||
- `GITHUB_TOKEN`: **(Required)** A GitHub Personal Access Token with issues:write permissions for the documentation repository.
|
||||
- `GOOGLE_API_KEY`: **(Required)** Your API key for the Gemini API.
|
||||
- `DOC_OWNER`: The GitHub organization or username that owns the documentation repository (defaults to `google`).
|
||||
- `CODE_OWNER`: The GitHub organization or username that owns the code repository (defaults to `google`).
|
||||
- `DOC_REPO`: The name of the documentation repository (defaults to `adk-docs`).
|
||||
- `CODE_REPO`: The name of the code repository (defaults to `adk-python`).
|
||||
- `LOCAL_REPOS_DIR_PATH`: The local directory to clone the repositories into (defaults to `/tmp`).
|
||||
- `INTERACTIVE`: Controls the agent's interaction mode. Set to 1 for interactive mode (default), and 0 for automated mode.
|
||||
|
||||
For local execution, you can place these variables in a `.env` file in the
|
||||
project's root directory. For automated workflows, they should be configured as
|
||||
environment variables or secrets.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,691 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""ADK Release Analyzer Agent - Multi-agent architecture for analyzing releases.
|
||||
|
||||
This agent uses a SequentialAgent + LoopAgent pattern to handle large releases
|
||||
without context overflow:
|
||||
|
||||
1. PlannerAgent: Collects changed files and creates analysis groups
|
||||
2. LoopAgent + FileGroupAnalyzer: Processes one group at a time
|
||||
3. SummaryAgent: Compiles all findings and creates the GitHub issue
|
||||
|
||||
State keys used:
|
||||
- start_tag, end_tag: Release tags being compared
|
||||
- compare_url: GitHub compare URL
|
||||
- file_groups: List of file groups to analyze
|
||||
- current_group_index: Index of current group being processed
|
||||
- recommendations: Accumulated recommendations from all groups
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
SAMPLES_DIR = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..")
|
||||
)
|
||||
if SAMPLES_DIR not in sys.path:
|
||||
sys.path.append(SAMPLES_DIR)
|
||||
|
||||
from adk_documentation.settings import CODE_OWNER
|
||||
from adk_documentation.settings import CODE_REPO
|
||||
from adk_documentation.settings import DOC_OWNER
|
||||
from adk_documentation.settings import DOC_REPO
|
||||
from adk_documentation.settings import IS_INTERACTIVE
|
||||
from adk_documentation.settings import LOCAL_REPOS_DIR_PATH
|
||||
from adk_documentation.tools import clone_or_pull_repo
|
||||
from adk_documentation.tools import create_issue
|
||||
from adk_documentation.tools import get_changed_files_summary
|
||||
from adk_documentation.tools import get_file_diff_for_release
|
||||
from adk_documentation.tools import list_directory_contents
|
||||
from adk_documentation.tools import list_releases
|
||||
from adk_documentation.tools import read_local_git_repo_file_content
|
||||
from adk_documentation.tools import search_local_git_repo
|
||||
from google.adk import Agent
|
||||
from google.adk.agents.loop_agent import LoopAgent
|
||||
from google.adk.agents.readonly_context import ReadonlyContext
|
||||
from google.adk.agents.sequential_agent import SequentialAgent
|
||||
from google.adk.models import Gemini
|
||||
from google.adk.tools.exit_loop_tool import exit_loop
|
||||
from google.adk.tools.tool_context import ToolContext
|
||||
from google.genai import types
|
||||
|
||||
# Retry configuration for handling API rate limits and overload
|
||||
_RETRY_OPTIONS = types.HttpRetryOptions(
|
||||
initial_delay=10,
|
||||
attempts=8,
|
||||
exp_base=2,
|
||||
max_delay=300,
|
||||
http_status_codes=[429, 503],
|
||||
)
|
||||
|
||||
# Use gemini-3.1-pro-preview for planning and summary (better quality)
|
||||
GEMINI_PRO_WITH_RETRY = Gemini(
|
||||
model="gemini-3.1-pro-preview",
|
||||
retry_options=_RETRY_OPTIONS,
|
||||
)
|
||||
|
||||
# Maximum number of files per analysis group to avoid context overflow
|
||||
MAX_FILES_PER_GROUP = 5
|
||||
|
||||
if IS_INTERACTIVE:
|
||||
APPROVAL_INSTRUCTION = (
|
||||
"Ask for user approval or confirmation for creating or updating the"
|
||||
" issue."
|
||||
)
|
||||
else:
|
||||
APPROVAL_INSTRUCTION = (
|
||||
"**Do not** wait or ask for user approval or confirmation for creating"
|
||||
" or updating the issue."
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tool functions for state management
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_next_file_group(tool_context: ToolContext) -> dict[str, Any]:
|
||||
"""Gets the next group of files to analyze from the state.
|
||||
|
||||
This tool retrieves the next file group from state["file_groups"]
|
||||
and increments the current_group_index.
|
||||
|
||||
Args:
|
||||
tool_context: The tool context providing access to state.
|
||||
|
||||
Returns:
|
||||
A dictionary with the next file group or indication that all groups
|
||||
are processed.
|
||||
"""
|
||||
file_groups = tool_context.state.get("file_groups", [])
|
||||
current_index = tool_context.state.get("current_group_index", 0)
|
||||
|
||||
if current_index >= len(file_groups):
|
||||
print(f"[Progress] All {len(file_groups)} groups processed.")
|
||||
return {
|
||||
"status": "complete",
|
||||
"message": "All file groups have been processed.",
|
||||
"total_groups": len(file_groups),
|
||||
"processed": current_index,
|
||||
}
|
||||
|
||||
current_group = file_groups[current_index]
|
||||
file_paths = [f.get("relative_path", "?") for f in current_group]
|
||||
print(
|
||||
f"[Progress] Starting group {current_index + 1}/{len(file_groups)}:"
|
||||
f" {file_paths}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"group_index": current_index,
|
||||
"total_groups": len(file_groups),
|
||||
"remaining": len(file_groups) - current_index - 1,
|
||||
"files": current_group,
|
||||
}
|
||||
|
||||
|
||||
def save_group_recommendations(
|
||||
tool_context: ToolContext,
|
||||
group_index: int,
|
||||
recommendations: list[dict[str, str]],
|
||||
) -> dict[str, Any]:
|
||||
"""Saves recommendations for a file group to state.
|
||||
|
||||
Args:
|
||||
tool_context: The tool context providing access to state.
|
||||
group_index: The index of the group these recommendations belong to.
|
||||
recommendations: List of recommendation dicts with keys:
|
||||
- summary: Brief summary of the change
|
||||
- doc_file: Path to the doc file to update
|
||||
- current_state: Current content in the doc
|
||||
- proposed_change: What should be changed
|
||||
- reasoning: Why this change is needed
|
||||
- reference: Reference to the code file
|
||||
|
||||
Returns:
|
||||
A dictionary confirming the save operation.
|
||||
"""
|
||||
all_recommendations = tool_context.state.get("recommendations", [])
|
||||
all_recommendations.extend(recommendations)
|
||||
tool_context.state["recommendations"] = all_recommendations
|
||||
# Advance index only after recommendations are saved, so interrupted
|
||||
# groups get retried on resume instead of being skipped.
|
||||
tool_context.state["current_group_index"] = group_index + 1
|
||||
|
||||
total_groups = len(tool_context.state.get("file_groups", []))
|
||||
print(
|
||||
f"[Progress] Group {group_index + 1}/{total_groups} done."
|
||||
f" +{len(recommendations)} recommendations"
|
||||
f" ({len(all_recommendations)} total)"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"group_index": group_index,
|
||||
"new_recommendations": len(recommendations),
|
||||
"total_recommendations": len(all_recommendations),
|
||||
}
|
||||
|
||||
|
||||
def get_all_recommendations(tool_context: ToolContext) -> dict[str, Any]:
|
||||
"""Retrieves all accumulated recommendations from state.
|
||||
|
||||
Args:
|
||||
tool_context: The tool context providing access to state.
|
||||
|
||||
Returns:
|
||||
A dictionary with all recommendations and metadata.
|
||||
"""
|
||||
recommendations = tool_context.state.get("recommendations", [])
|
||||
start_tag = tool_context.state.get("start_tag", "unknown")
|
||||
end_tag = tool_context.state.get("end_tag", "unknown")
|
||||
compare_url = tool_context.state.get("compare_url", "")
|
||||
|
||||
print(
|
||||
f"[Summary] Retrieving recommendations: {len(recommendations)} total,"
|
||||
f" release {start_tag} → {end_tag}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"start_tag": start_tag,
|
||||
"end_tag": end_tag,
|
||||
"compare_url": compare_url,
|
||||
"total_recommendations": len(recommendations),
|
||||
"recommendations": recommendations,
|
||||
}
|
||||
|
||||
|
||||
def save_release_info(
|
||||
tool_context: ToolContext,
|
||||
start_tag: str,
|
||||
end_tag: str,
|
||||
compare_url: str,
|
||||
file_groups: list[list[dict[str, Any]]],
|
||||
release_summary: str,
|
||||
all_changed_files: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Saves release info and file groups to state for processing.
|
||||
|
||||
Args:
|
||||
tool_context: The tool context providing access to state.
|
||||
start_tag: The starting release tag.
|
||||
end_tag: The ending release tag.
|
||||
compare_url: The GitHub compare URL.
|
||||
file_groups: List of file groups, where each group is a list of file
|
||||
info dicts.
|
||||
release_summary: A high-level summary of all changes in this release,
|
||||
including the main themes (e.g., "new feature X", "refactoring Y",
|
||||
"bug fixes in Z"). This helps individual analyzers understand the
|
||||
bigger picture.
|
||||
all_changed_files: List of all changed file paths (for cross-reference).
|
||||
|
||||
Returns:
|
||||
A dictionary confirming the save operation.
|
||||
"""
|
||||
tool_context.state["start_tag"] = start_tag
|
||||
tool_context.state["end_tag"] = end_tag
|
||||
tool_context.state["compare_url"] = compare_url
|
||||
tool_context.state["file_groups"] = file_groups
|
||||
tool_context.state["current_group_index"] = 0
|
||||
tool_context.state["recommendations"] = []
|
||||
tool_context.state["release_summary"] = release_summary
|
||||
tool_context.state["all_changed_files"] = all_changed_files
|
||||
|
||||
total_files = sum(len(group) for group in file_groups)
|
||||
print(
|
||||
f"[Planning] Release {start_tag} → {end_tag}:"
|
||||
f" {total_files} files in {len(file_groups)} groups"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"start_tag": start_tag,
|
||||
"end_tag": end_tag,
|
||||
"total_groups": len(file_groups),
|
||||
"total_files": sum(len(group) for group in file_groups),
|
||||
}
|
||||
|
||||
|
||||
def get_release_context(tool_context: ToolContext) -> dict[str, Any]:
|
||||
"""Gets the global release context for cross-group awareness.
|
||||
|
||||
This allows individual file group analyzers to understand:
|
||||
- The overall theme of the release
|
||||
- What other files were changed (for identifying related changes)
|
||||
- What recommendations have already been made (to avoid duplicates)
|
||||
|
||||
Args:
|
||||
tool_context: The tool context providing access to state.
|
||||
|
||||
Returns:
|
||||
A dictionary with global release context.
|
||||
"""
|
||||
return {
|
||||
"status": "success",
|
||||
"start_tag": tool_context.state.get("start_tag", "unknown"),
|
||||
"end_tag": tool_context.state.get("end_tag", "unknown"),
|
||||
"release_summary": tool_context.state.get("release_summary", ""),
|
||||
"all_changed_files": tool_context.state.get("all_changed_files", []),
|
||||
"existing_recommendations": tool_context.state.get("recommendations", []),
|
||||
"current_group_index": tool_context.state.get("current_group_index", 0),
|
||||
"total_groups": len(tool_context.state.get("file_groups", [])),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent 1: Planner Agent
|
||||
# =============================================================================
|
||||
|
||||
planner_agent = Agent(
|
||||
model=GEMINI_PRO_WITH_RETRY,
|
||||
name="release_planner",
|
||||
description=(
|
||||
"Plans the analysis by fetching release info and organizing files into"
|
||||
" groups for incremental processing."
|
||||
),
|
||||
instruction=f"""
|
||||
# 1. Identity
|
||||
You are the Release Planner, responsible for setting up the analysis of ADK
|
||||
Python releases. You gather information about changes and organize them for
|
||||
efficient processing.
|
||||
|
||||
# 2. Workflow
|
||||
1. First, call `clone_or_pull_repo` for both repositories:
|
||||
- ADK Python codebase: owner={CODE_OWNER}, repo={CODE_REPO}, path={LOCAL_REPOS_DIR_PATH}/{CODE_REPO}
|
||||
- ADK Docs: owner={DOC_OWNER}, repo={DOC_REPO}, path={LOCAL_REPOS_DIR_PATH}/{DOC_REPO}
|
||||
|
||||
2. Call `list_releases` to find the release tags for {CODE_OWNER}/{CODE_REPO}.
|
||||
- By default, compare the two most recent releases.
|
||||
- If the user specifies tags, use those instead.
|
||||
|
||||
3. Call `get_changed_files_summary` to get the list of changed files WITHOUT
|
||||
the full patches (to save context space).
|
||||
- **IMPORTANT**: Pass these parameters:
|
||||
- `local_repo_path="{LOCAL_REPOS_DIR_PATH}/{CODE_REPO}"` to avoid 300-file limit
|
||||
- `path_filter="src/google/adk/"` to only get ADK source files (reduces token usage)
|
||||
|
||||
4. Further filter the returned files:
|
||||
- **EXCLUDE** test files and `__init__.py` files
|
||||
- **IMPORTANT**: Do NOT exclude any file just because it has few changes.
|
||||
Even single-line changes to public APIs need documentation updates.
|
||||
- **PRIORITIZE** by importance:
|
||||
a) New files (status: "added") - ALWAYS include these
|
||||
b) CLI files (cli/) - often contain user-facing flags and options
|
||||
c) Tool files (tools/) - may contain new tools or tool parameters
|
||||
d) Core files (agents/, models/, sessions/, memory/, a2a/, flows/,
|
||||
plugins/, evaluation/)
|
||||
e) Files with many changes (high additions + deletions)
|
||||
|
||||
5. **Create a high-level release summary** based on the changed files:
|
||||
- Identify the main themes (e.g., "new tool X added", "refactoring of Y")
|
||||
- Note any files that appear related (e.g., same feature area)
|
||||
- This summary will be shared with individual file analyzers so they
|
||||
understand the bigger picture.
|
||||
|
||||
6. Group the filtered files into groups of at most {MAX_FILES_PER_GROUP} files each.
|
||||
- **IMPORTANT**: Group RELATED files together (same directory or feature)
|
||||
- Files that are part of the same feature should be in the same group
|
||||
- Each group should be independently analyzable
|
||||
|
||||
7. Call `save_release_info` to save:
|
||||
- start_tag, end_tag
|
||||
- compare_url
|
||||
- file_groups (the organized groups)
|
||||
- release_summary (the high-level summary you created)
|
||||
- all_changed_files (list of all file paths for cross-reference)
|
||||
|
||||
# 3. Output
|
||||
Provide a summary of:
|
||||
- Which releases are being compared
|
||||
- The high-level themes of this release
|
||||
- How many files changed in total
|
||||
- How many files are relevant for doc analysis
|
||||
- How many groups were created
|
||||
""",
|
||||
tools=[
|
||||
clone_or_pull_repo,
|
||||
list_releases,
|
||||
get_changed_files_summary,
|
||||
save_release_info,
|
||||
],
|
||||
output_key="planner_output",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent 2: File Group Analyzer (runs inside LoopAgent)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def file_analyzer_instruction(readonly_context: ReadonlyContext) -> str:
|
||||
"""Dynamic instruction that includes current state info."""
|
||||
start_tag = readonly_context.state.get("start_tag", "unknown")
|
||||
end_tag = readonly_context.state.get("end_tag", "unknown")
|
||||
release_summary = readonly_context.state.get("release_summary", "")
|
||||
|
||||
return f"""
|
||||
# 1. Identity
|
||||
You are the File Group Analyzer, responsible for analyzing a group of changed
|
||||
files and finding related documentation that needs updating.
|
||||
|
||||
# 2. Context
|
||||
- Comparing releases: {start_tag} to {end_tag}
|
||||
- Code repository: {CODE_OWNER}/{CODE_REPO}
|
||||
- Docs repository: {DOC_OWNER}/{DOC_REPO}
|
||||
- Docs local path: {LOCAL_REPOS_DIR_PATH}/{DOC_REPO}
|
||||
- Code local path: {LOCAL_REPOS_DIR_PATH}/{CODE_REPO}
|
||||
|
||||
## Release Summary (from Planner)
|
||||
{release_summary}
|
||||
|
||||
# 3. Workflow
|
||||
1. Call `get_next_file_group` to get the next group of files to analyze.
|
||||
- If status is "complete", call the `exit_loop` tool to exit the loop.
|
||||
|
||||
2. **FIRST**, call `get_release_context` to understand:
|
||||
- The overall release themes (to understand how your files fit in)
|
||||
- What other files were changed (to identify related changes)
|
||||
- What recommendations already exist (to AVOID DUPLICATES)
|
||||
|
||||
3. For each file in the group:
|
||||
a) Call `get_file_diff_for_release` to get the patch content for that file.
|
||||
b) Analyze the changes THOROUGHLY. Look for:
|
||||
**API Changes:**
|
||||
- New functions, classes, methods (especially public ones)
|
||||
- New parameters added to existing functions
|
||||
- New CLI arguments or flags (look for argparse, click decorators)
|
||||
- New environment variables (look for os.environ, getenv)
|
||||
- New tools or features being added
|
||||
- Renamed or deprecated functionality
|
||||
**Behavior Changes (even without API changes):**
|
||||
- Default values changed
|
||||
- Error handling or exception types changed
|
||||
- Return value format or content changed
|
||||
- Side effects added or removed
|
||||
- Performance characteristics changed
|
||||
- Edge case handling changed
|
||||
- Validation rules changed
|
||||
c) Consider how this file relates to OTHER changed files in this release.
|
||||
d) Generate MULTIPLE search patterns based on:
|
||||
- Class/function names that changed
|
||||
- Feature names mentioned in the file path
|
||||
- Keywords from the patch content (e.g., "local_storage", "allow_origins")
|
||||
- Tool names, parameter names, environment variable names
|
||||
|
||||
4. For EACH significant change, call `search_local_git_repo` to find related docs
|
||||
in {LOCAL_REPOS_DIR_PATH}/{DOC_REPO}/docs/
|
||||
- Search for the feature name, class name, or related keywords
|
||||
- **ALWAYS** pass `ignored_dirs=["api-reference"]` to skip auto-generated API
|
||||
reference docs (they are updated automatically by code, not manually)
|
||||
- If no docs found, recommend creating new documentation
|
||||
|
||||
5. Call `read_local_git_repo_file_content` to read the relevant doc files
|
||||
and check if they need updating.
|
||||
- **SKIP** any files under `docs/api-reference/` — these are auto-generated.
|
||||
|
||||
6. For each documentation update needed, create a recommendation with:
|
||||
- summary: Brief summary of what needs to change
|
||||
- doc_file: Relative path in the docs repo (e.g., docs/tools/google-search.md)
|
||||
- current_state: What the doc currently says
|
||||
- proposed_change: What it should say instead
|
||||
- reasoning: Why this update is needed
|
||||
- reference: The source code file path
|
||||
- related_files: Other changed files that are part of the same change (if any)
|
||||
|
||||
7. Call `save_group_recommendations` with all recommendations for this group.
|
||||
|
||||
8. After saving, output a brief summary of what you found for this group.
|
||||
|
||||
# 4. Rules
|
||||
- **BE THOROUGH**: Check EVERY change in the diff that could affect users.
|
||||
This includes API changes AND behavior changes (default values, error handling,
|
||||
return formats, side effects, etc.).
|
||||
- Focus on changes that users need to know about
|
||||
- Include behavior changes even if the API signature stays the same
|
||||
- If a change only affects auto-generated API reference docs, note that
|
||||
regeneration is needed instead of manual updates
|
||||
- **AVOID DUPLICATES**: Check existing_recommendations before adding new ones
|
||||
- **CROSS-REFERENCE**: If files in your group relate to files in other groups,
|
||||
mention this in your recommendation so the Summary agent can consolidate
|
||||
- **DON'T MISS ITEMS**: Better to have too many recommendations than too few.
|
||||
If unsure whether something needs documentation, include it.
|
||||
- For new features with no existing docs, recommend creating a new page
|
||||
"""
|
||||
|
||||
|
||||
file_group_analyzer = Agent(
|
||||
model=GEMINI_PRO_WITH_RETRY,
|
||||
name="file_group_analyzer",
|
||||
description=(
|
||||
"Analyzes a group of changed files and generates recommendations."
|
||||
),
|
||||
instruction=file_analyzer_instruction,
|
||||
include_contents="none",
|
||||
tools=[
|
||||
get_next_file_group,
|
||||
get_release_context, # Get global context to avoid duplicates
|
||||
get_file_diff_for_release,
|
||||
search_local_git_repo,
|
||||
read_local_git_repo_file_content,
|
||||
list_directory_contents,
|
||||
save_group_recommendations,
|
||||
exit_loop, # Call this when all groups are processed
|
||||
],
|
||||
output_key="analyzer_output",
|
||||
)
|
||||
|
||||
# Loop agent that processes file groups one at a time
|
||||
file_analysis_loop = LoopAgent(
|
||||
name="file_analysis_loop",
|
||||
sub_agents=[file_group_analyzer],
|
||||
max_iterations=50, # Safety limit
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent 3: Summary Agent
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def summary_instruction(readonly_context: ReadonlyContext) -> str:
|
||||
"""Dynamic instruction with release info."""
|
||||
start_tag = readonly_context.state.get("start_tag", "unknown")
|
||||
end_tag = readonly_context.state.get("end_tag", "unknown")
|
||||
|
||||
return f"""
|
||||
# 1. Identity
|
||||
You are the Summary Agent, responsible for compiling all recommendations into
|
||||
a well-formatted GitHub issue.
|
||||
|
||||
# 2. Workflow
|
||||
1. Call `get_all_recommendations` to retrieve all accumulated recommendations.
|
||||
|
||||
2. Organize the recommendations:
|
||||
- Group by importance: Feature changes > Bug fixes > Other
|
||||
- Within each group, sort by number of affected files
|
||||
- Remove duplicates or merge similar recommendations
|
||||
|
||||
3. Format the issue body using this template for each recommendation:
|
||||
```
|
||||
### N. **Summary of the change**
|
||||
|
||||
**Doc file**: path/to/doc.md
|
||||
|
||||
**Current state**:
|
||||
> Current content in the doc
|
||||
|
||||
**Proposed Change**:
|
||||
> What it should say instead
|
||||
|
||||
**Reasoning**:
|
||||
Explanation of why this change is necessary.
|
||||
|
||||
**Reference**: src/google/adk/path/to/file.py
|
||||
```
|
||||
|
||||
4. Create the GitHub issue:
|
||||
- Title: "Found docs updates needed from ADK python release {start_tag} to {end_tag}"
|
||||
- Include the compare link at the top
|
||||
- {APPROVAL_INSTRUCTION}
|
||||
|
||||
5. Call `create_issue` for {DOC_OWNER}/{DOC_REPO} with the formatted content.
|
||||
|
||||
# 3. Output
|
||||
Present a summary of:
|
||||
- Total recommendations created
|
||||
- Issue URL if created
|
||||
- Any notes about the analysis
|
||||
"""
|
||||
|
||||
|
||||
summary_agent = Agent(
|
||||
model=GEMINI_PRO_WITH_RETRY,
|
||||
name="summary_agent",
|
||||
description="Compiles recommendations and creates the GitHub issue.",
|
||||
instruction=summary_instruction,
|
||||
include_contents="none",
|
||||
tools=[
|
||||
get_all_recommendations,
|
||||
create_issue,
|
||||
],
|
||||
output_key="summary_output",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pipeline Agent: Sequential orchestration of the analysis
|
||||
# =============================================================================
|
||||
|
||||
analysis_pipeline = SequentialAgent(
|
||||
name="analysis_pipeline",
|
||||
description=(
|
||||
"Executes the release analysis pipeline: planning, file analysis, and"
|
||||
" summary generation."
|
||||
),
|
||||
sub_agents=[
|
||||
planner_agent,
|
||||
file_analysis_loop,
|
||||
summary_agent,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# Resume pipeline: skips planner, continues from where loop left off.
|
||||
# Deep copy agents since ADK agents can only have one parent.
|
||||
_resume_loop = copy.deepcopy(file_analysis_loop)
|
||||
_resume_loop.parent_agent = None
|
||||
_resume_summary = copy.deepcopy(summary_agent)
|
||||
_resume_summary.parent_agent = None
|
||||
|
||||
resume_pipeline = SequentialAgent(
|
||||
name="resume_pipeline",
|
||||
description=(
|
||||
"Resumes the release analysis pipeline from the file analysis loop,"
|
||||
" skipping the planning phase."
|
||||
),
|
||||
sub_agents=[
|
||||
_resume_loop,
|
||||
_resume_summary,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Root Agent: Entry point that understands user requests
|
||||
# =============================================================================
|
||||
|
||||
root_agent = Agent(
|
||||
model=GEMINI_PRO_WITH_RETRY,
|
||||
name="adk_release_analyzer",
|
||||
description=(
|
||||
"Analyzes ADK Python releases and generates documentation update"
|
||||
" recommendations."
|
||||
),
|
||||
instruction=f"""
|
||||
# 1. Identity
|
||||
You are the ADK Release Analyzer, a helper bot that analyzes changes between
|
||||
ADK Python releases and identifies documentation updates needed in the ADK
|
||||
Docs repository.
|
||||
|
||||
# 2. Capabilities
|
||||
You can help users in several ways:
|
||||
|
||||
## A. Full Release Analysis (delegate to analysis_pipeline)
|
||||
When users want a complete analysis of releases, delegate to the
|
||||
`analysis_pipeline` sub-agent. This will:
|
||||
- Clone/update repositories
|
||||
- Analyze all changed files
|
||||
- Generate recommendations
|
||||
- Create a GitHub issue
|
||||
|
||||
Use this when users say things like:
|
||||
- "Analyze the latest releases"
|
||||
- "Check what docs need updating for v1.15.0"
|
||||
- "Run a full analysis"
|
||||
|
||||
## B. Quick Queries (use your tools directly)
|
||||
For targeted questions, use your tools directly WITHOUT delegating:
|
||||
|
||||
- **"How should I modify doc1.md?"** → Use `search_local_git_repo` to find
|
||||
mentions of doc1.md in the codebase, then use `get_changed_files_summary`
|
||||
to see what changed, and provide specific guidance.
|
||||
|
||||
- **"What changed in the tools module?"** → Use `get_changed_files_summary`
|
||||
and filter for tools/ directory.
|
||||
|
||||
- **"Show me the recommendations from the last analysis"** → Use
|
||||
`get_all_recommendations` to retrieve stored recommendations.
|
||||
|
||||
- **"What releases are available?"** → Use `list_releases` directly.
|
||||
|
||||
# 3. Workflow Decision
|
||||
1. First, understand what the user is asking:
|
||||
- Full analysis request → delegate to analysis_pipeline
|
||||
- Specific question about a file/module → use tools directly
|
||||
- Query about previous results → use get_all_recommendations
|
||||
|
||||
2. For quick queries, ensure repos are cloned first using `clone_or_pull_repo`
|
||||
if needed.
|
||||
|
||||
3. Always explain what you're doing and provide clear, actionable answers.
|
||||
|
||||
# 4. Available Tools
|
||||
- `clone_or_pull_repo`: Ensure local repos are up to date
|
||||
- `list_releases`: See available release tags
|
||||
- `get_changed_files_summary`: Get list of changed files (lightweight)
|
||||
- `get_file_diff_for_release`: Get patch for a specific file
|
||||
- `search_local_git_repo`: Search for patterns in repos
|
||||
- `read_local_git_repo_file_content`: Read file contents
|
||||
- `get_all_recommendations`: Retrieve recommendations from previous analysis
|
||||
|
||||
# 5. Repository Info
|
||||
- Code repo: {CODE_OWNER}/{CODE_REPO} at {LOCAL_REPOS_DIR_PATH}/{CODE_REPO}
|
||||
- Docs repo: {DOC_OWNER}/{DOC_REPO} at {LOCAL_REPOS_DIR_PATH}/{DOC_REPO}
|
||||
""",
|
||||
tools=[
|
||||
clone_or_pull_repo,
|
||||
list_releases,
|
||||
get_changed_files_summary,
|
||||
get_file_diff_for_release,
|
||||
search_local_git_repo,
|
||||
read_local_git_repo_file_content,
|
||||
get_all_recommendations,
|
||||
],
|
||||
sub_agents=[analysis_pipeline],
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
# Copyright 2026 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 logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from adk_documentation.adk_release_analyzer import agent
|
||||
from adk_documentation.settings import CODE_OWNER
|
||||
from adk_documentation.settings import CODE_REPO
|
||||
from adk_documentation.settings import DOC_OWNER
|
||||
from adk_documentation.settings import DOC_REPO
|
||||
from adk_documentation.utils import call_agent_async
|
||||
from google.adk.cli.utils import logs
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions import DatabaseSessionService
|
||||
|
||||
APP_NAME = "adk_release_analyzer"
|
||||
USER_ID = "adk_release_analyzer_user"
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), "sessions.db")
|
||||
DB_URL = f"sqlite+aiosqlite:///{DB_PATH}"
|
||||
|
||||
logs.setup_adk_logger(level=logging.INFO)
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="ADK Release Analyzer")
|
||||
parser.add_argument(
|
||||
"--resume",
|
||||
action="store_true",
|
||||
help="Resume from the last session instead of starting fresh.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--start-tag",
|
||||
type=str,
|
||||
default=None,
|
||||
help="The older release tag (base) for comparison, e.g. v1.26.0.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--end-tag",
|
||||
type=str,
|
||||
default=None,
|
||||
help="The newer release tag (head) for comparison, e.g. v1.27.0.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
session_service = DatabaseSessionService(db_url=DB_URL)
|
||||
|
||||
if args.resume:
|
||||
# Find the most recent session to resume
|
||||
sessions_response = await session_service.list_sessions(
|
||||
app_name=APP_NAME, user_id=USER_ID
|
||||
)
|
||||
if not sessions_response.sessions:
|
||||
print("No previous session found. Starting fresh.")
|
||||
args.resume = False
|
||||
|
||||
if args.resume:
|
||||
# Resume: use existing session with resume_pipeline (skip planner)
|
||||
last_session = sessions_response.sessions[-1]
|
||||
session_id = last_session.id
|
||||
session = await session_service.get_session(
|
||||
app_name=APP_NAME, user_id=USER_ID, session_id=session_id
|
||||
)
|
||||
state = session.state
|
||||
group_index = state.get("current_group_index", 0)
|
||||
total_groups = len(state.get("file_groups", []))
|
||||
num_recs = len(state.get("recommendations", []))
|
||||
print(f"Resuming session {session_id}")
|
||||
print(
|
||||
f" Progress: group {group_index + 1}/{total_groups},"
|
||||
f" {num_recs} recommendations so far"
|
||||
)
|
||||
print(
|
||||
f" Release: {state.get('start_tag', '?')} →"
|
||||
f" {state.get('end_tag', '?')}"
|
||||
)
|
||||
|
||||
runner = Runner(
|
||||
agent=agent.resume_pipeline,
|
||||
app_name=APP_NAME,
|
||||
session_service=session_service,
|
||||
)
|
||||
prompt = "Resume analyzing the remaining file groups."
|
||||
else:
|
||||
# Fresh run
|
||||
runner = Runner(
|
||||
agent=agent.root_agent,
|
||||
app_name=APP_NAME,
|
||||
session_service=session_service,
|
||||
)
|
||||
session = await session_service.create_session(
|
||||
app_name=APP_NAME,
|
||||
user_id=USER_ID,
|
||||
)
|
||||
session_id = session.id
|
||||
if args.start_tag and args.end_tag:
|
||||
prompt = (
|
||||
f"Please analyze ADK Python releases from {args.start_tag} to"
|
||||
f" {args.end_tag}!"
|
||||
)
|
||||
elif args.end_tag:
|
||||
prompt = (
|
||||
f"Please analyze the ADK Python release {args.end_tag} against its"
|
||||
" previous release!"
|
||||
)
|
||||
else:
|
||||
prompt = "Please analyze the most recent two releases of ADK Python!"
|
||||
|
||||
print(f"Session ID: {session_id}")
|
||||
print("-" * 80)
|
||||
|
||||
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 analyzing {CODE_OWNER}/{CODE_REPO} releases for"
|
||||
f" {DOC_OWNER}/{DOC_REPO} updates 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(
|
||||
"Triaging 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,33 @@
|
||||
# Copyright 2026 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_TOKEN = os.getenv("GITHUB_TOKEN")
|
||||
if not GITHUB_TOKEN:
|
||||
raise ValueError("GITHUB_TOKEN environment variable not set")
|
||||
|
||||
DOC_OWNER = os.getenv("DOC_OWNER", "google")
|
||||
CODE_OWNER = os.getenv("CODE_OWNER", "google")
|
||||
DOC_REPO = os.getenv("DOC_REPO", "adk-docs")
|
||||
CODE_REPO = os.getenv("CODE_REPO", "adk-python")
|
||||
LOCAL_REPOS_DIR_PATH = os.getenv("LOCAL_REPOS_DIR_PATH", "/tmp")
|
||||
|
||||
IS_INTERACTIVE = os.getenv("INTERACTIVE", "1").lower() in ["true", "1"]
|
||||
@@ -0,0 +1,823 @@
|
||||
# Copyright 2026 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 datetime import datetime
|
||||
import os
|
||||
import subprocess
|
||||
from subprocess import CompletedProcess
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from adk_documentation.settings import GITHUB_BASE_URL
|
||||
from adk_documentation.utils import error_response
|
||||
from adk_documentation.utils import get_paginated_request
|
||||
from adk_documentation.utils import get_request
|
||||
from adk_documentation.utils import patch_request
|
||||
from adk_documentation.utils import post_request
|
||||
import requests
|
||||
|
||||
|
||||
def list_releases(repo_owner: str, repo_name: str) -> Dict[str, Any]:
|
||||
"""Lists all releases for a repository.
|
||||
|
||||
This function retrieves all releases and for each one, returns its ID,
|
||||
creation time, publication time, and associated tag name. It handles
|
||||
pagination to ensure all releases are fetched.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and a list of releases.
|
||||
"""
|
||||
# The initial URL for the releases endpoint
|
||||
# per_page=100 is used to reduce the number of API calls
|
||||
url = (
|
||||
f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/releases?per_page=100"
|
||||
)
|
||||
|
||||
try:
|
||||
all_releases_data = get_paginated_request(url)
|
||||
|
||||
# Format the response to include only the requested fields
|
||||
formatted_releases = []
|
||||
for release in all_releases_data:
|
||||
formatted_releases.append({
|
||||
"id": release.get("id"),
|
||||
"tag_name": release.get("tag_name"),
|
||||
"created_at": release.get("created_at"),
|
||||
"published_at": release.get("published_at"),
|
||||
})
|
||||
|
||||
return {"status": "success", "releases": formatted_releases}
|
||||
except requests.exceptions.HTTPError as e:
|
||||
return error_response(f"HTTP Error: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Request Error: {e}")
|
||||
|
||||
|
||||
def get_changed_files_between_releases(
|
||||
repo_owner: str, repo_name: str, start_tag: str, end_tag: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Gets changed files and their modifications between two release tags.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
start_tag: The older tag (base) for the comparison.
|
||||
end_tag: The newer tag (head) for the comparison.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and a list of changed files.
|
||||
Each file includes its name, status (added, removed, modified),
|
||||
and the patch/diff content.
|
||||
"""
|
||||
# The 'basehead' parameter is specified as 'base...head'.
|
||||
url = f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/compare/{start_tag}...{end_tag}"
|
||||
|
||||
try:
|
||||
comparison_data = get_request(url)
|
||||
|
||||
# The API returns a 'files' key with the list of changed files.
|
||||
changed_files = comparison_data.get("files", [])
|
||||
|
||||
# Extract just the information we need for a cleaner output
|
||||
formatted_files = []
|
||||
for file_data in changed_files:
|
||||
formatted_files.append({
|
||||
"relative_path": file_data.get("filename"),
|
||||
"status": file_data.get("status"),
|
||||
"additions": file_data.get("additions"),
|
||||
"deletions": file_data.get("deletions"),
|
||||
"changes": file_data.get("changes"),
|
||||
"patch": file_data.get(
|
||||
"patch", "No patch available."
|
||||
), # The diff content
|
||||
})
|
||||
return {"status": "success", "changed_files": formatted_files}
|
||||
except requests.exceptions.HTTPError as e:
|
||||
return error_response(f"HTTP Error: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Request Error: {e}")
|
||||
|
||||
|
||||
def clone_or_pull_repo(
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
local_path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Clones a GitHub repository to a local folder using owner and repo name.
|
||||
|
||||
If the folder already exists and is a valid Git repository, it pulls the
|
||||
latest changes instead.
|
||||
|
||||
Args:
|
||||
repo_owner: The username or organization that owns the repository.
|
||||
repo_name: The name of the repository.
|
||||
local_path: The local directory path where the repository should be cloned
|
||||
or updated.
|
||||
|
||||
Returns:
|
||||
A dictionary indicating the status of the operation, output message, and
|
||||
the head commit hash.
|
||||
"""
|
||||
repo_url = f"git@github.com:{repo_owner}/{repo_name}.git"
|
||||
|
||||
try:
|
||||
# Check local path and decide to clone or pull
|
||||
if os.path.exists(local_path):
|
||||
git_dir_path = os.path.join(local_path, ".git")
|
||||
if os.path.isdir(git_dir_path):
|
||||
print(f"Repository exists at '{local_path}'. Pulling latest changes...")
|
||||
try:
|
||||
output = _get_pull(local_path)
|
||||
except subprocess.CalledProcessError as e:
|
||||
return error_response(f"git pull failed: {e.stderr}")
|
||||
else:
|
||||
return error_response(
|
||||
f"Path '{local_path}' exists but is not a Git repository."
|
||||
)
|
||||
else:
|
||||
print(f"Cloning from {repo_owner}/{repo_name} into '{local_path}'...")
|
||||
try:
|
||||
output = _get_clone(repo_url, local_path)
|
||||
except subprocess.CalledProcessError as e:
|
||||
return error_response(f"git clone failed: {e.stderr}")
|
||||
head_commit_sha = _find_head_commit_sha(local_path)
|
||||
except FileNotFoundError:
|
||||
return error_response("Error: 'git' command not found. Is Git installed?")
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return error_response(f"Command timeout: {e}")
|
||||
except (subprocess.CalledProcessError, OSError, ValueError) as e:
|
||||
return error_response(f"An unexpected error occurred: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"output": output,
|
||||
"head_commit_sha": head_commit_sha,
|
||||
}
|
||||
|
||||
|
||||
def read_local_git_repo_file_content(file_path: str) -> Dict[str, Any]:
|
||||
"""Reads the content of a specified file in a local Git repository.
|
||||
|
||||
Args:
|
||||
file_path: The full, absolute path to the file.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status, content of the file, and the head
|
||||
commit hash.
|
||||
"""
|
||||
print(f"Attempting to read file from path: {file_path}")
|
||||
if not os.path.isabs(file_path):
|
||||
return error_response(
|
||||
f"file_path must be an absolute path, got: {file_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
dir_path = os.path.dirname(file_path)
|
||||
head_commit_sha = _find_head_commit_sha(dir_path)
|
||||
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||
head_commit_sha = "unknown"
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
lines = content.splitlines()
|
||||
numbered_lines = [f"{i + 1}: {line}" for i, line in enumerate(lines)]
|
||||
numbered_content = "\n".join(numbered_lines)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"file_path": file_path,
|
||||
"content": numbered_content,
|
||||
"head_commit_sha": head_commit_sha,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return error_response(f"Error: File not found at {file_path}")
|
||||
except (IOError, OSError) as e:
|
||||
return error_response(f"An unexpected error occurred: {e}")
|
||||
|
||||
|
||||
def list_directory_contents(directory_path: str) -> Dict[str, Any]:
|
||||
"""Recursively lists all files and directories within a specified directory.
|
||||
|
||||
Args:
|
||||
directory_path: The full, absolute path to the directory.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and a map where keys are directory
|
||||
paths relative to the initial directory_path, and values are lists of
|
||||
their contents.
|
||||
Returns an error message if the directory cannot be accessed.
|
||||
"""
|
||||
print(
|
||||
f"Attempting to recursively list contents of directory: {directory_path}"
|
||||
)
|
||||
if not os.path.isdir(directory_path):
|
||||
return error_response(f"Error: Directory not found at {directory_path}")
|
||||
|
||||
directory_map = {}
|
||||
try:
|
||||
for root, dirs, files in os.walk(directory_path):
|
||||
# Filter out hidden directories from traversal and from the result
|
||||
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
||||
# Filter out hidden files
|
||||
non_hidden_files = [f for f in files if not f.startswith(".")]
|
||||
|
||||
relative_path = os.path.relpath(root, directory_path)
|
||||
directory_map[relative_path] = dirs + non_hidden_files
|
||||
return {
|
||||
"status": "success",
|
||||
"directory_path": directory_path,
|
||||
"directory_map": directory_map,
|
||||
}
|
||||
except (IOError, OSError) as e:
|
||||
return error_response(f"An unexpected error occurred: {e}")
|
||||
|
||||
|
||||
def search_local_git_repo(
|
||||
directory_path: str,
|
||||
pattern: str,
|
||||
extensions: Optional[List[str]] = None,
|
||||
ignored_dirs: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Searches a local Git repository for a pattern.
|
||||
|
||||
Args:
|
||||
directory_path: The absolute path to the local Git repository.
|
||||
pattern: The search pattern (can be a simple string or regex for git
|
||||
grep).
|
||||
extensions: The list of file extensions to search, e.g. ["py", "md"]. If
|
||||
None, all extensions will be searched.
|
||||
ignored_dirs: The list of directories to ignore, e.g. ["tests"]. If None,
|
||||
no directories will be ignored.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status, and a list of match details (relative
|
||||
file path to the directory_path, line number, content).
|
||||
"""
|
||||
print(
|
||||
f"Attempting to search for pattern: {pattern} in directory:"
|
||||
f" {directory_path}, with extensions: {extensions}"
|
||||
)
|
||||
try:
|
||||
grep_process = _git_grep(directory_path, pattern, extensions, ignored_dirs)
|
||||
if grep_process.returncode > 1:
|
||||
return error_response(f"git grep failed: {grep_process.stderr}")
|
||||
|
||||
matches = []
|
||||
if grep_process.stdout:
|
||||
for line in grep_process.stdout.strip().split("\n"):
|
||||
try:
|
||||
file_path, line_number_str, line_content = line.split(":", 2)
|
||||
matches.append({
|
||||
"file_path": file_path,
|
||||
"line_number": int(line_number_str),
|
||||
"line_content": line_content.strip(),
|
||||
})
|
||||
except ValueError:
|
||||
return error_response(
|
||||
f"Error: Failed to parse line: {line} from git grep output."
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"matches": matches,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return error_response(f"Directory not found: {directory_path}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
return error_response(f"git grep failed: {e.stderr}")
|
||||
except (IOError, OSError, ValueError) as e:
|
||||
return error_response(f"An unexpected error occurred: {e}")
|
||||
|
||||
|
||||
def create_pull_request_from_changes(
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
local_path: str,
|
||||
base_branch: str,
|
||||
changes: Dict[str, str],
|
||||
commit_message: str,
|
||||
pr_title: str,
|
||||
pr_body: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Creates a new branch, applies file changes, commits, pushes, and creates a PR.
|
||||
|
||||
Args:
|
||||
repo_owner: The username or organization that owns the repository.
|
||||
repo_name: The name of the repository.
|
||||
local_path: The local absolute path to the cloned repository.
|
||||
base_branch: The name of the branch to merge the changes into (e.g.,
|
||||
"main").
|
||||
changes: A dictionary where keys are file paths relative to the repo root
|
||||
and values are the new and full content for those files.
|
||||
commit_message: The message for the git commit.
|
||||
pr_title: The title for the pull request.
|
||||
pr_body: The body/description for the pull request.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and the pull request object on success,
|
||||
or an error message on failure.
|
||||
"""
|
||||
try:
|
||||
# Step 0: Ensure we are on the base branch and it's up to date.
|
||||
_run_git_command(["checkout", base_branch], local_path)
|
||||
_run_git_command(["pull", "origin", base_branch], local_path)
|
||||
|
||||
# Step 1: Create a new, unique branch from the base branch.
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
new_branch = f"agent-changes-{timestamp}"
|
||||
_run_git_command(["checkout", "-b", new_branch], local_path)
|
||||
print(f"Created and switched to new branch: {new_branch}")
|
||||
|
||||
# Step 2: Apply the file changes.
|
||||
if not changes:
|
||||
return error_response("No changes provided to apply.")
|
||||
|
||||
for relative_path, new_content in changes.items():
|
||||
full_path = os.path.join(local_path, relative_path)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
with open(full_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
print(f"Applied changes to {relative_path}")
|
||||
|
||||
# Step 3: Stage the changes.
|
||||
_run_git_command(["add", "."], local_path)
|
||||
print("Staged all changes.")
|
||||
|
||||
# Step 4: Commit the changes.
|
||||
_run_git_command(["commit", "-m", commit_message], local_path)
|
||||
print(f"Committed changes with message: '{commit_message}'")
|
||||
|
||||
# Step 5: Push the new branch to the remote repository.
|
||||
_run_git_command(["push", "-u", "origin", new_branch], local_path)
|
||||
print(f"Pushed branch '{new_branch}' to origin.")
|
||||
|
||||
# Step 6: Create the pull request via GitHub API.
|
||||
url = f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/pulls"
|
||||
payload = {
|
||||
"title": pr_title,
|
||||
"body": pr_body,
|
||||
"head": new_branch,
|
||||
"base": base_branch,
|
||||
}
|
||||
pr_response = post_request(url, payload)
|
||||
print(f"Successfully created pull request: {pr_response.get('html_url')}")
|
||||
|
||||
return {"status": "success", "pull_request": pr_response}
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
return error_response(f"A git command failed: {e.stderr}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"GitHub API request failed: {e}")
|
||||
except (IOError, OSError) as e:
|
||||
return error_response(f"A file system error occurred: {e}")
|
||||
|
||||
|
||||
def get_issue(
|
||||
repo_owner: str, repo_name: str, issue_number: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Get the details of the specified issue number.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
issue_number: issue number of the GitHub issue.
|
||||
|
||||
Returns:
|
||||
The status of this request, with the issue details when successful.
|
||||
"""
|
||||
url = (
|
||||
f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/issues/{issue_number}"
|
||||
)
|
||||
try:
|
||||
response = get_request(url)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
return {"status": "success", "issue": response}
|
||||
|
||||
|
||||
def create_issue(
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
title: str,
|
||||
body: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new issue in the specified repository.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
title: The title of the issue.
|
||||
body: The body of the issue.
|
||||
|
||||
Returns:
|
||||
The status of this request, with the issue details when successful.
|
||||
"""
|
||||
url = f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/issues"
|
||||
payload = {"title": title, "body": body, "labels": ["docs updates"]}
|
||||
try:
|
||||
response = post_request(url, payload)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
return {"status": "success", "issue": response}
|
||||
|
||||
|
||||
def update_issue(
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
issue_number: int,
|
||||
title: str,
|
||||
body: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Update an existing issue in the specified repository.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
issue_number: The number of the issue to update.
|
||||
title: The title of the issue.
|
||||
body: The body of the issue.
|
||||
|
||||
Returns:
|
||||
The status of this request, with the issue details when successful.
|
||||
"""
|
||||
url = (
|
||||
f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/issues/{issue_number}"
|
||||
)
|
||||
payload = {"title": title, "body": body}
|
||||
try:
|
||||
response = patch_request(url, payload)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
return {"status": "success", "issue": response}
|
||||
|
||||
|
||||
def _run_git_command(command: List[str], cwd: str) -> CompletedProcess[str]:
|
||||
"""A helper to run a git command and raise an exception on error."""
|
||||
base_command = ["git"]
|
||||
process = subprocess.run(
|
||||
base_command + command,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True, # This will raise CalledProcessError if the command fails
|
||||
)
|
||||
return process
|
||||
|
||||
|
||||
def _find_head_commit_sha(repo_path: str) -> str:
|
||||
"""Checks the head commit hash of a Git repository."""
|
||||
head_sha_command = ["git", "rev-parse", "HEAD"]
|
||||
head_sha_process = subprocess.run(
|
||||
head_sha_command,
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
current_commit_sha = head_sha_process.stdout.strip()
|
||||
return current_commit_sha
|
||||
|
||||
|
||||
def _get_pull(repo_path: str) -> str:
|
||||
"""Pulls the latest changes from a Git repository."""
|
||||
pull_process = subprocess.run(
|
||||
["git", "pull"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return pull_process.stdout.strip()
|
||||
|
||||
|
||||
def _get_clone(repo_url: str, repo_path: str) -> str:
|
||||
"""Clones a Git repository to a local folder."""
|
||||
clone_process = subprocess.run(
|
||||
["git", "clone", repo_url, repo_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return clone_process.stdout.strip()
|
||||
|
||||
|
||||
def _git_grep(
|
||||
repo_path: str,
|
||||
pattern: str,
|
||||
extensions: Optional[List[str]] = None,
|
||||
ignored_dirs: Optional[List[str]] = None,
|
||||
) -> subprocess.CompletedProcess[Any]:
|
||||
"""Uses 'git grep' to find all matching lines in a Git repository."""
|
||||
grep_command = [
|
||||
"git",
|
||||
"grep",
|
||||
"-n",
|
||||
"-I",
|
||||
"-E",
|
||||
"--ignore-case",
|
||||
"-e",
|
||||
pattern,
|
||||
]
|
||||
pathspecs = []
|
||||
if extensions:
|
||||
pathspecs.extend([f"*.{ext}" for ext in extensions])
|
||||
if ignored_dirs:
|
||||
pathspecs.extend([f":(exclude){d}" for d in ignored_dirs])
|
||||
|
||||
if pathspecs:
|
||||
grep_command.append("--")
|
||||
grep_command.extend(pathspecs)
|
||||
|
||||
grep_process = subprocess.run(
|
||||
grep_command,
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False, # Don't raise error on non-zero exit code (1 means no match)
|
||||
)
|
||||
return grep_process
|
||||
|
||||
|
||||
def get_file_diff_for_release(
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
start_tag: str,
|
||||
end_tag: str,
|
||||
file_path: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Gets the diff/patch for a specific file between two release tags.
|
||||
|
||||
This is useful for incremental processing where you want to analyze
|
||||
one file at a time instead of loading all changes at once.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
start_tag: The older tag (base) for the comparison.
|
||||
end_tag: The newer tag (head) for the comparison.
|
||||
file_path: The relative path of the file to get the diff for.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and the file diff details.
|
||||
"""
|
||||
url = f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/compare/{start_tag}...{end_tag}"
|
||||
|
||||
try:
|
||||
comparison_data = get_request(url)
|
||||
changed_files = comparison_data.get("files", [])
|
||||
|
||||
for file_data in changed_files:
|
||||
if file_data.get("filename") == file_path:
|
||||
return {
|
||||
"status": "success",
|
||||
"file": {
|
||||
"relative_path": file_data.get("filename"),
|
||||
"status": file_data.get("status"),
|
||||
"additions": file_data.get("additions"),
|
||||
"deletions": file_data.get("deletions"),
|
||||
"changes": file_data.get("changes"),
|
||||
"patch": file_data.get("patch", "No patch available."),
|
||||
},
|
||||
}
|
||||
|
||||
return error_response(f"File {file_path} not found in the comparison.")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
return error_response(f"HTTP Error: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Request Error: {e}")
|
||||
|
||||
|
||||
def get_changed_files_summary(
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
start_tag: str,
|
||||
end_tag: str,
|
||||
local_repo_path: Optional[str] = None,
|
||||
path_filter: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Gets a summary of changed files between two releases without patches.
|
||||
|
||||
This function uses local git commands when local_repo_path is provided,
|
||||
which avoids the GitHub API's 300-file limit for large comparisons.
|
||||
Falls back to GitHub API if local_repo_path is not provided or invalid.
|
||||
|
||||
Args:
|
||||
repo_owner: The name of the repository owner.
|
||||
repo_name: The name of the repository.
|
||||
start_tag: The older tag (base) for the comparison.
|
||||
end_tag: The newer tag (head) for the comparison.
|
||||
local_repo_path: Optional absolute path to local git repo. If provided
|
||||
and valid, uses git diff instead of GitHub API to get complete
|
||||
file list (avoids 300-file limit).
|
||||
path_filter: Optional path prefix to filter files. Only files whose
|
||||
path starts with this prefix will be included. Example:
|
||||
"src/google/adk/" to only include ADK source files.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and a summary of changed files.
|
||||
"""
|
||||
# Use local git if valid path is provided (avoids GitHub API 300-file limit)
|
||||
if local_repo_path and os.path.isdir(os.path.join(local_repo_path, ".git")):
|
||||
return _get_changed_files_from_local_git(
|
||||
local_repo_path, start_tag, end_tag, repo_owner, repo_name, path_filter
|
||||
)
|
||||
|
||||
# Fall back to GitHub API (limited to 300 files)
|
||||
url = f"{GITHUB_BASE_URL}/repos/{repo_owner}/{repo_name}/compare/{start_tag}...{end_tag}"
|
||||
|
||||
try:
|
||||
comparison_data = get_request(url)
|
||||
changed_files = comparison_data.get("files", [])
|
||||
|
||||
# Group files by directory for easier processing
|
||||
files_by_dir: Dict[str, List[Dict[str, Any]]] = {}
|
||||
formatted_files = []
|
||||
|
||||
for file_data in changed_files:
|
||||
file_info = {
|
||||
"relative_path": file_data.get("filename"),
|
||||
"status": file_data.get("status"),
|
||||
"additions": file_data.get("additions"),
|
||||
"deletions": file_data.get("deletions"),
|
||||
"changes": file_data.get("changes"),
|
||||
}
|
||||
formatted_files.append(file_info)
|
||||
|
||||
# Group by top-level directory
|
||||
path = file_data.get("filename", "")
|
||||
parts = path.split("/")
|
||||
top_dir = parts[0] if parts else "root"
|
||||
if top_dir not in files_by_dir:
|
||||
files_by_dir[top_dir] = []
|
||||
files_by_dir[top_dir].append(file_info)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"total_files": len(formatted_files),
|
||||
"files": formatted_files,
|
||||
"files_by_directory": files_by_dir,
|
||||
"compare_url": (
|
||||
f"https://github.com/{repo_owner}/{repo_name}"
|
||||
f"/compare/{start_tag}...{end_tag}"
|
||||
),
|
||||
"note": (
|
||||
(
|
||||
"Using GitHub API which is limited to 300 files. "
|
||||
"Provide local_repo_path to get complete file list."
|
||||
)
|
||||
if len(formatted_files) >= 300
|
||||
else None
|
||||
),
|
||||
}
|
||||
except requests.exceptions.HTTPError as e:
|
||||
return error_response(f"HTTP Error: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Request Error: {e}")
|
||||
|
||||
|
||||
def _get_changed_files_from_local_git(
|
||||
local_repo_path: str,
|
||||
start_tag: str,
|
||||
end_tag: str,
|
||||
repo_owner: str,
|
||||
repo_name: str,
|
||||
path_filter: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Gets changed files using local git commands (no file limit).
|
||||
|
||||
Args:
|
||||
local_repo_path: Path to local git repository.
|
||||
start_tag: The older tag (base) for the comparison.
|
||||
end_tag: The newer tag (head) for the comparison.
|
||||
repo_owner: Repository owner for compare URL.
|
||||
repo_name: Repository name for compare URL.
|
||||
path_filter: Optional path prefix to filter files.
|
||||
|
||||
Returns:
|
||||
A dictionary containing the status and a summary of changed files.
|
||||
"""
|
||||
try:
|
||||
# Fetch tags to ensure we have them
|
||||
subprocess.run(
|
||||
["git", "fetch", "--tags"],
|
||||
cwd=local_repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Get list of changed files with their status
|
||||
diff_result = subprocess.run(
|
||||
["git", "diff", "--name-status", f"{start_tag}...{end_tag}"],
|
||||
cwd=local_repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Get numstat for additions/deletions
|
||||
numstat_result = subprocess.run(
|
||||
["git", "diff", "--numstat", f"{start_tag}...{end_tag}"],
|
||||
cwd=local_repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Parse numstat output (additions, deletions, filename)
|
||||
file_stats: Dict[str, Dict[str, int]] = {}
|
||||
for line in numstat_result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 3:
|
||||
additions = int(parts[0]) if parts[0] != "-" else 0
|
||||
deletions = int(parts[1]) if parts[1] != "-" else 0
|
||||
filename = parts[2]
|
||||
file_stats[filename] = {
|
||||
"additions": additions,
|
||||
"deletions": deletions,
|
||||
"changes": additions + deletions,
|
||||
}
|
||||
|
||||
# Parse name-status output and combine with numstat
|
||||
status_map = {
|
||||
"A": "added",
|
||||
"D": "removed",
|
||||
"M": "modified",
|
||||
"R": "renamed",
|
||||
"C": "copied",
|
||||
}
|
||||
|
||||
files_by_dir: Dict[str, List[Dict[str, Any]]] = {}
|
||||
formatted_files = []
|
||||
|
||||
for line in diff_result.stdout.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 2:
|
||||
status_code = parts[0][0] # First char is the status
|
||||
filename = parts[-1] # Last part is filename (handles renames)
|
||||
|
||||
# Apply path filter if specified
|
||||
if path_filter and not filename.startswith(path_filter):
|
||||
continue
|
||||
|
||||
stats = file_stats.get(
|
||||
filename,
|
||||
{
|
||||
"additions": 0,
|
||||
"deletions": 0,
|
||||
"changes": 0,
|
||||
},
|
||||
)
|
||||
|
||||
file_info = {
|
||||
"relative_path": filename,
|
||||
"status": status_map.get(status_code, "modified"),
|
||||
"additions": stats["additions"],
|
||||
"deletions": stats["deletions"],
|
||||
"changes": stats["changes"],
|
||||
}
|
||||
formatted_files.append(file_info)
|
||||
|
||||
# Group by top-level directory
|
||||
dir_parts = filename.split("/")
|
||||
top_dir = dir_parts[0] if dir_parts else "root"
|
||||
if top_dir not in files_by_dir:
|
||||
files_by_dir[top_dir] = []
|
||||
files_by_dir[top_dir].append(file_info)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"total_files": len(formatted_files),
|
||||
"files": formatted_files,
|
||||
"files_by_directory": files_by_dir,
|
||||
"compare_url": (
|
||||
f"https://github.com/{repo_owner}/{repo_name}"
|
||||
f"/compare/{start_tag}...{end_tag}"
|
||||
),
|
||||
}
|
||||
except subprocess.CalledProcessError as e:
|
||||
return error_response(f"Git command failed: {e.stderr}")
|
||||
except (OSError, ValueError) as e:
|
||||
return error_response(f"Error getting changed files: {e}")
|
||||
@@ -0,0 +1,144 @@
|
||||
# Copyright 2026 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 re
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Tuple
|
||||
|
||||
from adk_documentation.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 get_request(
|
||||
url: str,
|
||||
headers: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Executes a GET request."""
|
||||
if headers is None:
|
||||
headers = HEADERS
|
||||
if params is None:
|
||||
params = {}
|
||||
response = requests.get(url, headers=headers, params=params, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def get_paginated_request(
|
||||
url: str, headers: dict[str, Any] | None = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Executes GET requests and follows 'next' pagination links to fetch all results."""
|
||||
if headers is None:
|
||||
headers = HEADERS
|
||||
|
||||
results = []
|
||||
while url:
|
||||
response = requests.get(url, headers=headers, timeout=60)
|
||||
response.raise_for_status()
|
||||
results.extend(response.json())
|
||||
url = response.links.get("next", {}).get("url")
|
||||
return results
|
||||
|
||||
|
||||
def post_request(url: str, payload: Any) -> Dict[str, Any]:
|
||||
response = requests.post(url, headers=HEADERS, json=payload, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def patch_request(url: str, payload: Any) -> Dict[str, Any]:
|
||||
response = requests.patch(url, headers=HEADERS, json=payload, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def parse_suggestions(issue_body: str) -> List[Tuple[int, str]]:
|
||||
"""Parse numbered suggestions from issue body.
|
||||
|
||||
Supports multiple formats:
|
||||
- Format A (markdown headers): "### 1. Title"
|
||||
- Format B (numbered list with bold): "1. **Title**"
|
||||
|
||||
Args:
|
||||
issue_body: The body text of the GitHub issue.
|
||||
|
||||
Returns:
|
||||
A list of tuples, where each tuple contains:
|
||||
- The suggestion number (1-based)
|
||||
- The full text of that suggestion
|
||||
"""
|
||||
# Try different patterns in order of preference
|
||||
patterns = [
|
||||
# Format A: "### 1. Title" (markdown header with number)
|
||||
(r"(?=^###\s+\d+\.)", r"^###\s+(\d+)\."),
|
||||
# Format B: "1. **Title**" (numbered list with bold)
|
||||
(r"(?=^\d+\.\s+\*\*)", r"^(\d+)\.\s+\*\*"),
|
||||
]
|
||||
|
||||
for split_pattern, match_pattern in patterns:
|
||||
parts = re.split(split_pattern, issue_body, flags=re.MULTILINE)
|
||||
|
||||
suggestions = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
match = re.match(match_pattern, part)
|
||||
if match:
|
||||
suggestion_num = int(match.group(1))
|
||||
suggestions.append((suggestion_num, part))
|
||||
|
||||
# If we found suggestions with this pattern, return them
|
||||
if suggestions:
|
||||
return suggestions
|
||||
|
||||
return []
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,241 @@
|
||||
# Copyright 2026 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 pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from adk_issue_formatting_agent.settings import GITHUB_BASE_URL
|
||||
from adk_issue_formatting_agent.settings import IS_INTERACTIVE
|
||||
from adk_issue_formatting_agent.settings import OWNER
|
||||
from adk_issue_formatting_agent.settings import REPO
|
||||
from adk_issue_formatting_agent.utils import error_response
|
||||
from adk_issue_formatting_agent.utils import get_request
|
||||
from adk_issue_formatting_agent.utils import post_request
|
||||
from adk_issue_formatting_agent.utils import read_file
|
||||
from google.adk import Agent
|
||||
import requests
|
||||
|
||||
BUG_REPORT_TEMPLATE = read_file(
|
||||
Path(__file__).parent / "../../../../.github/ISSUE_TEMPLATE/bug_report.md"
|
||||
)
|
||||
FEATURE_REQUEST_TEMPLATE = read_file(
|
||||
Path(__file__).parent
|
||||
/ "../../../../.github/ISSUE_TEMPLATE/feature_request.md"
|
||||
)
|
||||
|
||||
APPROVAL_INSTRUCTION = (
|
||||
"**Do not** wait or ask for user approval or confirmation for adding the"
|
||||
" comment."
|
||||
)
|
||||
if IS_INTERACTIVE:
|
||||
APPROVAL_INSTRUCTION = (
|
||||
"Ask for user approval or confirmation for adding the comment."
|
||||
)
|
||||
|
||||
|
||||
def list_open_issues(issue_count: int) -> dict[str, Any]:
|
||||
"""List most recent `issue_count` number of open issues in the repo.
|
||||
|
||||
Args:
|
||||
issue_count: number of issues to return
|
||||
|
||||
Returns:
|
||||
The status of this request, with a list of issues when successful.
|
||||
"""
|
||||
url = f"{GITHUB_BASE_URL}/search/issues"
|
||||
query = f"repo:{OWNER}/{REPO} is:open is:issue"
|
||||
params = {
|
||||
"q": query,
|
||||
"sort": "created",
|
||||
"order": "desc",
|
||||
"per_page": issue_count,
|
||||
"page": 1,
|
||||
}
|
||||
|
||||
try:
|
||||
response = get_request(url, params)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
issues = response.get("items", None)
|
||||
return {"status": "success", "issues": issues}
|
||||
|
||||
|
||||
def get_issue(issue_number: int) -> dict[str, Any]:
|
||||
"""Get the details of the specified issue number.
|
||||
|
||||
Args:
|
||||
issue_number: issue number of the GitHub issue.
|
||||
|
||||
Returns:
|
||||
The status of this request, with the issue details when successful.
|
||||
"""
|
||||
url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{issue_number}"
|
||||
try:
|
||||
response = get_request(url)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
return {"status": "success", "issue": response}
|
||||
|
||||
|
||||
def add_comment_to_issue(issue_number: int, comment: str) -> dict[str, any]:
|
||||
"""Add the specified comment to the given issue number.
|
||||
|
||||
Args:
|
||||
issue_number: issue number of the GitHub issue
|
||||
comment: comment to add
|
||||
|
||||
Returns:
|
||||
The status of this request, with the applied comment when successful.
|
||||
"""
|
||||
print(f"Attempting to add comment '{comment}' to issue #{issue_number}")
|
||||
url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{issue_number}/comments"
|
||||
payload = {"body": comment}
|
||||
|
||||
try:
|
||||
response = post_request(url, payload)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
return {
|
||||
"status": "success",
|
||||
"added_comment": response,
|
||||
}
|
||||
|
||||
|
||||
def list_comments_on_issue(issue_number: int) -> dict[str, any]:
|
||||
"""List all comments on the given issue number.
|
||||
|
||||
Args:
|
||||
issue_number: issue number of the GitHub issue
|
||||
|
||||
Returns:
|
||||
The status of this request, with the list of comments when successful.
|
||||
"""
|
||||
print(f"Attempting to list comments on issue #{issue_number}")
|
||||
url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{issue_number}/comments"
|
||||
|
||||
try:
|
||||
response = get_request(url)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
return {"status": "success", "comments": response}
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
model="gemini-3.5-flash",
|
||||
name="adk_issue_formatting_assistant",
|
||||
description="Check ADK issue format and content.",
|
||||
instruction=f"""
|
||||
# 1. IDENTITY
|
||||
You are an AI assistant designed to help maintain the quality and consistency of issues in our GitHub repository.
|
||||
Your primary role is to act as a "GitHub Issue Format Validator." You will analyze new and existing **open** issues
|
||||
to ensure they contain all the necessary information as required by our templates. You are helpful, polite,
|
||||
and precise in your feedback.
|
||||
|
||||
# 2. CONTEXT & RESOURCES
|
||||
* **Repository:** You are operating on the GitHub repository `{OWNER}/{REPO}`.
|
||||
* **Bug Report Template:** (`{BUG_REPORT_TEMPLATE}`)
|
||||
* **Feature Request Template:** (`{FEATURE_REQUEST_TEMPLATE}`)
|
||||
|
||||
# 3. CORE MISSION
|
||||
Your goal is to check if a GitHub issue, identified as either a "bug" or a "feature request,"
|
||||
contains all the information required by the corresponding template. If it does not, your job is
|
||||
to post a single, helpful comment asking the original author to provide the missing information.
|
||||
{APPROVAL_INSTRUCTION}
|
||||
|
||||
**IMPORTANT NOTE:**
|
||||
* You add one comment at most each time you are invoked.
|
||||
* Don't proceed to other issues which are not the target issues.
|
||||
* Don't take any action on closed issues.
|
||||
|
||||
# 4. BEHAVIORAL RULES & LOGIC
|
||||
|
||||
## Step 1: Identify Issue Type & Applicability
|
||||
|
||||
Your first task is to determine if the issue is a valid target for validation.
|
||||
|
||||
1. **Assess Content Intent:** You must perform a quick semantic check of the issue's title, body, and comments.
|
||||
If you determine the issue's content is fundamentally *not* a bug report or a feature request
|
||||
(for example, it is a general question, a request for help, or a discussion prompt), then you must ignore it.
|
||||
2. **Exit Condition:** If the issue does not clearly fall into the categories of "bug" or "feature request"
|
||||
based on both its labels and its content, **take no action**.
|
||||
|
||||
## Step 2: Analyze the Issue Content
|
||||
|
||||
If you have determined the issue is a valid bug or feature request, your analysis depends on whether it has comments.
|
||||
|
||||
**Scenario A: Issue has NO comments**
|
||||
1. Read the main body of the issue.
|
||||
2. Compare the content of the issue body against the required headings/sections in the relevant template (Bug or Feature).
|
||||
3. Check for the presence of content under each heading. A heading with no content below it is considered incomplete.
|
||||
4. If one or more sections are missing or empty, proceed to Step 3.
|
||||
5. If all sections are filled out, your task is complete. Do nothing.
|
||||
|
||||
**Scenario B: Issue HAS one or more comments**
|
||||
1. First, analyze the main issue body to see which sections of the template are filled out.
|
||||
2. Next, read through **all** the comments in chronological order.
|
||||
3. As you read the comments, check if the information provided in them satisfies any of the template sections that were missing from the original issue body.
|
||||
4. After analyzing the body and all comments, determine if any required sections from the template *still* remain unaddressed.
|
||||
5. If one or more sections are still missing information, proceed to Step 3.
|
||||
6. If the issue body and comments *collectively* provide all the required information, your task is complete. Do nothing.
|
||||
|
||||
## Step 3: Formulate and Post a Comment (If Necessary)
|
||||
|
||||
If you determined in Step 2 that information is missing, you must post a **single comment** on the issue.
|
||||
|
||||
Please include a bolded note in your comment that this comment was added by an ADK agent.
|
||||
|
||||
**Comment Guidelines:**
|
||||
* **Be Polite and Helpful:** Start with a friendly tone.
|
||||
* **Be Specific:** Clearly list only the sections from the template that are still missing. Do not list sections that have already been filled out.
|
||||
* **Address the Author:** Mention the issue author by their username (e.g., `@username`).
|
||||
* **Provide Context:** Explain *why* the information is needed (e.g., "to help us reproduce the bug" or "to better understand your request").
|
||||
* **Do not be repetitive:** If you have already commented on an issue asking for information, do not comment again unless new information has been added and it's still incomplete.
|
||||
|
||||
**Example Comment for a Bug Report:**
|
||||
> **Response from ADK Agent**
|
||||
>
|
||||
> Hello @[issue-author-username], thank you for submitting this issue!
|
||||
>
|
||||
> To help us investigate and resolve this bug effectively, could you please provide the missing details for the following sections of our bug report template:
|
||||
>
|
||||
> * **To Reproduce:** (Please provide the specific steps required to reproduce the behavior)
|
||||
> * **Desktop (please complete the following information):** (Please provide OS, Python version, and ADK version)
|
||||
>
|
||||
> This information will give us the context we need to move forward. Thanks!
|
||||
|
||||
**Example Comment for a Feature Request:**
|
||||
> **Response from ADK Agent**
|
||||
>
|
||||
> Hi @[issue-author-username], thanks for this great suggestion!
|
||||
>
|
||||
> To help our team better understand and evaluate your feature request, could you please provide a bit more information on the following section:
|
||||
>
|
||||
> * **Is your feature request related to a problem? Please describe.**
|
||||
>
|
||||
> We look forward to hearing more about your idea!
|
||||
|
||||
# 5. FINAL INSTRUCTION
|
||||
|
||||
Execute this process for the given GitHub issue. Your final output should either be **[NO ACTION]**
|
||||
if the issue is complete or invalid, or **[POST COMMENT]** followed by the exact text of the comment you will post.
|
||||
|
||||
Please include your justification for your decision in your output.
|
||||
""",
|
||||
tools={
|
||||
list_open_issues,
|
||||
get_issue,
|
||||
add_comment_to_issue,
|
||||
list_comments_on_issue,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright 2026 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_TOKEN = os.getenv("GITHUB_TOKEN")
|
||||
if not GITHUB_TOKEN:
|
||||
raise ValueError("GITHUB_TOKEN environment variable not set")
|
||||
|
||||
OWNER = os.getenv("OWNER", "google")
|
||||
REPO = os.getenv("REPO", "adk-python")
|
||||
EVENT_NAME = os.getenv("EVENT_NAME")
|
||||
ISSUE_NUMBER = os.getenv("ISSUE_NUMBER")
|
||||
ISSUE_COUNT_TO_PROCESS = os.getenv("ISSUE_COUNT_TO_PROCESS")
|
||||
|
||||
IS_INTERACTIVE = os.environ.get("INTERACTIVE", "1").lower() in ["true", "1"]
|
||||
@@ -0,0 +1,54 @@
|
||||
# Copyright 2026 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_issue_formatting_agent.settings import GITHUB_TOKEN
|
||||
import requests
|
||||
|
||||
headers = {
|
||||
"Authorization": f"token {GITHUB_TOKEN}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
|
||||
def get_request(
|
||||
url: str, params: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
if params is None:
|
||||
params = {}
|
||||
response = requests.get(url, headers=headers, params=params, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def post_request(url: str, payload: Any) -> dict[str, Any]:
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def error_response(error_message: str) -> dict[str, Any]:
|
||||
return {"status": "error", "message": error_message}
|
||||
|
||||
|
||||
def read_file(file_path: str) -> str:
|
||||
"""Read the content of the given file."""
|
||||
try:
|
||||
with open(file_path, "r") as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found: {file_path}.")
|
||||
return ""
|
||||
@@ -0,0 +1,18 @@
|
||||
You are the automated security and moderation agent for the {OWNER}/{REPO} repository.
|
||||
|
||||
You will be provided with an Issue Number and a list of comments made by non-maintainers.
|
||||
Your job is to read through these comments and identify if any of them contain SPAM, promotional content for 3rd-party websites, SEO links, or objectionable material.
|
||||
|
||||
CRITERIA FOR SPAM:
|
||||
- The comment is completely unrelated to the repository or the specific issue.
|
||||
- The comment promotes a 3rd party product, service, or website.
|
||||
- The comment is generic "SEO spam" (e.g., "Great post! Check out my site at [link]").
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Evaluate the provided comments.
|
||||
2. If you identify spam, call the `flag_issue_as_spam` tool.
|
||||
- Pass the `item_number`.
|
||||
- Pass a brief `detection_reason` explaining which comment is spam and why (e.g., "@spammer_bot posted an irrelevant link to a shoe store").
|
||||
3. If NONE of the comments contain spam, do NOT call any tools. Just respond with "No spam detected."
|
||||
|
||||
Remember: Do not flag comments that are merely unhelpful, off-topic, or from beginners asking legitimate questions. Only flag actual spam, endorsements, or objectionable material.
|
||||
@@ -0,0 +1,65 @@
|
||||
# ADK Issue Monitoring Agent 🛡️
|
||||
|
||||
An intelligent, cost-optimized, automated moderation agent built with the **Google Agent Development Kit (ADK)**.
|
||||
|
||||
This agent automatically audits GitHub repository issues to detect SEO spam, unsolicited promotional links, and irrelevant third-party endorsements. If spam is detected, it automatically applies a `spam` label and alerts the repository maintainers.
|
||||
|
||||
## ✨ Key Features & Optimizations
|
||||
|
||||
- **Zero-Waste LLM Invocations:** Fetches issue comments via REST APIs and pre-filters them in Python. It automatically ignores comments from maintainers, `[bot]` accounts, and the official `adk-bot`. The Gemini LLM is never invoked for safe threads, saving 100% of the token cost.
|
||||
- **Dual-Mode Scanning:** Can perform a **Deep Clean** (auditing the entire history of all open issues) or a **Daily Sweep** (only fetching issues updated within the last 24 hours).
|
||||
- **Token Truncation:** Uses Regular Expressions to strip out Markdown code blocks (```` ``` ````) replacing them with `[CODE BLOCK REMOVED]`, and truncates unusually long text to 1,500 characters before sending it to the AI.
|
||||
- **Idempotency (Anti-Double-Posting):** The bot reads the comment history for its own signature. If it has already flagged an issue, it instantly skips it, preventing infinite feedback loops.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Configuration
|
||||
|
||||
The agent is configured via environment variables, typically set as secrets in GitHub Actions.
|
||||
|
||||
### Required Secrets
|
||||
|
||||
| Secret Name | Description |
|
||||
| :--------------- | :--------------------------------------------------------------------------------------------------- |
|
||||
| `GITHUB_TOKEN` | A GitHub Personal Access Token (PAT) or Service Account Token with `repo` and `issues: write` scope. |
|
||||
| `GOOGLE_API_KEY` | An API key for the Google AI (Gemini) model used for reasoning. |
|
||||
|
||||
### Optional Configuration
|
||||
|
||||
These variables control the scanning behavior, thresholds, and model selection.
|
||||
|
||||
| Variable Name | Description | Default |
|
||||
| :--------------------- | :----------------------------------------------------------------------------------------------------------------- | :---------------------- |
|
||||
| `INITIAL_FULL_SCAN` | If `true`, audits every open issue in the repository. If `false`, only audits issues updated in the last 24 hours. | `false` |
|
||||
| `SPAM_LABEL_NAME` | The exact text of the label applied to flagged issues. | `spam` |
|
||||
| `BOT_NAME` | The GitHub username of your official bot to ensure its comments are ignored. | `adk-bot` |
|
||||
| `CONCURRENCY_LIMIT` | The number of issues to process concurrently. | `3` |
|
||||
| `SLEEP_BETWEEN_CHUNKS` | Time in seconds to sleep between batches to respect GitHub API rate limits. | `1.5` |
|
||||
| `LLM_MODEL_NAME` | The specific Gemini model version to use. | `gemini-2.5-flash` |
|
||||
| `OWNER` | Repository owner (auto-detected in Actions). | (Environment dependent) |
|
||||
| `REPO` | Repository name (auto-detected in Actions). | (Environment dependent) |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Deployment
|
||||
|
||||
To deploy this agent, a GitHub Actions workflow file (`.github/workflows/issue-monitor.yml`) is recommended.
|
||||
|
||||
### Directory Structure Note
|
||||
|
||||
Because this agent resides within the `adk-python` package structure, the workflow must ensure the script is executed correctly to handle imports. It must be run as a module from the parent directory.
|
||||
|
||||
### Example Workflow Execution
|
||||
|
||||
```yaml
|
||||
- name: Run ADK Issue Monitoring Agent
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
REPO: ${{ github.event.repository.name }}
|
||||
# Mapped to the manual trigger checkbox in the GitHub UI
|
||||
INITIAL_FULL_SCAN: ${{ github.event.inputs.full_scan == 'true' }}
|
||||
PYTHONPATH: contributing/samples
|
||||
run: python -m adk_issue_monitoring_agent.main
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,118 @@
|
||||
# Copyright 2026 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 logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from adk_issue_monitoring_agent.settings import BOT_ALERT_SIGNATURE
|
||||
from adk_issue_monitoring_agent.settings import GITHUB_BASE_URL
|
||||
from adk_issue_monitoring_agent.settings import LLM_MODEL_NAME
|
||||
from adk_issue_monitoring_agent.settings import OWNER
|
||||
from adk_issue_monitoring_agent.settings import REPO
|
||||
from adk_issue_monitoring_agent.settings import SPAM_LABEL_NAME
|
||||
from adk_issue_monitoring_agent.utils import error_response
|
||||
from adk_issue_monitoring_agent.utils import get_issue_comments
|
||||
from adk_issue_monitoring_agent.utils import get_issue_details
|
||||
from adk_issue_monitoring_agent.utils import post_request
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
logger = logging.getLogger("google_adk." + __name__)
|
||||
|
||||
|
||||
def load_prompt_template(filename: str) -> str:
|
||||
file_path = os.path.join(os.path.dirname(__file__), filename)
|
||||
with open(file_path, "r") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
PROMPT_TEMPLATE = load_prompt_template("PROMPT_INSTRUCTION.txt")
|
||||
|
||||
# --- Tools ---
|
||||
|
||||
|
||||
def flag_issue_as_spam(
|
||||
item_number: int, detection_reason: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Flags an issue as spam by adding a label and leaving a comment for maintainers.
|
||||
Includes idempotency checks to avoid duplicate POST actions.
|
||||
|
||||
Args:
|
||||
item_number (int): The GitHub issue number.
|
||||
detection_reason (str): The explanation of what the spam is.
|
||||
"""
|
||||
logger.info(f"Flagging #{item_number} as SPAM. Reason: {detection_reason}")
|
||||
|
||||
label_url = (
|
||||
f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/labels"
|
||||
)
|
||||
comment_url = (
|
||||
f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/comments"
|
||||
)
|
||||
|
||||
safe_reason = detection_reason.replace("```", "'''")
|
||||
|
||||
alert_body = (
|
||||
f"{BOT_ALERT_SIGNATURE}\n"
|
||||
"@maintainers, a suspected spam comment was detected in this thread.\n\n"
|
||||
"**Reason:**\n"
|
||||
f"```text\n{safe_reason}\n```"
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. Fetch current state to check what actions are actually needed
|
||||
issue = get_issue_details(OWNER, REPO, item_number)
|
||||
comments = get_issue_comments(OWNER, REPO, item_number)
|
||||
|
||||
current_labels = [
|
||||
label["name"].lower() for label in issue.get("labels", [])
|
||||
]
|
||||
is_labeled = SPAM_LABEL_NAME.lower() in current_labels
|
||||
is_commented = any(
|
||||
BOT_ALERT_SIGNATURE in c.get("body", "") for c in comments
|
||||
)
|
||||
|
||||
if is_labeled and is_commented:
|
||||
logger.info(f"#{item_number} is already labeled and commented. Skipping.")
|
||||
elif is_labeled and not is_commented:
|
||||
post_request(comment_url, {"body": alert_body})
|
||||
logger.info(f"Successfully posted spam alert comment to #{item_number}.")
|
||||
elif not is_labeled and is_commented:
|
||||
post_request(label_url, {"labels": [SPAM_LABEL_NAME]})
|
||||
logger.info(
|
||||
f"Successfully added '{SPAM_LABEL_NAME}' label to #{item_number}."
|
||||
)
|
||||
else:
|
||||
post_request(label_url, {"labels": [SPAM_LABEL_NAME]})
|
||||
post_request(comment_url, {"body": alert_body})
|
||||
logger.info(f"Successfully fully flagged #{item_number}.")
|
||||
|
||||
return {"status": "success", "message": "Maintainers alerted successfully."}
|
||||
|
||||
except RequestException as e:
|
||||
return error_response(f"Error flagging issue: {e}")
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
model=LLM_MODEL_NAME,
|
||||
name="spam_auditor_agent",
|
||||
description="Audits issue comments for spam.",
|
||||
instruction=PROMPT_TEMPLATE.format(
|
||||
OWNER=OWNER,
|
||||
REPO=REPO,
|
||||
),
|
||||
tools=[flag_issue_as_spam],
|
||||
)
|
||||
@@ -0,0 +1,204 @@
|
||||
# Copyright 2026 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 logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from adk_issue_monitoring_agent.agent import root_agent
|
||||
from adk_issue_monitoring_agent.settings import BOT_ALERT_SIGNATURE
|
||||
from adk_issue_monitoring_agent.settings import BOT_NAME
|
||||
from adk_issue_monitoring_agent.settings import CONCURRENCY_LIMIT
|
||||
from adk_issue_monitoring_agent.settings import OWNER
|
||||
from adk_issue_monitoring_agent.settings import REPO
|
||||
from adk_issue_monitoring_agent.settings import SLEEP_BETWEEN_CHUNKS
|
||||
from adk_issue_monitoring_agent.utils import get_api_call_count
|
||||
from adk_issue_monitoring_agent.utils import get_issue_comments
|
||||
from adk_issue_monitoring_agent.utils import get_issue_details
|
||||
from adk_issue_monitoring_agent.utils import get_repository_maintainers
|
||||
from adk_issue_monitoring_agent.utils import get_target_issues
|
||||
from adk_issue_monitoring_agent.utils import reset_api_call_count
|
||||
from google.adk.cli.utils import logs
|
||||
from google.adk.runners import InMemoryRunner
|
||||
from google.genai import types
|
||||
|
||||
logs.setup_adk_logger(level=logging.INFO)
|
||||
logger = logging.getLogger("google_adk." + __name__)
|
||||
|
||||
APP_NAME = "issue_monitoring_app"
|
||||
USER_ID = "issue_monitoring_user"
|
||||
|
||||
|
||||
async def process_single_issue(
|
||||
runner: InMemoryRunner, issue_number: int, maintainers: list[str]
|
||||
) -> tuple[float, int]:
|
||||
start_time = time.perf_counter()
|
||||
start_api_calls = get_api_call_count()
|
||||
|
||||
try:
|
||||
# 1. Fetch the main issue AND the comments
|
||||
issue = get_issue_details(OWNER, REPO, issue_number)
|
||||
comments = get_issue_comments(OWNER, REPO, issue_number)
|
||||
|
||||
user_comments = []
|
||||
|
||||
# 2. Process the ORIGINAL ISSUE DESCRIPTION first!
|
||||
issue_author = issue.get("user", {}).get("login", "")
|
||||
issue_body = issue.get("body") or ""
|
||||
|
||||
# Only check the description if the author isn't a maintainer/bot
|
||||
if (
|
||||
issue_author not in maintainers
|
||||
and not issue_author.endswith("[bot]")
|
||||
and issue_author != BOT_NAME
|
||||
):
|
||||
cleaned_issue_body = re.sub(
|
||||
r"```.*?```", "\n[CODE BLOCK REMOVED]\n", issue_body, flags=re.DOTALL
|
||||
)
|
||||
if len(cleaned_issue_body) > 1500:
|
||||
cleaned_issue_body = cleaned_issue_body[:1500] + "\n...[TRUNCATED]"
|
||||
user_comments.append(
|
||||
f"Author (Original Issue): @{issue_author}\nText:"
|
||||
f" {cleaned_issue_body}\n---"
|
||||
)
|
||||
|
||||
# 3. Process all the replies (comments)
|
||||
for c in comments:
|
||||
author = c.get("user", {}).get("login", "")
|
||||
body = c.get("body") or ""
|
||||
|
||||
if BOT_ALERT_SIGNATURE in body:
|
||||
logger.info(
|
||||
f"#{issue_number}: Spam bot already alerted maintainers previously."
|
||||
" Skipping."
|
||||
)
|
||||
return (
|
||||
time.perf_counter() - start_time,
|
||||
get_api_call_count() - start_api_calls,
|
||||
)
|
||||
|
||||
if (
|
||||
author in maintainers
|
||||
or author.endswith("[bot]")
|
||||
or author == BOT_NAME
|
||||
):
|
||||
continue
|
||||
|
||||
cleaned_body = re.sub(
|
||||
r"```.*?```", "\n[CODE BLOCK REMOVED]\n", body, flags=re.DOTALL
|
||||
)
|
||||
|
||||
if len(cleaned_body) > 1500:
|
||||
cleaned_body = cleaned_body[:1500] + "\n...[TRUNCATED]"
|
||||
|
||||
user_comments.append(f"Author: @{author}\nComment: {cleaned_body}\n---")
|
||||
|
||||
# 4. Skip LLM if no user text exists
|
||||
if not user_comments:
|
||||
logger.debug(f"#{issue_number}: No non-maintainer text found. Skipping.")
|
||||
return (
|
||||
time.perf_counter() - start_time,
|
||||
get_api_call_count() - start_api_calls,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Processing Issue #{issue_number} (Found {len(user_comments)} items to"
|
||||
" review)..."
|
||||
)
|
||||
|
||||
# 5. Format prompt and invoke LLM
|
||||
compiled_comments = "\n".join(user_comments)
|
||||
prompt_text = (
|
||||
"Please review the following text for issue"
|
||||
f" #{issue_number}:\n\n{compiled_comments}"
|
||||
)
|
||||
|
||||
session = await runner.session_service.create_session(
|
||||
user_id=USER_ID, app_name=APP_NAME
|
||||
)
|
||||
prompt_message = types.Content(
|
||||
role="user", parts=[types.Part(text=prompt_text)]
|
||||
)
|
||||
|
||||
async for event in runner.run_async(
|
||||
user_id=USER_ID, session_id=session.id, new_message=prompt_message
|
||||
):
|
||||
if (
|
||||
event.content
|
||||
and event.content.parts
|
||||
and hasattr(event.content.parts[0], "text")
|
||||
):
|
||||
text = event.content.parts[0].text
|
||||
if text:
|
||||
clean_text = text[:100].replace("\n", " ")
|
||||
logger.info(f"#{issue_number} Decision: {clean_text}...")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing issue #{issue_number}: {e}", exc_info=True)
|
||||
|
||||
# Calculate duration and API calls regardless of success or failure
|
||||
duration = time.perf_counter() - start_time
|
||||
issue_api_calls = get_api_call_count() - start_api_calls
|
||||
return duration, issue_api_calls
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info(f"--- Starting Issue Monitoring Agent for {OWNER}/{REPO} ---")
|
||||
reset_api_call_count()
|
||||
|
||||
# Step 1: Fetch Maintainers
|
||||
try:
|
||||
maintainers = get_repository_maintainers(OWNER, REPO)
|
||||
logger.info(f"Found {len(maintainers)} maintainers.")
|
||||
except Exception as e:
|
||||
logger.critical(f"Failed to fetch maintainers: {e}")
|
||||
return
|
||||
|
||||
# Step 2: Fetch target issues
|
||||
try:
|
||||
all_issues = get_target_issues(OWNER, REPO)
|
||||
except Exception as e:
|
||||
logger.critical(f"Failed to fetch issue list: {e}")
|
||||
return
|
||||
|
||||
total_count = len(all_issues)
|
||||
if total_count == 0:
|
||||
logger.info("No issues matched criteria. Run finished.")
|
||||
return
|
||||
|
||||
logger.info(f"Found {total_count} issues to process.")
|
||||
|
||||
# Initialize the runner ONCE for the entire run
|
||||
runner = InMemoryRunner(agent=root_agent, app_name=APP_NAME)
|
||||
|
||||
# Step 3: Iterate through issues async 'CONCURRENCY_LIMIT' at a time
|
||||
for i in range(0, total_count, CONCURRENCY_LIMIT):
|
||||
chunk = all_issues[i : i + CONCURRENCY_LIMIT]
|
||||
logger.info(f"Processing chunk: {chunk}")
|
||||
|
||||
tasks = [
|
||||
process_single_issue(runner, issue_num, maintainers)
|
||||
for issue_num in chunk
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
if (i + CONCURRENCY_LIMIT) < total_count:
|
||||
await asyncio.sleep(SLEEP_BETWEEN_CHUNKS)
|
||||
|
||||
logger.info(f"--- Run Finished. Total API calls: {get_api_call_count()} ---")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,43 @@
|
||||
# Copyright 2026 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 pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
CURRENT_DIR = Path(__file__).resolve().parent
|
||||
ENV_PATH = CURRENT_DIR / ".env"
|
||||
load_dotenv(dotenv_path=ENV_PATH, override=True)
|
||||
|
||||
GITHUB_BASE_URL = "https://api.github.com"
|
||||
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
|
||||
if not GITHUB_TOKEN:
|
||||
raise ValueError("GITHUB_TOKEN environment variable not set")
|
||||
|
||||
OWNER = os.getenv("OWNER", "google")
|
||||
REPO = os.getenv("REPO", "adk-python")
|
||||
LLM_MODEL_NAME = os.getenv("LLM_MODEL_NAME", "gemini-2.5-flash")
|
||||
|
||||
SPAM_LABEL_NAME = os.getenv("SPAM_LABEL_NAME", "spam")
|
||||
CONCURRENCY_LIMIT = int(os.getenv("CONCURRENCY_LIMIT", 3))
|
||||
BOT_NAME = os.getenv("BOT_NAME", "adk-bot")
|
||||
BOT_ALERT_SIGNATURE = os.getenv(
|
||||
"BOT_ALERT_SIGNATURE", "🚨 **Automated Spam Detection Alert** 🚨"
|
||||
)
|
||||
SLEEP_BETWEEN_CHUNKS = float(os.getenv("SLEEP_BETWEEN_CHUNKS", 1.5))
|
||||
|
||||
|
||||
# Toggle for the initial run
|
||||
INITIAL_FULL_SCAN = os.getenv("INITIAL_FULL_SCAN", "false").lower() == "true"
|
||||
@@ -0,0 +1,171 @@
|
||||
# Copyright 2026 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 logging
|
||||
from typing import Any
|
||||
|
||||
from adk_issue_monitoring_agent.settings import GITHUB_TOKEN
|
||||
from adk_issue_monitoring_agent.settings import INITIAL_FULL_SCAN
|
||||
from adk_issue_monitoring_agent.settings import SPAM_LABEL_NAME
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
logger = logging.getLogger("google_adk." + __name__)
|
||||
|
||||
_api_call_count = 0
|
||||
|
||||
|
||||
def get_api_call_count() -> int:
|
||||
return _api_call_count
|
||||
|
||||
|
||||
def reset_api_call_count() -> None:
|
||||
global _api_call_count
|
||||
_api_call_count = 0
|
||||
|
||||
|
||||
def _increment_api_call_count() -> None:
|
||||
global _api_call_count
|
||||
_api_call_count += 1
|
||||
|
||||
|
||||
retry_strategy = Retry(
|
||||
total=6,
|
||||
backoff_factor=2,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=["GET", "DELETE"],
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
_session = requests.Session()
|
||||
_session.mount("https://", adapter)
|
||||
_session.headers.update({
|
||||
"Authorization": f"token {GITHUB_TOKEN}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
})
|
||||
|
||||
|
||||
def get_request(url: str, params: dict[str, Any] | None = None) -> Any:
|
||||
_increment_api_call_count()
|
||||
response = _session.get(url, params=params or {}, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def post_request(url: str, payload: Any) -> Any:
|
||||
_increment_api_call_count()
|
||||
response = _session.post(url, json=payload, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def error_response(error_message: str) -> dict[str, Any]:
|
||||
return {"status": "error", "message": error_message}
|
||||
|
||||
|
||||
def get_repository_maintainers(owner: str, repo: str) -> list[str]:
|
||||
"""Fetches all users with push/maintain access."""
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/collaborators"
|
||||
data = get_request(url, {"permission": "push"})
|
||||
return [user["login"] for user in data]
|
||||
|
||||
|
||||
def get_issue_details(
|
||||
owner: str, repo: str, issue_number: int
|
||||
) -> dict[str, Any]:
|
||||
"""Fetches the main issue object to get the original description (body)."""
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}"
|
||||
return get_request(url)
|
||||
|
||||
|
||||
def get_issue_comments(
|
||||
owner: str, repo: str, issue_number: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetches ALL comments for a specific issue, handling pagination."""
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/comments"
|
||||
all_comments = []
|
||||
page = 1
|
||||
|
||||
while True:
|
||||
data = get_request(url, params={"per_page": 100, "page": page})
|
||||
if not data:
|
||||
break
|
||||
|
||||
all_comments.extend(data)
|
||||
|
||||
if len(data) < 100:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return all_comments
|
||||
|
||||
|
||||
def get_target_issues(owner: str, repo: str) -> list[int]:
|
||||
"""
|
||||
Fetches issues.
|
||||
If INITIAL_FULL_SCAN is True, fetches ALL open issues.
|
||||
If False, fetches only issues updated in the last 24 hours using the 'since' parameter.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from datetime import timezone
|
||||
|
||||
url = f"https://api.github.com/repos/{owner}/{repo}/issues"
|
||||
params = {
|
||||
"state": "open",
|
||||
"per_page": 100,
|
||||
}
|
||||
|
||||
if INITIAL_FULL_SCAN:
|
||||
logger.info("INITIAL_FULL_SCAN is True. Fetching ALL open issues...")
|
||||
else:
|
||||
yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
)
|
||||
params["since"] = yesterday
|
||||
logger.info(f"Daily mode: Fetching issues updated since {yesterday}...")
|
||||
|
||||
issue_numbers = []
|
||||
page = 1
|
||||
|
||||
while True:
|
||||
params["page"] = page
|
||||
try:
|
||||
items = get_request(url, params=params)
|
||||
|
||||
if not items:
|
||||
break
|
||||
|
||||
for item in items:
|
||||
if "pull_request" not in item:
|
||||
# Extract all the label names on this issue
|
||||
current_labels = [label["name"] for label in item.get("labels", [])]
|
||||
|
||||
# Only add the issue if it DOES NOT already have the spam label
|
||||
if SPAM_LABEL_NAME not in current_labels:
|
||||
issue_numbers.append(item["number"])
|
||||
else:
|
||||
logger.debug(
|
||||
f"Skipping #{item['number']} - already marked as spam."
|
||||
)
|
||||
|
||||
if len(items) < 100:
|
||||
break
|
||||
|
||||
page += 1
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to fetch issues on page {page}: {e}")
|
||||
break
|
||||
|
||||
return issue_numbers
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Knowledge Agent
|
||||
|
||||
An intelligent assistant for performing Vertex AI Search to find ADK knowledge
|
||||
and documentation.
|
||||
|
||||
## Deployment
|
||||
|
||||
This agent is deployed to Google Could Run as an A2A agent, which is used by
|
||||
the parent ADK Agent Builder Assistant.
|
||||
|
||||
Here are the steps to deploy the agent:
|
||||
|
||||
1. Set environment variables
|
||||
|
||||
```bash
|
||||
export GOOGLE_CLOUD_PROJECT=your-project-id
|
||||
export GOOGLE_CLOUD_LOCATION=us-central1 # Or your preferred location
|
||||
export GOOGLE_GENAI_USE_ENTERPRISE=True
|
||||
```
|
||||
|
||||
2. Run the deployment command
|
||||
|
||||
```bash
|
||||
$ adk deploy cloud_run --project=your-project-id --region=us-central1 --service_name=adk-agent-builder-knowledge-service --with_ui --a2a ./adk_knowledge_agent
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,27 @@
|
||||
{
|
||||
"capabilities": {},
|
||||
"defaultInputModes": [
|
||||
"text/plain"
|
||||
],
|
||||
"defaultOutputModes": [
|
||||
"application/json"
|
||||
],
|
||||
"description": "Agent for performing Vertex AI Search to find ADK knowledge and documentation",
|
||||
"name": "adk_knowledge_agent",
|
||||
"skills": [
|
||||
{
|
||||
"id": "adk_knowledge_search",
|
||||
"name": "ADK Knowledge Search",
|
||||
"description": "Searches for ADK examples and documentation using the Vertex AI Search tool",
|
||||
"tags": [
|
||||
"search",
|
||||
"documentation",
|
||||
"knowledge base",
|
||||
"Vertex AI",
|
||||
"ADK"
|
||||
]
|
||||
}
|
||||
],
|
||||
"url": "https://adk-agent-builder-knowledge-service-654646711756.us-central1.run.app/a2a/adk_knowledge_agent",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright 2026 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 json
|
||||
from typing import Optional
|
||||
|
||||
from google.adk.agents import LlmAgent
|
||||
from google.adk.agents.callback_context import CallbackContext
|
||||
from google.adk.models import LlmResponse
|
||||
from google.adk.tools.vertex_ai_search_tool import VertexAiSearchTool
|
||||
from google.genai import types
|
||||
|
||||
VERTEXAI_DATASTORE_ID = "projects/adk-agent-builder-assistant/locations/global/collections/default_collection/dataStores/adk-agent-builder-sample-datastore_1758230446136"
|
||||
|
||||
|
||||
def citation_retrieval_after_model_callback(
|
||||
callback_context: CallbackContext,
|
||||
llm_response: LlmResponse,
|
||||
) -> Optional[LlmResponse]:
|
||||
"""Callback function to retrieve citations after model response is generated."""
|
||||
grounding_metadata = llm_response.grounding_metadata
|
||||
if not grounding_metadata:
|
||||
return None
|
||||
|
||||
content = llm_response.content
|
||||
if not llm_response.content:
|
||||
return None
|
||||
|
||||
parts = content.parts
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
# Add citations to the response as JSON objects.
|
||||
parts.append(types.Part(text="References:\n"))
|
||||
for grounding_chunk in grounding_metadata.grounding_chunks:
|
||||
retrieved_context = grounding_chunk.retrieved_context
|
||||
if not retrieved_context:
|
||||
continue
|
||||
|
||||
citation = {
|
||||
"title": retrieved_context.title,
|
||||
"uri": retrieved_context.uri,
|
||||
"snippet": retrieved_context.text,
|
||||
}
|
||||
parts.append(types.Part(text=json.dumps(citation)))
|
||||
|
||||
return LlmResponse(content=types.Content(parts=parts))
|
||||
|
||||
|
||||
root_agent = LlmAgent(
|
||||
name="adk_knowledge_agent",
|
||||
description=(
|
||||
"Agent for performing Vertex AI Search to find ADK knowledge and"
|
||||
" documentation"
|
||||
),
|
||||
instruction="""You are a specialized search agent for an ADK knowledge base.
|
||||
|
||||
You can use the VertexAiSearchTool to search for ADK examples and documentation in the document store.
|
||||
""",
|
||||
tools=[VertexAiSearchTool(data_store_id=VERTEXAI_DATASTORE_ID)],
|
||||
after_model_callback=citation_retrieval_after_model_callback,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
google-adk[a2a]==2.2.0
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,149 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
# pylint: disable=g-importing-member
|
||||
|
||||
import os
|
||||
|
||||
from google.adk import Agent
|
||||
import requests
|
||||
|
||||
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "")
|
||||
if not GITHUB_TOKEN:
|
||||
raise ValueError("GITHUB_TOKEN environment variable not set")
|
||||
|
||||
OWNER = os.getenv("OWNER", "google")
|
||||
REPO = os.getenv("REPO", "adk-python")
|
||||
|
||||
|
||||
def get_github_pr_info_http(pr_number: int) -> str | None:
|
||||
"""Fetches information for a GitHub Pull Request by sending direct HTTP requests.
|
||||
|
||||
Args:
|
||||
pr_number (int): The number of the Pull Request.
|
||||
|
||||
Returns:
|
||||
pr_message: A string.
|
||||
"""
|
||||
base_url = "https://api.github.com"
|
||||
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {GITHUB_TOKEN}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
pr_message = ""
|
||||
|
||||
# --- 1. Get main PR details ---
|
||||
pr_url = f"{base_url}/repos/{OWNER}/{REPO}/pulls/{pr_number}"
|
||||
print(f"Fetching PR details from: {pr_url}")
|
||||
try:
|
||||
response = requests.get(pr_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
pr_data = response.json()
|
||||
pr_message += f"The PR title is: {pr_data.get('title')}\n"
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(
|
||||
f"HTTP Error fetching PR details: {e.response.status_code} - "
|
||||
f" {e.response.text}"
|
||||
)
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Network or request error fetching PR details: {e}")
|
||||
return None
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
return None
|
||||
|
||||
# --- 2. Fetching associated commits (paginated) ---
|
||||
commits_url = pr_data.get(
|
||||
"commits_url"
|
||||
) # This URL is provided in the initial PR response
|
||||
if commits_url:
|
||||
print("\n--- Associated Commits in this PR: ---")
|
||||
page = 1
|
||||
while True:
|
||||
# GitHub API often uses 'per_page' and 'page' for pagination
|
||||
params = {
|
||||
"per_page": 100,
|
||||
"page": page,
|
||||
} # Fetch up to 100 commits per page
|
||||
try:
|
||||
response = requests.get(commits_url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
commits_data = response.json()
|
||||
|
||||
if not commits_data: # No more commits
|
||||
break
|
||||
|
||||
pr_message += "The associated commits are:\n"
|
||||
for commit in commits_data:
|
||||
message = commit.get("commit", {}).get("message", "").splitlines()[0]
|
||||
if message:
|
||||
pr_message += message + "\n"
|
||||
|
||||
# Check for 'Link' header to determine if more pages exist
|
||||
# This is how GitHub's API indicates pagination
|
||||
if "Link" in response.headers:
|
||||
link_header = response.headers["Link"]
|
||||
if 'rel="next"' in link_header:
|
||||
page += 1 # Move to the next page
|
||||
else:
|
||||
break # No more pages
|
||||
else:
|
||||
break # No Link header, so probably only one page
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(
|
||||
f"HTTP Error fetching PR commits (page {page}):"
|
||||
f" {e.response.status_code} - {e.response.text}"
|
||||
)
|
||||
break
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(
|
||||
f"Network or request error fetching PR commits (page {page}): {e}"
|
||||
)
|
||||
break
|
||||
else:
|
||||
print("Commits URL not found in PR data.")
|
||||
|
||||
return pr_message
|
||||
|
||||
|
||||
system_prompt = """
|
||||
You are a helpful assistant to generate reasonable descriptions for pull requests for software engineers.
|
||||
|
||||
The descriptions should not be too short (e.g.: less than 3 words), or too long (e.g.: more than 30 words).
|
||||
|
||||
The generated description should start with `chore`, `docs`, `feat`, `fix`, `test`, or `refactor`.
|
||||
`feat` stands for a new feature.
|
||||
`fix` stands for a bug fix.
|
||||
`chore`, `docs`, `test`, and `refactor` stand for improvements.
|
||||
|
||||
Some good descriptions are:
|
||||
1. feat: Added implementation for `get_eval_case`, `update_eval_case` and `delete_eval_case` for the local eval sets manager.
|
||||
2. feat: Provide inject_session_state as public util method.
|
||||
|
||||
Some bad descriptions are:
|
||||
1. fix: This fixes bugs.
|
||||
2. feat: This is a new feature.
|
||||
|
||||
"""
|
||||
|
||||
root_agent = Agent(
|
||||
name="github_pr_agent",
|
||||
description="Generate pull request descriptions for ADK.",
|
||||
instruction=system_prompt,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
# pylint: disable=g-importing-member
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import agent
|
||||
from google.adk.agents.run_config import RunConfig
|
||||
from google.adk.runners import InMemoryRunner
|
||||
from google.adk.sessions.session import Session
|
||||
from google.genai import types
|
||||
|
||||
|
||||
async def main():
|
||||
app_name = "adk_pr_app"
|
||||
user_id_1 = "adk_pr_user"
|
||||
runner = InMemoryRunner(
|
||||
agent=agent.root_agent,
|
||||
app_name=app_name,
|
||||
)
|
||||
session_11 = await runner.session_service.create_session(
|
||||
app_name=app_name, user_id=user_id_1
|
||||
)
|
||||
|
||||
async def run_agent_prompt(session: Session, prompt_text: str):
|
||||
content = types.Content(
|
||||
role="user", parts=[types.Part.from_text(text=prompt_text)]
|
||||
)
|
||||
final_agent_response_parts = []
|
||||
async for event in runner.run_async(
|
||||
user_id=user_id_1,
|
||||
session_id=session.id,
|
||||
new_message=content,
|
||||
run_config=RunConfig(save_input_blobs_as_artifacts=False),
|
||||
):
|
||||
if event.content.parts and event.content.parts[0].text:
|
||||
if event.author == agent.root_agent.name:
|
||||
final_agent_response_parts.append(event.content.parts[0].text)
|
||||
print(f"<<<< Agent Final Output: {''.join(final_agent_response_parts)}\n")
|
||||
|
||||
pr_message = agent.get_github_pr_info_http(pr_number=1422)
|
||||
query = "Generate pull request description for " + pr_message
|
||||
await run_agent_prompt(session_11, query)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start_time = time.time()
|
||||
print(
|
||||
"Script start time:",
|
||||
time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(start_time)),
|
||||
)
|
||||
print("------------------------------------")
|
||||
asyncio.run(main())
|
||||
end_time = time.time()
|
||||
print("------------------------------------")
|
||||
print(
|
||||
"Script end time:",
|
||||
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,76 @@
|
||||
# ADK Pull Request Triaging Assistant
|
||||
|
||||
The ADK Pull Request (PR) Triaging Assistant is a Python-based agent designed to help manage and triage GitHub pull requests for the `google/adk-python` repository. It uses a large language model to analyze new and unlabelled pull requests, recommend appropriate labels, assign a reviewer, and check contribution guides based on a predefined set of rules.
|
||||
|
||||
This agent can be operated in two distinct modes:
|
||||
|
||||
- an interactive mode for local use
|
||||
- a fully automated GitHub Actions workflow.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## 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 pull requests.
|
||||
|
||||
### 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 applying a label or posting a comment to a GitHub pull request.
|
||||
|
||||
### 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.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## GitHub Workflow Mode
|
||||
|
||||
For automated, hands-off PR triaging, the agent can be integrated directly into your repository's CI/CD pipeline using a GitHub Actions workflow.
|
||||
|
||||
### Workflow Triggers
|
||||
|
||||
The GitHub workflow is configured to run on specific triggers:
|
||||
|
||||
- **Pull Request Events**: The workflow executes automatically whenever a new PR is `opened` or an existing one is `reopened` or `edited`.
|
||||
|
||||
### Automated Labeling
|
||||
|
||||
When running as part of the GitHub workflow, the agent operates non-interactively. It identifies and applies the best label or posts a comment directly without requiring user approval. This behavior is configured by setting the `INTERACTIVE` environment variable to `0` in the workflow file.
|
||||
|
||||
### Workflow Configuration
|
||||
|
||||
The workflow is defined in a YAML file (`.github/workflows/pr-triage.yml`). This file contains the steps to check out the code, set up the Python environment, install dependencies, and run the triaging script with the necessary environment variables and secrets.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The following environment variables are required for the agent to connect to the necessary services.
|
||||
|
||||
- `GITHUB_TOKEN`: **(Required)** A GitHub Personal Access Token with `pull_requests:write` permissions. Needed for both interactive and workflow modes.
|
||||
- `GOOGLE_API_KEY`: **(Required)** Your API key for the Gemini API. Needed for both interactive and workflow modes.
|
||||
- `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 2026 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,425 @@
|
||||
# Copyright 2026 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 pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from adk_pr_triaging_agent.settings import GITHUB_BASE_URL
|
||||
from adk_pr_triaging_agent.settings import IS_INTERACTIVE
|
||||
from adk_pr_triaging_agent.settings import OWNER
|
||||
from adk_pr_triaging_agent.settings import REPO
|
||||
from adk_pr_triaging_agent.utils import error_response
|
||||
from adk_pr_triaging_agent.utils import get_diff
|
||||
from adk_pr_triaging_agent.utils import get_request
|
||||
from adk_pr_triaging_agent.utils import is_assignable
|
||||
from adk_pr_triaging_agent.utils import post_request
|
||||
from adk_pr_triaging_agent.utils import read_file
|
||||
from adk_pr_triaging_agent.utils import run_graphql_query
|
||||
from google.adk import Agent
|
||||
import requests
|
||||
|
||||
ALLOWED_LABELS = [
|
||||
"documentation",
|
||||
"services",
|
||||
"tools",
|
||||
"mcp",
|
||||
"eval",
|
||||
"live",
|
||||
"models",
|
||||
"tracing",
|
||||
"core",
|
||||
"web",
|
||||
]
|
||||
|
||||
# Component label -> GitHub login of the owner who shepherds that component.
|
||||
# The owner becomes the PR's assignee so the contributor can see who is
|
||||
# handling their PR. github login != corp ldap, so this is the login form. Keep
|
||||
# in sync with the OWNERS file (the authority) and adk_triaging_agent's map.
|
||||
LABEL_TO_OWNER = {
|
||||
"documentation": "joefernandez",
|
||||
"services": "DeanChensj",
|
||||
"tools": "xuanyang15",
|
||||
"mcp": "wukath",
|
||||
"eval": "ankursharmas",
|
||||
"live": "wuliang229",
|
||||
"models": "xuanyang15",
|
||||
"tracing": "jawoszek",
|
||||
"core": "DeanChensj",
|
||||
"web": "wyf7107",
|
||||
}
|
||||
|
||||
CONTRIBUTING_MD = read_file(
|
||||
Path(__file__).resolve().parents[4] / "CONTRIBUTING.md"
|
||||
)
|
||||
|
||||
APPROVAL_INSTRUCTION = (
|
||||
"Do not ask for user approval for labeling, commenting, or assigning!"
|
||||
" If you can't find appropriate labels for the PR, do not label it."
|
||||
)
|
||||
if IS_INTERACTIVE:
|
||||
APPROVAL_INSTRUCTION = (
|
||||
"Only label, comment, or assign when the user approves the action!"
|
||||
)
|
||||
|
||||
|
||||
def get_pull_request_details(pr_number: int) -> str:
|
||||
"""Get the details of the specified pull request.
|
||||
|
||||
Args:
|
||||
pr_number: number of the GitHub pull request.
|
||||
|
||||
Returns:
|
||||
The status of this request, with the details when successful.
|
||||
"""
|
||||
print(f"Fetching details for PR #{pr_number} from {OWNER}/{REPO}")
|
||||
query = """
|
||||
query($owner: String!, $repo: String!, $prNumber: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $prNumber) {
|
||||
id
|
||||
number
|
||||
title
|
||||
body
|
||||
state
|
||||
author {
|
||||
login
|
||||
}
|
||||
labels(last: 10) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
assignees(first: 10) {
|
||||
nodes {
|
||||
login
|
||||
}
|
||||
}
|
||||
files(last: 50) {
|
||||
nodes {
|
||||
path
|
||||
}
|
||||
}
|
||||
comments(last: 50) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
createdAt
|
||||
author {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
commits(last: 50) {
|
||||
nodes {
|
||||
commit {
|
||||
url
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
statusCheckRollup {
|
||||
state
|
||||
contexts(last: 20) {
|
||||
nodes {
|
||||
... on StatusContext {
|
||||
context
|
||||
state
|
||||
targetUrl
|
||||
}
|
||||
... on CheckRun {
|
||||
name
|
||||
status
|
||||
conclusion
|
||||
detailsUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
variables = {"owner": OWNER, "repo": REPO, "prNumber": pr_number}
|
||||
url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/pulls/{pr_number}"
|
||||
|
||||
try:
|
||||
response = run_graphql_query(query, variables)
|
||||
if "errors" in response:
|
||||
return error_response(str(response["errors"]))
|
||||
|
||||
pr = response.get("data", {}).get("repository", {}).get("pullRequest")
|
||||
if not pr:
|
||||
return error_response(f"Pull Request #{pr_number} not found.")
|
||||
|
||||
# Filter out main merge commits.
|
||||
original_commits = pr.get("commits", {}).get("nodes", {})
|
||||
if original_commits:
|
||||
filtered_commits = [
|
||||
commit_node
|
||||
for commit_node in original_commits
|
||||
if not commit_node["commit"]["message"].startswith(
|
||||
"Merge branch 'main' into"
|
||||
)
|
||||
]
|
||||
pr["commits"]["nodes"] = filtered_commits
|
||||
|
||||
# Get diff of the PR and truncate it to avoid exceeding the maximum tokens.
|
||||
pr["diff"] = get_diff(url)[:10000]
|
||||
|
||||
return {"status": "success", "pull_request": pr}
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(str(e))
|
||||
|
||||
|
||||
def add_label_to_pr(pr_number: int, label: str) -> dict[str, Any]:
|
||||
"""Adds a specified label on a pull request.
|
||||
|
||||
Args:
|
||||
pr_number: the number of the GitHub pull request
|
||||
label: the label to add
|
||||
|
||||
Returns:
|
||||
The status of this request, with the applied label and response when
|
||||
successful.
|
||||
"""
|
||||
print(f"Attempting to add label '{label}' to PR #{pr_number}")
|
||||
if label not in ALLOWED_LABELS:
|
||||
return error_response(
|
||||
f"Error: Label '{label}' is not an allowed label. Will not apply."
|
||||
)
|
||||
|
||||
# Pull Request is a special issue in GitHub, so we can use issue url for PR.
|
||||
label_url = (
|
||||
f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{pr_number}/labels"
|
||||
)
|
||||
label_payload = [label]
|
||||
|
||||
try:
|
||||
response = post_request(label_url, label_payload)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"applied_label": label,
|
||||
"response": response,
|
||||
}
|
||||
|
||||
|
||||
def assign_owner_to_pr(pr_number: int, label: str) -> dict[str, Any]:
|
||||
"""Assign the component owner (the shepherd) to a PR based on its label.
|
||||
|
||||
The owner is looked up from `LABEL_TO_OWNER` so the contributor can see who is
|
||||
shepherding their PR. GitHub only allows assigning users with
|
||||
repo write/triage access, so a non-assignable owner is reported as skipped
|
||||
rather than silently dropped.
|
||||
|
||||
Args:
|
||||
pr_number: the number of the GitHub pull request
|
||||
label: the component label the PR was triaged into
|
||||
|
||||
Returns:
|
||||
The status of this request, with the assigned owner when successful.
|
||||
"""
|
||||
owner = LABEL_TO_OWNER.get(label)
|
||||
if not owner:
|
||||
return error_response(f"Error: no owner mapped for label '{label}'.")
|
||||
print(f"Attempting to assign owner '{owner}' to PR #{pr_number}")
|
||||
if not is_assignable(owner):
|
||||
return {
|
||||
"status": "skipped",
|
||||
"reason": f"'{owner}' is not assignable (needs repo access)",
|
||||
"owner": owner,
|
||||
}
|
||||
|
||||
# Pull Request is a special issue in GitHub, so we can use the issue url.
|
||||
assignee_url = (
|
||||
f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{pr_number}/assignees"
|
||||
)
|
||||
try:
|
||||
response = post_request(assignee_url, {"assignees": [owner]})
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"assigned_owner": owner,
|
||||
"response": response,
|
||||
}
|
||||
|
||||
|
||||
def add_comment_to_pr(pr_number: int, comment: str) -> dict[str, Any]:
|
||||
"""Add the specified comment to the given PR number.
|
||||
|
||||
Args:
|
||||
pr_number: the number of the GitHub pull request
|
||||
comment: the comment to add
|
||||
|
||||
Returns:
|
||||
The status of this request, with the applied comment when successful.
|
||||
"""
|
||||
print(f"Attempting to add comment '{comment}' to issue #{pr_number}")
|
||||
|
||||
# Pull Request is a special issue in GitHub, so we can use issue url for PR.
|
||||
url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{pr_number}/comments"
|
||||
payload = {"body": comment}
|
||||
|
||||
try:
|
||||
post_request(url, payload)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
return {
|
||||
"status": "success",
|
||||
"added_comment": comment,
|
||||
}
|
||||
|
||||
|
||||
def list_untriaged_pull_requests(pr_count: int) -> dict[str, Any]:
|
||||
"""List open pull requests that need triaging.
|
||||
|
||||
Returns pull requests that need triaging (i.e. do not have google-contributor
|
||||
label and do not have any allowed triage category labels).
|
||||
|
||||
Args:
|
||||
pr_count: number of pull requests to return
|
||||
|
||||
Returns:
|
||||
The status of this request, with a list of pull requests when successful.
|
||||
"""
|
||||
url = f"{GITHUB_BASE_URL}/search/issues"
|
||||
query = f"repo:{OWNER}/{REPO} is:open is:pr"
|
||||
params = {
|
||||
"q": query,
|
||||
"sort": "updated",
|
||||
"order": "desc",
|
||||
"per_page": 100,
|
||||
"page": 1,
|
||||
}
|
||||
|
||||
try:
|
||||
response = get_request(url, params)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
|
||||
issues = response.get("items", [])
|
||||
triage_labels = set(ALLOWED_LABELS)
|
||||
untriaged_prs = []
|
||||
|
||||
for pr in issues:
|
||||
pr_labels = {label["name"] for label in pr.get("labels", [])}
|
||||
if "google-contributor" in pr_labels:
|
||||
continue
|
||||
# If it already has any of the ALLOWED_LABELS, skip it.
|
||||
if pr_labels & triage_labels:
|
||||
continue
|
||||
|
||||
untriaged_prs.append({
|
||||
"number": pr["number"],
|
||||
"title": pr["title"],
|
||||
})
|
||||
|
||||
if len(untriaged_prs) >= pr_count:
|
||||
break
|
||||
|
||||
return {"status": "success", "pull_requests": untriaged_prs}
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
model="gemini-3.5-flash",
|
||||
name="adk_pr_triaging_assistant",
|
||||
description="Triage ADK pull requests.",
|
||||
instruction=f"""
|
||||
# 1. Identity
|
||||
You are a Pull Request (PR) triaging bot for the GitHub {REPO} repo with the owner {OWNER}.
|
||||
|
||||
# 2. Responsibilities
|
||||
Your core responsibility includes:
|
||||
- Get the pull request details.
|
||||
- Add a label to the pull request.
|
||||
- Assign the component owner (the shepherd) to the pull request.
|
||||
- Check if the pull request is following the contribution guidelines.
|
||||
- Add a comment to the pull request if it's not following the guidelines.
|
||||
|
||||
**IMPORTANT: {APPROVAL_INSTRUCTION}**
|
||||
|
||||
# 3. Guidelines & Rules
|
||||
Here are the rules for labeling:
|
||||
- If the PR is about documentations, label it with "documentation".
|
||||
- If it's about session, memory, artifacts services, label it with "services"
|
||||
- If it's about UI/web, label it with "web"
|
||||
- If it's related to tools, label it with "tools"
|
||||
- If it's about agent evaluation, then label it with "eval".
|
||||
- If it's about streaming/live, label it with "live".
|
||||
- If it's about model support(non-Gemini, like Litellm, Ollama, OpenAI models), label it with "models".
|
||||
- If it's about tracing, label it with "tracing".
|
||||
- If it's agent orchestration, agent definition, label it with "core".
|
||||
- If it's about Model Context Protocol (e.g. MCP tool, MCP toolset, MCP session management etc.), label it with "mcp".
|
||||
- If you can't find an appropriate labels for the PR, follow the previous instruction that starts with "IMPORTANT:".
|
||||
|
||||
Here is the contribution guidelines:
|
||||
`{CONTRIBUTING_MD}`
|
||||
|
||||
Here are the guidelines for checking if the PR is following the guidelines:
|
||||
- The "statusCheckRollup" in the pull request details may help you to identify if the PR is following some of the guidelines (e.g. CLA compliance).
|
||||
|
||||
Here are the guidelines for the comment:
|
||||
- **Be Polite and Helpful:** Start with a friendly tone.
|
||||
- **Be Specific:** Clearly list only the sections from the contribution guidelines that are still missing.
|
||||
- **Address the Author:** Mention the PR author by their username (e.g., `@username`).
|
||||
- **Provide Context:** Explain *why* the information or action is needed.
|
||||
- **Do not be repetitive:** If you have already commented on an PR asking for information, do not comment again unless new information has been added and it's still incomplete.
|
||||
- **Identify yourself:** Include a bolded note (e.g. "Response from ADK Triaging Agent") in your comment to indicate this comment was added by an ADK Answering Agent.
|
||||
|
||||
**Example Comment for a PR:**
|
||||
> **Response from ADK Triaging Agent**
|
||||
>
|
||||
> Hello @[pr-author-username], thank you for creating this PR!
|
||||
>
|
||||
> This PR is a bug fix, could you please associate the github issue with this PR? If there is no existing issue, could you please create one?
|
||||
>
|
||||
> In addition, could you please provide logs or screenshot after the fix is applied?
|
||||
>
|
||||
> This information will help reviewers to review your PR more efficiently. Thanks!
|
||||
|
||||
# 4. Steps
|
||||
- If you are asked to find pull requests that need triaging, use `list_untriaged_pull_requests` first.
|
||||
- For each pull request to be triaged:
|
||||
- Call the `get_pull_request_details` tool to get the details of the PR.
|
||||
- Skip the PR (i.e. do not label or comment) if any of the following is true:
|
||||
- the PR is closed
|
||||
- the PR is labeled with "google-contributor"
|
||||
- the PR is already labelled with the above labels (e.g. "documentation", "services", "tools", etc.).
|
||||
- Check if the PR is following the contribution guidelines.
|
||||
- If it's not following the guidelines, recommend or add a comment to the PR that points to the contribution guidelines (https://github.com/google/adk-python/blob/main/CONTRIBUTING.md).
|
||||
- If it's following the guidelines, recommend or add a label to the PR.
|
||||
- After you add a component label, assign the component owner (the shepherd) to the PR:
|
||||
- Call `assign_owner_to_pr` with the same label you applied.
|
||||
- Skip assignment if the PR already has an assignee.
|
||||
- If the tool reports the owner is not assignable, just note it; do not comment about it.
|
||||
|
||||
# 5. Output
|
||||
Present the following in an easy to read format highlighting PR number and your label.
|
||||
- The PR summary in a few sentence
|
||||
- The label you recommended or added with the justification
|
||||
- The owner you assigned (or why you did not)
|
||||
- The comment you recommended or added to the PR with the justification
|
||||
""",
|
||||
tools=[
|
||||
list_untriaged_pull_requests,
|
||||
get_pull_request_details,
|
||||
add_label_to_pr,
|
||||
assign_owner_to_pr,
|
||||
add_comment_to_pr,
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
# Copyright 2026 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 logging
|
||||
import time
|
||||
|
||||
from adk_pr_triaging_agent import agent
|
||||
from adk_pr_triaging_agent.settings import OWNER
|
||||
from adk_pr_triaging_agent.settings import PR_COUNT_TO_PROCESS
|
||||
from adk_pr_triaging_agent.settings import PULL_REQUEST_NUMBER
|
||||
from adk_pr_triaging_agent.settings import REPO
|
||||
from adk_pr_triaging_agent.utils import call_agent_async
|
||||
from adk_pr_triaging_agent.utils import parse_number_string
|
||||
from google.adk.cli.utils import logs
|
||||
from google.adk.runners import InMemoryRunner
|
||||
|
||||
APP_NAME = "adk_pr_triaging_app"
|
||||
USER_ID = "adk_pr_triaging_user"
|
||||
|
||||
logs.setup_adk_logger(level=logging.DEBUG)
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
pr_number = parse_number_string(PULL_REQUEST_NUMBER)
|
||||
if pr_number:
|
||||
prompt = f"Please triage pull request #{pr_number}!"
|
||||
else:
|
||||
pr_count = parse_number_string(PR_COUNT_TO_PROCESS, default_value=10)
|
||||
print(
|
||||
"No pull request number received. Operating in batch mode (limit:"
|
||||
f" {pr_count})."
|
||||
)
|
||||
prompt = (
|
||||
f"Please use 'list_untriaged_pull_requests' to find {pr_count} pull"
|
||||
" requests that need triaging, then triage each one according to your"
|
||||
" instructions."
|
||||
)
|
||||
|
||||
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 triaging {OWNER}/{REPO} pull request #{PULL_REQUEST_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(
|
||||
"Triaging 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,33 @@
|
||||
# Copyright 2026 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")
|
||||
|
||||
OWNER = os.getenv("OWNER", "google")
|
||||
REPO = os.getenv("REPO", "adk-python")
|
||||
PULL_REQUEST_NUMBER = os.getenv("PULL_REQUEST_NUMBER")
|
||||
PR_COUNT_TO_PROCESS = os.getenv("PR_COUNT_TO_PROCESS", "10")
|
||||
|
||||
IS_INTERACTIVE = os.environ.get("INTERACTIVE", "1").lower() in ["true", "1"]
|
||||
@@ -0,0 +1,133 @@
|
||||
# Copyright 2026 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_pr_triaging_agent.settings import GITHUB_BASE_URL
|
||||
from adk_pr_triaging_agent.settings import GITHUB_GRAPHQL_URL
|
||||
from adk_pr_triaging_agent.settings import GITHUB_TOKEN
|
||||
from adk_pr_triaging_agent.settings import OWNER
|
||||
from adk_pr_triaging_agent.settings import REPO
|
||||
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",
|
||||
}
|
||||
|
||||
diff_headers = {
|
||||
"Authorization": f"token {GITHUB_TOKEN}",
|
||||
"Accept": "application/vnd.github.v3.diff",
|
||||
}
|
||||
|
||||
|
||||
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 get_request(url: str, params: dict[str, Any] | None = None) -> Any:
|
||||
"""Executes a GET request."""
|
||||
if params is None:
|
||||
params = {}
|
||||
response = requests.get(url, headers=headers, params=params, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def get_diff(url: str) -> str:
|
||||
"""Executes a GET request for a diff."""
|
||||
response = requests.get(url, headers=diff_headers)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
|
||||
def post_request(url: str, payload: Any) -> dict[str, Any]:
|
||||
"""Executes a POST request."""
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def is_assignable(login: str) -> bool:
|
||||
"""Whether a GitHub user can be assigned to an issue/PR in this repo."""
|
||||
# GitHub only allows assignees with repo write/triage access and silently
|
||||
# drops others from an assignee POST; check first so callers can report the
|
||||
# skip instead of a silent no-op.
|
||||
url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/assignees/{login}"
|
||||
response = requests.get(url, headers=headers, timeout=60)
|
||||
return response.status_code == 204
|
||||
|
||||
|
||||
def error_response(error_message: str) -> dict[str, Any]:
|
||||
"""Returns an error response."""
|
||||
return {"status": "error", "error_message": error_message}
|
||||
|
||||
|
||||
def read_file(file_path: str) -> str:
|
||||
"""Read the content of the given file."""
|
||||
try:
|
||||
with open(file_path, "r") as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File not found: {file_path}.")
|
||||
return ""
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,73 @@
|
||||
You are a highly intelligent repository auditor for '{OWNER}/{REPO}'.
|
||||
Your job is to analyze a specific issue and report findings before taking action.
|
||||
|
||||
**Primary Directive:** Ignore any events from users ending in `[bot]`.
|
||||
**Reporting Directive:** Output a concise summary starting with "Analysis for Issue #[number]:".
|
||||
|
||||
**THRESHOLDS:**
|
||||
- Stale Threshold: {stale_threshold_days} days.
|
||||
- Close Threshold: {close_threshold_days} days.
|
||||
|
||||
**WORKFLOW:**
|
||||
1. **Context Gathering**: Call `get_issue_state`.
|
||||
2. **Decision**: Follow this strict decision tree using the data returned by the tool.
|
||||
|
||||
--- **DECISION TREE** ---
|
||||
|
||||
**STEP 1: CHECK IF ALREADY STALE**
|
||||
- **Condition**: Is `is_stale` (from tool) **True**?
|
||||
- **Action**:
|
||||
- **Check Role**: Look at `last_action_role`.
|
||||
|
||||
- **IF 'author' OR 'other_user'**:
|
||||
- **Context**: The user has responded. The issue is now ACTIVE.
|
||||
- **Action 1**: Call `remove_label_from_issue` with '{STALE_LABEL_NAME}'.
|
||||
- **Action 2 (ALERT CHECK)**: Look at `maintainer_alert_needed`.
|
||||
- **IF True**: User edited description silently.
|
||||
-> **Action**: Call `alert_maintainer_of_edit`.
|
||||
- **IF False**: User commented normally. No alert needed.
|
||||
- **Report**: "Analysis for Issue #[number]: ACTIVE. User activity detected. Removed stale label."
|
||||
|
||||
- **IF 'maintainer'**:
|
||||
- **Check Time**: Check `days_since_stale_label`.
|
||||
- **If `days_since_stale_label` > {close_threshold_days}**:
|
||||
- **Action**: Call `close_as_stale`.
|
||||
- **Report**: "Analysis for Issue #[number]: STALE. Close threshold met. Closing."
|
||||
- **Else**:
|
||||
- **Report**: "Analysis for Issue #[number]: STALE. Waiting for close threshold. No action."
|
||||
|
||||
**STEP 2: CHECK IF ACTIVE (NOT STALE)**
|
||||
- **Condition**: `is_stale` is **False**.
|
||||
- **Action**:
|
||||
- **Check Role**: If `last_action_role` is 'author' or 'other_user':
|
||||
- **Context**: The issue is Active.
|
||||
- **Action (ALERT CHECK)**: Look at `maintainer_alert_needed`.
|
||||
- **IF True**: The user edited the description silently, and we haven't alerted yet.
|
||||
-> **Action**: Call `alert_maintainer_of_edit`.
|
||||
-> **Report**: "Analysis for Issue #[number]: ACTIVE. Silent update detected (Description Edit). Alerted maintainer."
|
||||
- **IF False**:
|
||||
-> **Report**: "Analysis for Issue #[number]: ACTIVE. Last action was by user. No action."
|
||||
|
||||
- **Check Role**: If `last_action_role` is 'maintainer':
|
||||
- **Proceed to STEP 3.**
|
||||
|
||||
**STEP 3: ANALYZE MAINTAINER INTENT**
|
||||
- **Context**: The last person to act was a Maintainer.
|
||||
- **Action**: Analyze `last_comment_text` using `maintainers` list and `last_actor_name`.
|
||||
|
||||
- **Internal Discussion Check**: Does the comment mention or address any username found in the `maintainers` list (other than the speaker `last_actor_name`)?
|
||||
- **Verdict**: **ACTIVE** (Internal Team Discussion).
|
||||
- **Report**: "Analysis for Issue #[number]: ACTIVE. Maintainer is discussing with another maintainer. No action."
|
||||
|
||||
- **Question Check**: Does the text ask a question, request clarification, ask for logs, or give suggestions?
|
||||
- **Time Check**: Is `days_since_activity` > {stale_threshold_days}?
|
||||
|
||||
- **DECISION**:
|
||||
- **IF (Question == YES) AND (Time == YES) AND (Internal Discussion Check == FALSE):**
|
||||
- **Action**: Call `add_stale_label_and_comment`.
|
||||
- **Check**: If '{REQUEST_CLARIFICATION_LABEL}' is not in `current_labels`, call `add_label_to_issue` with '{REQUEST_CLARIFICATION_LABEL}'.
|
||||
- **Report**: "Analysis for Issue #[number]: STALE. Maintainer asked question [days_since_activity] days ago. Marking stale."
|
||||
- **IF (Question == YES) BUT (Time == NO)**:
|
||||
- **Report**: "Analysis for Issue #[number]: PENDING. Maintainer asked question, but threshold not met yet. No action."
|
||||
- **IF (Question == NO) OR (Internal Discussion Check == TRUE):**
|
||||
- **Report**: "Analysis for Issue #[number]: ACTIVE. Maintainer gave status update or internal discussion detected. No action."
|
||||
@@ -0,0 +1,97 @@
|
||||
# ADK Stale Issue Auditor Agent
|
||||
|
||||
This directory contains an autonomous, **GraphQL-powered** agent designed to audit a GitHub repository for stale issues. It maintains repository hygiene by ensuring all open items are actionable and responsive.
|
||||
|
||||
Unlike traditional "Stale Bots" that only look at timestamps, this agent uses a **Unified History Trace** and an **LLM (Large Language Model)** to understand the *context* of a conversation. It distinguishes between a maintainer asking a question (stale candidate) vs. a maintainer providing a status update (active).
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Core Logic & Features
|
||||
|
||||
The agent operates as a "Repository Auditor," proactively scanning open issues using a high-efficiency decision tree.
|
||||
|
||||
### 1. Smart State Verification (GraphQL)
|
||||
|
||||
Instead of making multiple expensive API calls, the agent uses a single **GraphQL** query per issue to reconstruct the entire history of the conversation. It combines:
|
||||
|
||||
- **Comments**
|
||||
- **Description/Body Edits** ("Ghost Edits")
|
||||
- **Title Renames**
|
||||
- **State Changes** (Reopens)
|
||||
|
||||
It sorts these events chronologically to determine the **Last Active Actor**.
|
||||
|
||||
### 2. The "Last Actor" Rule
|
||||
|
||||
The agent follows a precise logic flow based on who acted last:
|
||||
|
||||
- **If Author/User acted last:** The issue is **ACTIVE**.
|
||||
|
||||
- This includes comments, title changes, and *silent* description edits.
|
||||
- **Action:** The agent immediately removes the `stale` label.
|
||||
- **Silent Update Alert:** If the user edited the description but *did not* comment, the agent posts a specific alert: *"Notification: The author has updated the issue description..."* to ensure maintainers are notified (since GitHub does not trigger notifications for body edits).
|
||||
- **Spam Prevention:** The agent checks if it has already alerted about a specific silent edit to avoid spamming the thread.
|
||||
|
||||
- **If Maintainer acted last:** The issue is **POTENTIALLY STALE**.
|
||||
|
||||
- The agent passes the text of the maintainer's last comment to the LLM.
|
||||
|
||||
### 3. Semantic Intent Analysis (LLM)
|
||||
|
||||
If the maintainer was the last person to speak, the LLM analyzes the comment text to determine intent:
|
||||
|
||||
- **Question/Request:** "Can you provide logs?" / "Please try v2.0."
|
||||
- **Verdict:** **STALE** (Waiting on Author).
|
||||
- **Action:** If the time threshold is met, the agent adds the `stale` label. It also checks for the `request clarification` label and adds it if missing.
|
||||
- **Status Update:** "We are working on a fix." / "Added to backlog."
|
||||
- **Verdict:** **ACTIVE** (Waiting on Maintainer).
|
||||
- **Action:** No action taken. The issue remains open without stale labels.
|
||||
|
||||
### 4. Lifecycle Management
|
||||
|
||||
- **Marking Stale:** After `STALE_HOURS_THRESHOLD` (default: 7 days) of inactivity following a maintainer's question.
|
||||
- **Closing:** After `CLOSE_HOURS_AFTER_STALE_THRESHOLD` (default: 7 days) of continued inactivity while marked stale.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Performance & Safety
|
||||
|
||||
- **GraphQL Optimized:** Fetches comments, edits, labels, and timeline events in a single network request to minimize latency and API quota usage.
|
||||
- **Search API Filtering:** Uses the GitHub Search API to pre-filter issues created recently, ensuring the bot doesn't waste cycles analyzing brand-new issues.
|
||||
- **Rate Limit Aware:** Includes intelligent sleeping and retry logic (exponential backoff) to handle GitHub API rate limits (HTTP 429) gracefully.
|
||||
- **Execution Metrics:** Logs the time taken and API calls consumed for every issue processed.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Configuration
|
||||
|
||||
The agent is configured via environment variables, typically set as secrets in GitHub Actions.
|
||||
|
||||
### Required Secrets
|
||||
|
||||
| Secret Name | Description |
|
||||
| :--------------- | :------------------------------------------------------------------------------- |
|
||||
| `GITHUB_TOKEN` | A GitHub Personal Access Token (PAT) or Service Account Token with `repo` scope. |
|
||||
| `GOOGLE_API_KEY` | An API key for the Google AI (Gemini) model used for reasoning. |
|
||||
|
||||
### Optional Configuration
|
||||
|
||||
These variables control the timing thresholds and model selection.
|
||||
|
||||
| Variable Name | Description | Default |
|
||||
| :---------------------------------- | :--------------------------------------------------------------------------- | :---------------------- |
|
||||
| `STALE_HOURS_THRESHOLD` | Hours of inactivity after a maintainer's question before marking as `stale`. | `168` (7 days) |
|
||||
| `CLOSE_HOURS_AFTER_STALE_THRESHOLD` | Hours after being marked `stale` before the issue is closed. | `168` (7 days) |
|
||||
| `LLM_MODEL_NAME` | The specific Gemini model version to use. | `gemini-2.5-flash` |
|
||||
| `OWNER` | Repository owner (auto-detected in Actions). | (Environment dependent) |
|
||||
| `REPO` | Repository name (auto-detected in Actions). | (Environment dependent) |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
## Deployment
|
||||
|
||||
To deploy this agent, a GitHub Actions workflow file (`.github/workflows/stale-bot.yml`) is recommended.
|
||||
|
||||
### Directory Structure Note
|
||||
|
||||
Because this agent resides within the `adk-python` package structure, the workflow must ensure the script is executed correctly to handle imports.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,606 @@
|
||||
# Copyright 2026 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 datetime import datetime
|
||||
from datetime import timezone
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
from adk_stale_agent.settings import CLOSE_HOURS_AFTER_STALE_THRESHOLD
|
||||
from adk_stale_agent.settings import GITHUB_BASE_URL
|
||||
from adk_stale_agent.settings import GRAPHQL_COMMENT_LIMIT
|
||||
from adk_stale_agent.settings import GRAPHQL_EDIT_LIMIT
|
||||
from adk_stale_agent.settings import GRAPHQL_TIMELINE_LIMIT
|
||||
from adk_stale_agent.settings import LLM_MODEL_NAME
|
||||
from adk_stale_agent.settings import OWNER
|
||||
from adk_stale_agent.settings import REPO
|
||||
from adk_stale_agent.settings import REQUEST_CLARIFICATION_LABEL
|
||||
from adk_stale_agent.settings import STALE_HOURS_THRESHOLD
|
||||
from adk_stale_agent.settings import STALE_LABEL_NAME
|
||||
from adk_stale_agent.utils import delete_request
|
||||
from adk_stale_agent.utils import error_response
|
||||
from adk_stale_agent.utils import get_request
|
||||
from adk_stale_agent.utils import patch_request
|
||||
from adk_stale_agent.utils import post_request
|
||||
import dateutil.parser
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
logger = logging.getLogger("google_adk." + __name__)
|
||||
|
||||
# --- Constants ---
|
||||
# Used to detect if the bot has already posted an alert to avoid spamming.
|
||||
BOT_ALERT_SIGNATURE = (
|
||||
"**Notification:** The author has updated the issue description"
|
||||
)
|
||||
BOT_NAME = "adk-bot"
|
||||
|
||||
# --- Global Cache ---
|
||||
_MAINTAINERS_CACHE: Optional[List[str]] = None
|
||||
|
||||
|
||||
def _get_cached_maintainers() -> List[str]:
|
||||
"""
|
||||
Fetches the list of repository maintainers.
|
||||
|
||||
This function relies on `utils.get_request` for network resilience.
|
||||
`get_request` is configured with an HTTPAdapter that automatically performs
|
||||
exponential backoff retries (up to 6 times) for 5xx errors and rate limits.
|
||||
|
||||
If the retries are exhausted or the data format is invalid, this function
|
||||
raises a RuntimeError to prevent the bot from running with incorrect permissions.
|
||||
|
||||
Returns:
|
||||
List[str]: A list of GitHub usernames with push access.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the API fails after all retries or returns invalid data.
|
||||
"""
|
||||
global _MAINTAINERS_CACHE
|
||||
if _MAINTAINERS_CACHE is not None:
|
||||
return _MAINTAINERS_CACHE
|
||||
|
||||
logger.info("Initializing Maintainers Cache...")
|
||||
|
||||
try:
|
||||
url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/collaborators"
|
||||
params = {"permission": "push"}
|
||||
|
||||
data = get_request(url, params)
|
||||
|
||||
if isinstance(data, list):
|
||||
_MAINTAINERS_CACHE = [u["login"] for u in data if "login" in u]
|
||||
logger.info(f"Cached {len(_MAINTAINERS_CACHE)} maintainers.")
|
||||
return _MAINTAINERS_CACHE
|
||||
else:
|
||||
logger.error(
|
||||
f"Invalid API response format: Expected list, got {type(data)}"
|
||||
)
|
||||
raise ValueError(f"GitHub API returned non-list data: {data}")
|
||||
|
||||
except Exception as e:
|
||||
logger.critical(
|
||||
f"FATAL: Failed to verify repository maintainers. Error: {e}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Maintainer verification failed. processing aborted."
|
||||
) from e
|
||||
|
||||
|
||||
def load_prompt_template(filename: str) -> str:
|
||||
"""
|
||||
Loads the raw text content of a prompt file.
|
||||
|
||||
Args:
|
||||
filename (str): The name of the file (e.g., 'PROMPT_INSTRUCTION.txt').
|
||||
|
||||
Returns:
|
||||
str: The file content.
|
||||
"""
|
||||
file_path = os.path.join(os.path.dirname(__file__), filename)
|
||||
with open(file_path, "r") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
PROMPT_TEMPLATE = load_prompt_template("PROMPT_INSTRUCTION.txt")
|
||||
|
||||
|
||||
def _fetch_graphql_data(item_number: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Executes the GraphQL query to fetch raw issue data, including comments,
|
||||
edits, and timeline events.
|
||||
|
||||
Args:
|
||||
item_number (int): The GitHub issue number.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: The raw 'issue' object from the GraphQL response.
|
||||
|
||||
Raises:
|
||||
RequestException: If the GraphQL query returns errors or the issue is not found.
|
||||
"""
|
||||
query = """
|
||||
query($owner: String!, $name: String!, $number: Int!, $commentLimit: Int!, $timelineLimit: Int!, $editLimit: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issue(number: $number) {
|
||||
author { login }
|
||||
createdAt
|
||||
labels(first: 20) { nodes { name } }
|
||||
|
||||
comments(last: $commentLimit) {
|
||||
nodes {
|
||||
author { login }
|
||||
body
|
||||
createdAt
|
||||
lastEditedAt
|
||||
}
|
||||
}
|
||||
|
||||
userContentEdits(last: $editLimit) {
|
||||
nodes {
|
||||
editor { login }
|
||||
editedAt
|
||||
}
|
||||
}
|
||||
|
||||
timelineItems(itemTypes: [LABELED_EVENT, RENAMED_TITLE_EVENT, REOPENED_EVENT], last: $timelineLimit) {
|
||||
nodes {
|
||||
__typename
|
||||
... on LabeledEvent {
|
||||
createdAt
|
||||
actor { login }
|
||||
label { name }
|
||||
}
|
||||
... on RenamedTitleEvent {
|
||||
createdAt
|
||||
actor { login }
|
||||
}
|
||||
... on ReopenedEvent {
|
||||
createdAt
|
||||
actor { login }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
variables = {
|
||||
"owner": OWNER,
|
||||
"name": REPO,
|
||||
"number": item_number,
|
||||
"commentLimit": GRAPHQL_COMMENT_LIMIT,
|
||||
"editLimit": GRAPHQL_EDIT_LIMIT,
|
||||
"timelineLimit": GRAPHQL_TIMELINE_LIMIT,
|
||||
}
|
||||
|
||||
response = post_request(
|
||||
f"{GITHUB_BASE_URL}/graphql", {"query": query, "variables": variables}
|
||||
)
|
||||
|
||||
if "errors" in response:
|
||||
raise RequestException(f"GraphQL Error: {response['errors'][0]['message']}")
|
||||
|
||||
data = response.get("data", {}).get("repository", {}).get("issue", {})
|
||||
if not data:
|
||||
raise RequestException(f"Issue #{item_number} not found.")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _build_history_timeline(
|
||||
data: Dict[str, Any],
|
||||
) -> Tuple[List[Dict[str, Any]], List[datetime], Optional[datetime]]:
|
||||
"""
|
||||
Parses raw GraphQL data into a unified, chronologically sorted history list.
|
||||
Also extracts specific event times needed for logic checks.
|
||||
|
||||
Args:
|
||||
data (Dict[str, Any]): The raw issue data from `_fetch_graphql_data`.
|
||||
|
||||
Returns:
|
||||
Tuple[List[Dict], List[datetime], Optional[datetime]]:
|
||||
- history: A list of normalized event dictionaries sorted by time.
|
||||
- label_events: A list of timestamps when the stale label was applied.
|
||||
- last_bot_alert_time: Timestamp of the last bot silent-edit alert (if any).
|
||||
"""
|
||||
issue_author = data.get("author", {}).get("login")
|
||||
history = []
|
||||
label_events = []
|
||||
last_bot_alert_time = None
|
||||
|
||||
# 1. Baseline: Issue Creation
|
||||
history.append({
|
||||
"type": "created",
|
||||
"actor": issue_author,
|
||||
"time": dateutil.parser.isoparse(data["createdAt"]),
|
||||
"data": None,
|
||||
})
|
||||
|
||||
# 2. Process Comments
|
||||
for c in data.get("comments", {}).get("nodes", []):
|
||||
if not c:
|
||||
continue
|
||||
|
||||
actor = c.get("author", {}).get("login")
|
||||
c_body = c.get("body", "")
|
||||
c_time = dateutil.parser.isoparse(c.get("createdAt"))
|
||||
|
||||
# Track bot alerts for spam prevention
|
||||
if BOT_ALERT_SIGNATURE in c_body:
|
||||
if last_bot_alert_time is None or c_time > last_bot_alert_time:
|
||||
last_bot_alert_time = c_time
|
||||
continue
|
||||
|
||||
if actor and not actor.endswith("[bot]") and actor != BOT_NAME:
|
||||
# Use edit time if available, otherwise creation time
|
||||
e_time = c.get("lastEditedAt")
|
||||
actual_time = dateutil.parser.isoparse(e_time) if e_time else c_time
|
||||
history.append({
|
||||
"type": "commented",
|
||||
"actor": actor,
|
||||
"time": actual_time,
|
||||
"data": c_body,
|
||||
})
|
||||
|
||||
# 3. Process Body Edits ("Ghost Edits")
|
||||
for e in data.get("userContentEdits", {}).get("nodes", []):
|
||||
if not e:
|
||||
continue
|
||||
actor = e.get("editor", {}).get("login")
|
||||
if actor and not actor.endswith("[bot]") and actor != BOT_NAME:
|
||||
history.append({
|
||||
"type": "edited_description",
|
||||
"actor": actor,
|
||||
"time": dateutil.parser.isoparse(e.get("editedAt")),
|
||||
"data": None,
|
||||
})
|
||||
|
||||
# 4. Process Timeline Events
|
||||
for t in data.get("timelineItems", {}).get("nodes", []):
|
||||
if not t:
|
||||
continue
|
||||
|
||||
etype = t.get("__typename")
|
||||
actor = t.get("actor", {}).get("login")
|
||||
time_val = dateutil.parser.isoparse(t.get("createdAt"))
|
||||
|
||||
if etype == "LabeledEvent":
|
||||
if t.get("label", {}).get("name") == STALE_LABEL_NAME:
|
||||
label_events.append(time_val)
|
||||
continue
|
||||
|
||||
if actor and not actor.endswith("[bot]") and actor != BOT_NAME:
|
||||
pretty_type = (
|
||||
"renamed_title" if etype == "RenamedTitleEvent" else "reopened"
|
||||
)
|
||||
history.append({
|
||||
"type": pretty_type,
|
||||
"actor": actor,
|
||||
"time": time_val,
|
||||
"data": None,
|
||||
})
|
||||
|
||||
# Sort chronologically
|
||||
history.sort(key=lambda x: x["time"])
|
||||
return history, label_events, last_bot_alert_time
|
||||
|
||||
|
||||
def _replay_history_to_find_state(
|
||||
history: List[Dict[str, Any]], maintainers: List[str], issue_author: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Replays the unified event history to determine the absolute last actor and their role.
|
||||
|
||||
Args:
|
||||
history (List[Dict]): Chronologically sorted list of events.
|
||||
maintainers (List[str]): List of maintainer usernames.
|
||||
issue_author (str): Username of the issue author.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: A dictionary containing the last state of the issue:
|
||||
- last_action_role (str): 'author', 'maintainer', or 'other_user'.
|
||||
- last_activity_time (datetime): Timestamp of the last human action.
|
||||
- last_action_type (str): The type of the last action (e.g., 'commented').
|
||||
- last_comment_text (Optional[str]): The text of the last comment.
|
||||
- last_actor_name (str): The specific username of the last actor.
|
||||
"""
|
||||
last_action_role = "author"
|
||||
last_activity_time = history[0]["time"]
|
||||
last_action_type = "created"
|
||||
last_comment_text = None
|
||||
last_actor_name = issue_author
|
||||
|
||||
for event in history:
|
||||
actor = event["actor"]
|
||||
etype = event["type"]
|
||||
|
||||
role = "other_user"
|
||||
if actor == issue_author:
|
||||
role = "author"
|
||||
elif actor in maintainers:
|
||||
role = "maintainer"
|
||||
|
||||
last_action_role = role
|
||||
last_activity_time = event["time"]
|
||||
last_action_type = etype
|
||||
last_actor_name = actor
|
||||
|
||||
# Only store text if it was a comment (resets on other events like labels/edits)
|
||||
if etype == "commented":
|
||||
last_comment_text = event["data"]
|
||||
else:
|
||||
last_comment_text = None
|
||||
|
||||
return {
|
||||
"last_action_role": last_action_role,
|
||||
"last_activity_time": last_activity_time,
|
||||
"last_action_type": last_action_type,
|
||||
"last_comment_text": last_comment_text,
|
||||
"last_actor_name": last_actor_name,
|
||||
}
|
||||
|
||||
|
||||
def get_issue_state(item_number: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrieves the comprehensive state of a GitHub issue using GraphQL.
|
||||
|
||||
This function orchestrates the fetching, parsing, and analysis of the issue's
|
||||
history to determine if it is stale, active, or pending maintainer review.
|
||||
|
||||
Args:
|
||||
item_number (int): The GitHub issue number.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: A comprehensive state dictionary for the LLM agent.
|
||||
Contains keys such as 'last_action_role', 'is_stale', 'days_since_activity',
|
||||
and 'maintainer_alert_needed'.
|
||||
"""
|
||||
try:
|
||||
maintainers = _get_cached_maintainers()
|
||||
|
||||
# 1. Fetch
|
||||
raw_data = _fetch_graphql_data(item_number)
|
||||
|
||||
issue_author = raw_data.get("author", {}).get("login")
|
||||
labels_list = [
|
||||
l["name"] for l in raw_data.get("labels", {}).get("nodes", [])
|
||||
]
|
||||
|
||||
# 2. Parse & Sort
|
||||
history, label_events, last_bot_alert_time = _build_history_timeline(
|
||||
raw_data
|
||||
)
|
||||
|
||||
# 3. Analyze (Replay)
|
||||
state = _replay_history_to_find_state(history, maintainers, issue_author)
|
||||
|
||||
# 4. Final Calculations & Alert Logic
|
||||
current_time = datetime.now(timezone.utc)
|
||||
days_since_activity = (
|
||||
current_time - state["last_activity_time"]
|
||||
).total_seconds() / 86400
|
||||
|
||||
# Stale Checks
|
||||
is_stale = STALE_LABEL_NAME in labels_list
|
||||
days_since_stale_label = 0.0
|
||||
if is_stale and label_events:
|
||||
latest_label_time = max(label_events)
|
||||
days_since_stale_label = (
|
||||
current_time - latest_label_time
|
||||
).total_seconds() / 86400
|
||||
|
||||
# Silent Edit Alert Logic
|
||||
maintainer_alert_needed = False
|
||||
if (
|
||||
state["last_action_role"] in ["author", "other_user"]
|
||||
and state["last_action_type"] == "edited_description"
|
||||
):
|
||||
if (
|
||||
last_bot_alert_time
|
||||
and last_bot_alert_time > state["last_activity_time"]
|
||||
):
|
||||
logger.info(
|
||||
f"#{item_number}: Silent edit detected, but Bot already alerted. No"
|
||||
" spam."
|
||||
)
|
||||
else:
|
||||
maintainer_alert_needed = True
|
||||
logger.info(f"#{item_number}: Silent edit detected. Alert needed.")
|
||||
|
||||
logger.debug(
|
||||
f"#{item_number} VERDICT: Role={state['last_action_role']}, "
|
||||
f"Idle={days_since_activity:.2f}d"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"last_action_role": state["last_action_role"],
|
||||
"last_action_type": state["last_action_type"],
|
||||
"last_actor_name": state["last_actor_name"],
|
||||
"maintainer_alert_needed": maintainer_alert_needed,
|
||||
"is_stale": is_stale,
|
||||
"days_since_activity": days_since_activity,
|
||||
"days_since_stale_label": days_since_stale_label,
|
||||
"last_comment_text": state["last_comment_text"],
|
||||
"current_labels": labels_list,
|
||||
"stale_threshold_days": STALE_HOURS_THRESHOLD / 24,
|
||||
"close_threshold_days": CLOSE_HOURS_AFTER_STALE_THRESHOLD / 24,
|
||||
"maintainers": maintainers,
|
||||
"issue_author": issue_author,
|
||||
}
|
||||
|
||||
except RequestException as e:
|
||||
return error_response(f"Network Error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error analyzing #{item_number}: {e}", exc_info=True
|
||||
)
|
||||
return error_response(f"Analysis Error: {e}")
|
||||
|
||||
|
||||
# --- Tool Definitions ---
|
||||
|
||||
|
||||
def _format_days(hours: float) -> str:
|
||||
"""
|
||||
Formats a duration in hours into a clean day string.
|
||||
|
||||
Example:
|
||||
168.0 -> "7"
|
||||
12.0 -> "0.5"
|
||||
"""
|
||||
days = hours / 24
|
||||
return f"{days:.1f}" if days % 1 != 0 else f"{int(days)}"
|
||||
|
||||
|
||||
def add_label_to_issue(item_number: int, label_name: str) -> dict[str, Any]:
|
||||
"""
|
||||
Adds a label to the issue.
|
||||
|
||||
Args:
|
||||
item_number (int): The GitHub issue number.
|
||||
label_name (str): The name of the label to add.
|
||||
"""
|
||||
logger.debug(f"Adding label '{label_name}' to issue #{item_number}.")
|
||||
url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/labels"
|
||||
try:
|
||||
post_request(url, [label_name])
|
||||
return {"status": "success"}
|
||||
except RequestException as e:
|
||||
return error_response(f"Error adding label: {e}")
|
||||
|
||||
|
||||
def remove_label_from_issue(
|
||||
item_number: int, label_name: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Removes a label from the issue.
|
||||
|
||||
Args:
|
||||
item_number (int): The GitHub issue number.
|
||||
label_name (str): The name of the label to remove.
|
||||
"""
|
||||
logger.debug(f"Removing label '{label_name}' from issue #{item_number}.")
|
||||
url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/labels/{label_name}"
|
||||
try:
|
||||
delete_request(url)
|
||||
return {"status": "success"}
|
||||
except RequestException as e:
|
||||
return error_response(f"Error removing label: {e}")
|
||||
|
||||
|
||||
def add_stale_label_and_comment(item_number: int) -> dict[str, Any]:
|
||||
"""
|
||||
Marks the issue as stale with a comment and label.
|
||||
|
||||
Args:
|
||||
item_number (int): The GitHub issue number.
|
||||
"""
|
||||
stale_days_str = _format_days(STALE_HOURS_THRESHOLD)
|
||||
close_days_str = _format_days(CLOSE_HOURS_AFTER_STALE_THRESHOLD)
|
||||
|
||||
comment = (
|
||||
"This issue has been automatically marked as stale because it has not"
|
||||
f" had recent activity for {stale_days_str} days after a maintainer"
|
||||
" requested clarification. It will be closed if no further activity"
|
||||
f" occurs within {close_days_str} days."
|
||||
)
|
||||
try:
|
||||
post_request(
|
||||
f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/comments",
|
||||
{"body": comment},
|
||||
)
|
||||
post_request(
|
||||
f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/labels",
|
||||
[STALE_LABEL_NAME],
|
||||
)
|
||||
return {"status": "success"}
|
||||
except RequestException as e:
|
||||
return error_response(f"Error marking issue as stale: {e}")
|
||||
|
||||
|
||||
def alert_maintainer_of_edit(item_number: int) -> dict[str, Any]:
|
||||
"""
|
||||
Posts a comment alerting maintainers of a silent description update.
|
||||
|
||||
Args:
|
||||
item_number (int): The GitHub issue number.
|
||||
"""
|
||||
# Uses the constant signature to ensure detection logic in get_issue_state works.
|
||||
comment = f"{BOT_ALERT_SIGNATURE}. Maintainers, please review."
|
||||
try:
|
||||
post_request(
|
||||
f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/comments",
|
||||
{"body": comment},
|
||||
)
|
||||
return {"status": "success"}
|
||||
except RequestException as e:
|
||||
return error_response(f"Error posting alert: {e}")
|
||||
|
||||
|
||||
def close_as_stale(item_number: int) -> dict[str, Any]:
|
||||
"""
|
||||
Closes the issue as not planned/stale.
|
||||
|
||||
Args:
|
||||
item_number (int): The GitHub issue number.
|
||||
"""
|
||||
days_str = _format_days(CLOSE_HOURS_AFTER_STALE_THRESHOLD)
|
||||
|
||||
comment = (
|
||||
"This has been automatically closed because it has been marked as stale"
|
||||
f" for over {days_str} days."
|
||||
)
|
||||
try:
|
||||
post_request(
|
||||
f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}/comments",
|
||||
{"body": comment},
|
||||
)
|
||||
patch_request(
|
||||
f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{item_number}",
|
||||
{"state": "closed"},
|
||||
)
|
||||
return {"status": "success"}
|
||||
except RequestException as e:
|
||||
return error_response(f"Error closing issue: {e}")
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
model=LLM_MODEL_NAME,
|
||||
name="adk_repository_auditor_agent",
|
||||
description="Audits open issues.",
|
||||
instruction=PROMPT_TEMPLATE.format(
|
||||
OWNER=OWNER,
|
||||
REPO=REPO,
|
||||
STALE_LABEL_NAME=STALE_LABEL_NAME,
|
||||
REQUEST_CLARIFICATION_LABEL=REQUEST_CLARIFICATION_LABEL,
|
||||
stale_threshold_days=STALE_HOURS_THRESHOLD / 24,
|
||||
close_threshold_days=CLOSE_HOURS_AFTER_STALE_THRESHOLD / 24,
|
||||
),
|
||||
tools=[
|
||||
add_label_to_issue,
|
||||
add_stale_label_and_comment,
|
||||
alert_maintainer_of_edit,
|
||||
close_as_stale,
|
||||
get_issue_state,
|
||||
remove_label_from_issue,
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
# Copyright 2026 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 logging
|
||||
import time
|
||||
from typing import Tuple
|
||||
|
||||
from adk_stale_agent.agent import root_agent
|
||||
from adk_stale_agent.settings import CONCURRENCY_LIMIT
|
||||
from adk_stale_agent.settings import OWNER
|
||||
from adk_stale_agent.settings import REPO
|
||||
from adk_stale_agent.settings import SLEEP_BETWEEN_CHUNKS
|
||||
from adk_stale_agent.settings import STALE_HOURS_THRESHOLD
|
||||
from adk_stale_agent.utils import get_api_call_count
|
||||
from adk_stale_agent.utils import get_old_open_issue_numbers
|
||||
from adk_stale_agent.utils import reset_api_call_count
|
||||
from google.adk.cli.utils import logs
|
||||
from google.adk.runners import InMemoryRunner
|
||||
from google.genai import types
|
||||
|
||||
logs.setup_adk_logger(level=logging.INFO)
|
||||
logger = logging.getLogger("google_adk." + __name__)
|
||||
|
||||
APP_NAME = "stale_bot_app"
|
||||
USER_ID = "stale_bot_user"
|
||||
|
||||
|
||||
async def process_single_issue(issue_number: int) -> Tuple[float, int]:
|
||||
"""
|
||||
Processes a single GitHub issue using the AI agent and logs execution metrics.
|
||||
|
||||
Args:
|
||||
issue_number (int): The GitHub issue number to audit.
|
||||
|
||||
Returns:
|
||||
Tuple[float, int]: A tuple containing:
|
||||
- duration (float): Time taken to process the issue in seconds.
|
||||
- api_calls (int): The number of API calls made during this specific execution.
|
||||
|
||||
Raises:
|
||||
Exception: catches generic exceptions to prevent one failure from stopping the batch.
|
||||
"""
|
||||
start_time = time.perf_counter()
|
||||
|
||||
start_api_calls = get_api_call_count()
|
||||
|
||||
logger.info(f"Processing Issue #{issue_number}...")
|
||||
logger.debug(f"#{issue_number}: Initializing runner and session.")
|
||||
|
||||
try:
|
||||
runner = InMemoryRunner(agent=root_agent, app_name=APP_NAME)
|
||||
session = await runner.session_service.create_session(
|
||||
user_id=USER_ID, app_name=APP_NAME
|
||||
)
|
||||
|
||||
prompt_text = f"Audit Issue #{issue_number}."
|
||||
prompt_message = types.Content(
|
||||
role="user", parts=[types.Part(text=prompt_text)]
|
||||
)
|
||||
|
||||
logger.debug(f"#{issue_number}: Sending prompt to agent.")
|
||||
|
||||
async for event in runner.run_async(
|
||||
user_id=USER_ID, session_id=session.id, new_message=prompt_message
|
||||
):
|
||||
if (
|
||||
event.content
|
||||
and event.content.parts
|
||||
and hasattr(event.content.parts[0], "text")
|
||||
):
|
||||
text = event.content.parts[0].text
|
||||
if text:
|
||||
clean_text = text[:150].replace("\n", " ")
|
||||
logger.info(f"#{issue_number} Decision: {clean_text}...")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing issue #{issue_number}: {e}", exc_info=True)
|
||||
|
||||
duration = time.perf_counter() - start_time
|
||||
|
||||
end_api_calls = get_api_call_count()
|
||||
issue_api_calls = end_api_calls - start_api_calls
|
||||
|
||||
logger.info(
|
||||
f"Issue #{issue_number} finished in {duration:.2f}s "
|
||||
f"with ~{issue_api_calls} API calls."
|
||||
)
|
||||
|
||||
return duration, issue_api_calls
|
||||
|
||||
|
||||
async def main():
|
||||
"""
|
||||
Main entry point to run the stale issue bot concurrently.
|
||||
|
||||
Fetches old issues and processes them in batches to respect API rate limits
|
||||
and concurrency constraints.
|
||||
"""
|
||||
logger.info(f"--- Starting Stale Bot for {OWNER}/{REPO} ---")
|
||||
logger.info(f"Concurrency level set to {CONCURRENCY_LIMIT}")
|
||||
|
||||
reset_api_call_count()
|
||||
|
||||
filter_days = STALE_HOURS_THRESHOLD / 24
|
||||
logger.debug(f"Fetching issues older than {filter_days:.2f} days...")
|
||||
|
||||
try:
|
||||
all_issues = get_old_open_issue_numbers(OWNER, REPO, days_old=filter_days)
|
||||
except Exception as e:
|
||||
logger.critical(f"Failed to fetch issue list: {e}", exc_info=True)
|
||||
return
|
||||
|
||||
total_count = len(all_issues)
|
||||
|
||||
search_api_calls = get_api_call_count()
|
||||
|
||||
if total_count == 0:
|
||||
logger.info("No issues matched the criteria. Run finished.")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Found {total_count} issues to process. "
|
||||
f"(Initial search used {search_api_calls} API calls)."
|
||||
)
|
||||
|
||||
total_processing_time = 0.0
|
||||
total_issue_api_calls = 0
|
||||
processed_count = 0
|
||||
|
||||
# Process the list in chunks of size CONCURRENCY_LIMIT
|
||||
for i in range(0, total_count, CONCURRENCY_LIMIT):
|
||||
chunk = all_issues[i : i + CONCURRENCY_LIMIT]
|
||||
current_chunk_num = i // CONCURRENCY_LIMIT + 1
|
||||
|
||||
logger.info(
|
||||
f"--- Starting chunk {current_chunk_num}: Processing issues {chunk} ---"
|
||||
)
|
||||
|
||||
tasks = [process_single_issue(issue_num) for issue_num in chunk]
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
for duration, api_calls in results:
|
||||
total_processing_time += duration
|
||||
total_issue_api_calls += api_calls
|
||||
|
||||
processed_count += len(chunk)
|
||||
logger.info(
|
||||
f"--- Finished chunk {current_chunk_num}. Progress:"
|
||||
f" {processed_count}/{total_count} ---"
|
||||
)
|
||||
|
||||
if (i + CONCURRENCY_LIMIT) < total_count:
|
||||
logger.debug(
|
||||
f"Sleeping for {SLEEP_BETWEEN_CHUNKS}s to respect rate limits..."
|
||||
)
|
||||
await asyncio.sleep(SLEEP_BETWEEN_CHUNKS)
|
||||
|
||||
total_api_calls_for_run = search_api_calls + total_issue_api_calls
|
||||
avg_time_per_issue = (
|
||||
total_processing_time / total_count if total_count > 0 else 0
|
||||
)
|
||||
|
||||
logger.info("--- Stale Agent Run Finished ---")
|
||||
logger.info(f"Successfully processed {processed_count} issues.")
|
||||
logger.info(f"Total API calls made this run: {total_api_calls_for_run}")
|
||||
logger.info(
|
||||
f"Average processing time per issue: {avg_time_per_issue:.2f} seconds."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start_time = time.perf_counter()
|
||||
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Bot execution interrupted manually.")
|
||||
except Exception as e:
|
||||
logger.critical(f"Unexpected fatal error: {e}", exc_info=True)
|
||||
|
||||
duration = time.perf_counter() - start_time
|
||||
logger.info(f"Full audit finished in {duration/60:.2f} minutes.")
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copyright 2026 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 environment variables from a .env file for local testing
|
||||
load_dotenv(override=True)
|
||||
|
||||
# --- GitHub API Configuration ---
|
||||
GITHUB_BASE_URL = "https://api.github.com"
|
||||
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
|
||||
if not GITHUB_TOKEN:
|
||||
raise ValueError("GITHUB_TOKEN environment variable not set")
|
||||
|
||||
OWNER = os.getenv("OWNER", "google")
|
||||
REPO = os.getenv("REPO", "adk-python")
|
||||
LLM_MODEL_NAME = os.getenv("LLM_MODEL_NAME", "gemini-2.5-flash")
|
||||
|
||||
STALE_LABEL_NAME = "stale"
|
||||
REQUEST_CLARIFICATION_LABEL = "request clarification"
|
||||
|
||||
# --- THRESHOLDS IN HOURS ---
|
||||
# Default: 168 hours (7 days)
|
||||
# The number of hours of inactivity after a maintainer comment before an issue is marked as stale.
|
||||
STALE_HOURS_THRESHOLD = float(os.getenv("STALE_HOURS_THRESHOLD", 168))
|
||||
|
||||
# Default: 168 hours (7 days)
|
||||
# The number of hours of inactivity after an issue is marked 'stale' before it is closed.
|
||||
CLOSE_HOURS_AFTER_STALE_THRESHOLD = float(
|
||||
os.getenv("CLOSE_HOURS_AFTER_STALE_THRESHOLD", 168)
|
||||
)
|
||||
|
||||
# --- Performance Configuration ---
|
||||
# The number of issues to process concurrently.
|
||||
# Higher values are faster but increase the immediate rate of API calls
|
||||
CONCURRENCY_LIMIT = int(os.getenv("CONCURRENCY_LIMIT", 3))
|
||||
|
||||
# --- GraphQL Query Limits ---
|
||||
# The number of most recent comments to fetch for context analysis.
|
||||
GRAPHQL_COMMENT_LIMIT = int(os.getenv("GRAPHQL_COMMENT_LIMIT", 30))
|
||||
|
||||
# The number of most recent description edits to fetch.
|
||||
GRAPHQL_EDIT_LIMIT = int(os.getenv("GRAPHQL_EDIT_LIMIT", 10))
|
||||
|
||||
# The number of most recent timeline events (labels, renames, reopens) to fetch.
|
||||
GRAPHQL_TIMELINE_LIMIT = int(os.getenv("GRAPHQL_TIMELINE_LIMIT", 20))
|
||||
|
||||
# --- Rate Limiting ---
|
||||
# Time in seconds to wait between processing chunks.
|
||||
SLEEP_BETWEEN_CHUNKS = float(os.getenv("SLEEP_BETWEEN_CHUNKS", 1.5))
|
||||
@@ -0,0 +1,260 @@
|
||||
# Copyright 2026 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 datetime import datetime
|
||||
from datetime import timedelta
|
||||
from datetime import timezone
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from adk_stale_agent.settings import GITHUB_TOKEN
|
||||
from adk_stale_agent.settings import STALE_HOURS_THRESHOLD
|
||||
import dateutil.parser
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
logger = logging.getLogger("google_adk." + __name__)
|
||||
|
||||
# --- API Call Counter for Monitoring ---
|
||||
_api_call_count = 0
|
||||
_counter_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_api_call_count() -> int:
|
||||
"""
|
||||
Returns the total number of API calls made since the last reset.
|
||||
|
||||
Returns:
|
||||
int: The global count of API calls.
|
||||
"""
|
||||
with _counter_lock:
|
||||
return _api_call_count
|
||||
|
||||
|
||||
def reset_api_call_count() -> None:
|
||||
"""Resets the global API call counter to zero."""
|
||||
global _api_call_count
|
||||
with _counter_lock:
|
||||
_api_call_count = 0
|
||||
|
||||
|
||||
def _increment_api_call_count() -> None:
|
||||
"""
|
||||
Atomically increments the global API call counter.
|
||||
Required because the agent may run tools in parallel threads.
|
||||
"""
|
||||
global _api_call_count
|
||||
with _counter_lock:
|
||||
_api_call_count += 1
|
||||
|
||||
|
||||
# --- Production-Ready HTTP Session with Exponential Backoff ---
|
||||
|
||||
# Configure the retry strategy:
|
||||
retry_strategy = Retry(
|
||||
total=6,
|
||||
backoff_factor=2,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
allowed_methods=[
|
||||
"HEAD",
|
||||
"GET",
|
||||
"POST",
|
||||
"PUT",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
"TRACE",
|
||||
"PATCH",
|
||||
],
|
||||
)
|
||||
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
|
||||
# Create a single, reusable Session object for connection pooling
|
||||
_session = requests.Session()
|
||||
_session.mount("https://", adapter)
|
||||
_session.mount("http://", adapter)
|
||||
|
||||
_session.headers.update({
|
||||
"Authorization": f"token {GITHUB_TOKEN}",
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
})
|
||||
|
||||
|
||||
def get_request(url: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
||||
"""
|
||||
Sends a GET request to the GitHub API with automatic retries.
|
||||
|
||||
Args:
|
||||
url (str): The URL endpoint.
|
||||
params (Optional[Dict[str, Any]]): Query parameters.
|
||||
|
||||
Returns:
|
||||
Any: The JSON response parsed into a dict or list.
|
||||
|
||||
Raises:
|
||||
requests.exceptions.RequestException: If retries are exhausted.
|
||||
"""
|
||||
_increment_api_call_count()
|
||||
try:
|
||||
response = _session.get(url, params=params or {}, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"GET request failed for {url}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def post_request(url: str, payload: Any) -> Any:
|
||||
"""
|
||||
Sends a POST request to the GitHub API with automatic retries.
|
||||
|
||||
Args:
|
||||
url (str): The URL endpoint.
|
||||
payload (Any): The JSON payload.
|
||||
|
||||
Returns:
|
||||
Any: The JSON response.
|
||||
"""
|
||||
_increment_api_call_count()
|
||||
try:
|
||||
response = _session.post(url, json=payload, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"POST request failed for {url}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def patch_request(url: str, payload: Any) -> Any:
|
||||
"""
|
||||
Sends a PATCH request to the GitHub API with automatic retries.
|
||||
|
||||
Args:
|
||||
url (str): The URL endpoint.
|
||||
payload (Any): The JSON payload.
|
||||
|
||||
Returns:
|
||||
Any: The JSON response.
|
||||
"""
|
||||
_increment_api_call_count()
|
||||
try:
|
||||
response = _session.patch(url, json=payload, timeout=60)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"PATCH request failed for {url}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def delete_request(url: str) -> Any:
|
||||
"""
|
||||
Sends a DELETE request to the GitHub API with automatic retries.
|
||||
|
||||
Args:
|
||||
url (str): The URL endpoint.
|
||||
|
||||
Returns:
|
||||
Any: A success dict if 204, else the JSON response.
|
||||
"""
|
||||
_increment_api_call_count()
|
||||
try:
|
||||
response = _session.delete(url, timeout=60)
|
||||
response.raise_for_status()
|
||||
if response.status_code == 204:
|
||||
return {"status": "success", "message": "Deletion successful."}
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"DELETE request failed for {url}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def error_response(error_message: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Creates a standardized error response dictionary for tool outputs.
|
||||
|
||||
Args:
|
||||
error_message (str): The error details.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Standardized error object.
|
||||
"""
|
||||
return {"status": "error", "message": error_message}
|
||||
|
||||
|
||||
def get_old_open_issue_numbers(
|
||||
owner: str, repo: str, days_old: Optional[float] = None
|
||||
) -> List[int]:
|
||||
"""
|
||||
Finds open issues older than the specified threshold using server-side filtering.
|
||||
|
||||
OPTIMIZATION:
|
||||
Instead of fetching ALL issues and filtering in Python (which wastes API calls),
|
||||
this uses the GitHub Search API `created:<DATE` syntax.
|
||||
|
||||
Args:
|
||||
owner (str): Repository owner.
|
||||
repo (str): Repository name.
|
||||
days_old (Optional[float]): Filter issues older than this many days.
|
||||
Defaults to STALE_HOURS_THRESHOLD / 24.
|
||||
|
||||
Returns:
|
||||
List[int]: A list of issue numbers matching the criteria.
|
||||
"""
|
||||
if days_old is None:
|
||||
days_old = STALE_HOURS_THRESHOLD / 24
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
cutoff_dt = now_utc - timedelta(days=days_old)
|
||||
|
||||
cutoff_str = cutoff_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
query = f"repo:{owner}/{repo} is:issue state:open created:<{cutoff_str}"
|
||||
|
||||
logger.info(
|
||||
f"Searching for issues in '{owner}/{repo}' created before {cutoff_str}..."
|
||||
)
|
||||
|
||||
issue_numbers = []
|
||||
page = 1
|
||||
url = "https://api.github.com/search/issues"
|
||||
|
||||
while True:
|
||||
params = {"q": query, "per_page": 100, "page": page}
|
||||
try:
|
||||
data = get_request(url, params=params)
|
||||
items = data.get("items", [])
|
||||
|
||||
if not items:
|
||||
break
|
||||
|
||||
for item in items:
|
||||
if "pull_request" not in item:
|
||||
issue_numbers.append(item["number"])
|
||||
|
||||
if len(items) < 100:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"GitHub search failed on page {page}: {e}")
|
||||
break
|
||||
|
||||
logger.info(f"Found {len(issue_numbers)} stale issues.")
|
||||
return issue_numbers
|
||||
@@ -0,0 +1,304 @@
|
||||
# Copyright 2026 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_triaging_agent.settings import GITHUB_BASE_URL
|
||||
from adk_triaging_agent.settings import IS_INTERACTIVE
|
||||
from adk_triaging_agent.settings import OWNER
|
||||
from adk_triaging_agent.settings import REPO
|
||||
from adk_triaging_agent.utils import error_response
|
||||
from adk_triaging_agent.utils import get_request
|
||||
from adk_triaging_agent.utils import patch_request
|
||||
from adk_triaging_agent.utils import post_request
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
import requests
|
||||
|
||||
LABEL_TO_OWNER = {
|
||||
"agent engine": "yeesian",
|
||||
"auth": "xuanyang15",
|
||||
"bq": "shobsi",
|
||||
"cli": "wyf7107",
|
||||
"core": "Jacksunwei",
|
||||
"documentation": "joefernandez",
|
||||
"eval": "ankursharmas",
|
||||
"integrations": "wukath",
|
||||
"live": "wuliang229",
|
||||
"mcp": "wukath",
|
||||
"models": "xuanyang15",
|
||||
"services": "DeanChensj",
|
||||
"skills": "wukath",
|
||||
"tools": "xuanyang15",
|
||||
"tracing": "jawoszek",
|
||||
"web": "wyf7107",
|
||||
"workflow": "DeanChensj",
|
||||
}
|
||||
|
||||
|
||||
LABEL_TO_GTECH = [
|
||||
"llalitkumarrr",
|
||||
"surajksharma07",
|
||||
"sanketpatil06",
|
||||
]
|
||||
|
||||
LABEL_GUIDELINES = """
|
||||
Label rubric and disambiguation rules:
|
||||
- "documentation": Tutorials, README content, reference docs, or samples.
|
||||
- "services": Session and memory services, persistence layers, or storage
|
||||
integrations.
|
||||
- "web": ADK web UI, FastAPI server, dashboards, or browser-based flows.
|
||||
- "question": Usage questions without a reproducible problem.
|
||||
- "tools": Built-in tools (e.g., SQL utils, code execution) or tool APIs.
|
||||
- "mcp": Model Context Protocol features. Apply both "mcp" and "tools".
|
||||
- "eval": Evaluation framework, test harnesses, scoring, or datasets.
|
||||
- "live": Streaming, bidi, audio, or Gemini Live configuration.
|
||||
- "models": Non-Gemini model adapters (LiteLLM, Ollama, OpenAI, etc.).
|
||||
- "tracing": Telemetry, observability, structured logs, or spans.
|
||||
- "cli": ADK CLI commands (e.g., create, deploy, eval) and CLI tools.
|
||||
- "skills": GCP Skills Registry (`GCPSkillRegistry`), skill prompt models,
|
||||
and dynamic skill toolsets.
|
||||
- "integrations": Third-party integrations (e.g., CrewAI, LangChain,
|
||||
Slack) excluding BigQuery.
|
||||
- "core": Core ADK runtime (Agent definitions, Runner, planners,
|
||||
thinking config, GlobalInstructionPlugin, CPU usage, or
|
||||
general orchestration including agent transfer for multi-agents system).
|
||||
Default to "core" when the topic is about ADK behavior and no other
|
||||
label is a better fit.
|
||||
- "agent engine": Vertex AI Agent Engine deployment or sandbox topics
|
||||
only (e.g., `.agent_engine_config.json`, `ae_ignore`, Agent Engine
|
||||
sandbox, `agent_engine_id`). If the issue does not explicitly mention
|
||||
Agent Engine concepts, do not use this label—choose "core" instead.
|
||||
- "a2a": A2A protocol, running agent as a2a agent with "--a2a" option for
|
||||
remote agent to talk with. Talking to remote agent via RemoteA2aAgent.
|
||||
NOT including those local multi-agent systems.
|
||||
- "bq": BigQuery integration or general issues related to BigQuery.
|
||||
- "workflow": Workflow agents and workflow execution.
|
||||
- "auth": Authentication or authorization issues.
|
||||
|
||||
When unsure between labels, prefer the most specific match. If a label
|
||||
cannot be assigned confidently, do not call the labeling tool.
|
||||
"""
|
||||
|
||||
APPROVAL_INSTRUCTION = (
|
||||
"Do not ask for user approval for labeling! If you can't find appropriate"
|
||||
" labels for the issue, do not label it."
|
||||
)
|
||||
if IS_INTERACTIVE:
|
||||
APPROVAL_INSTRUCTION = "Only label them when the user approves the labeling!"
|
||||
|
||||
|
||||
def list_untriaged_issues(issue_count: int) -> dict[str, Any]:
|
||||
"""List open issues that need triaging.
|
||||
|
||||
Returns issues that need any of the following actions:
|
||||
1. Issues without component labels (need labeling + type setting)
|
||||
2. Issues with 'planned' label but no assignee (need owner assignment)
|
||||
|
||||
Args:
|
||||
issue_count: number of issues to return
|
||||
|
||||
Returns:
|
||||
The status of this request, with a list of issues when successful.
|
||||
Each issue includes flags indicating what actions are needed.
|
||||
"""
|
||||
url = f"{GITHUB_BASE_URL}/search/issues"
|
||||
query = f"repo:{OWNER}/{REPO} is:open is:issue"
|
||||
params = {
|
||||
"q": query,
|
||||
"sort": "created",
|
||||
"order": "desc",
|
||||
"per_page": 100, # Fetch more to filter
|
||||
"page": 1,
|
||||
}
|
||||
|
||||
try:
|
||||
response = get_request(url, params)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
issues = response.get("items", [])
|
||||
|
||||
component_labels = set(LABEL_TO_OWNER.keys())
|
||||
untriaged_issues = []
|
||||
for issue in issues:
|
||||
issue_labels = {label["name"] for label in issue.get("labels", [])}
|
||||
assignees = issue.get("assignees", [])
|
||||
|
||||
existing_component_labels = issue_labels & component_labels
|
||||
has_component = bool(existing_component_labels)
|
||||
|
||||
# Determine what actions are needed
|
||||
needs_component_label = not has_component
|
||||
needs_owner = not assignees
|
||||
|
||||
# Include issue if it needs any action
|
||||
if needs_component_label or needs_owner:
|
||||
issue["has_component_label"] = has_component
|
||||
issue["existing_component_label"] = (
|
||||
list(existing_component_labels)[0]
|
||||
if existing_component_labels
|
||||
else None
|
||||
)
|
||||
issue["needs_component_label"] = needs_component_label
|
||||
issue["needs_owner"] = needs_owner
|
||||
untriaged_issues.append(issue)
|
||||
if len(untriaged_issues) >= issue_count:
|
||||
break
|
||||
return {"status": "success", "issues": untriaged_issues}
|
||||
|
||||
|
||||
def add_label_to_issue(issue_number: int, label: str) -> dict[str, Any]:
|
||||
"""Add the specified component label to the given issue number.
|
||||
Args:
|
||||
issue_number: issue number of the GitHub issue.
|
||||
label: label to assign
|
||||
|
||||
Returns:
|
||||
The status of this request, with the applied label when successful.
|
||||
"""
|
||||
print(f"Attempting to add label '{label}' to issue #{issue_number}")
|
||||
if label not in LABEL_TO_OWNER:
|
||||
return error_response(
|
||||
f"Error: Label '{label}' is not an allowed label. Will not apply."
|
||||
)
|
||||
|
||||
label_url = (
|
||||
f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{issue_number}/labels"
|
||||
)
|
||||
label_payload = [label]
|
||||
|
||||
try:
|
||||
response = post_request(label_url, label_payload)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": response,
|
||||
"applied_label": label,
|
||||
}
|
||||
|
||||
|
||||
def assign_gtech_owner_to_issue(issue_number: int) -> dict[str, Any]:
|
||||
"""Assign an owner from the GTech team to the given issue number.
|
||||
|
||||
This is go to option irrespective of component label or planned label,
|
||||
as long as the issue needs an owner.
|
||||
|
||||
All unassigned issues will be considered for GTech ownership. Unassigned
|
||||
issues will be separated in two categories: issues with type "Bug" and issues
|
||||
with type "Feature". Then bug issues and feature issues will be equally
|
||||
assigned to the Gtech members in such a way that every day all members get
|
||||
equal number of bug and feature issues.
|
||||
|
||||
Args:
|
||||
issue_number: issue number of the GitHub issue.
|
||||
|
||||
Returns:
|
||||
The status of this request, with the assigned owner when successful.
|
||||
"""
|
||||
print(f"Attempting to assign GTech owner to issue #{issue_number}")
|
||||
gtech_assignee = LABEL_TO_GTECH[issue_number % len(LABEL_TO_GTECH)]
|
||||
assignee_url = (
|
||||
f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{issue_number}/assignees"
|
||||
)
|
||||
assignee_payload = {"assignees": [gtech_assignee]}
|
||||
|
||||
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,
|
||||
"assigned_owner": gtech_assignee,
|
||||
}
|
||||
|
||||
|
||||
def change_issue_type(issue_number: int, issue_type: str) -> dict[str, Any]:
|
||||
"""Change the issue type of the given issue number.
|
||||
|
||||
Args:
|
||||
issue_number: issue number of the GitHub issue, in string format.
|
||||
issue_type: issue type to assign
|
||||
|
||||
Returns:
|
||||
The status of this request, with the applied issue type when successful.
|
||||
"""
|
||||
print(
|
||||
f"Attempting to change issue type '{issue_type}' to issue #{issue_number}"
|
||||
)
|
||||
url = f"{GITHUB_BASE_URL}/repos/{OWNER}/{REPO}/issues/{issue_number}"
|
||||
payload = {"type": issue_type}
|
||||
|
||||
try:
|
||||
response = patch_request(url, payload)
|
||||
except requests.exceptions.RequestException as e:
|
||||
return error_response(f"Error: {e}")
|
||||
|
||||
return {"status": "success", "message": response, "issue_type": issue_type}
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
model="gemini-3.5-flash",
|
||||
name="adk_triaging_assistant",
|
||||
description="Triage ADK issues.",
|
||||
instruction=f"""
|
||||
You are a triaging bot for the GitHub {REPO} repo with the owner {OWNER}. You will help get issues, and recommend a label.
|
||||
IMPORTANT: {APPROVAL_INSTRUCTION}
|
||||
|
||||
{LABEL_GUIDELINES}
|
||||
|
||||
## Triaging Workflow
|
||||
|
||||
Each issue will have flags indicating what actions are needed:
|
||||
- `needs_component_label`: true if the issue needs a component label
|
||||
- `needs_owner`: true if the issue needs an owner assigned
|
||||
|
||||
For each issue, perform ONLY the required actions based on the flags:
|
||||
|
||||
1. **If `needs_component_label` is true**:
|
||||
- Use `add_label_to_issue` to add the appropriate component label
|
||||
- Use `change_issue_type` to set the issue type:
|
||||
- Bug report → "Bug"
|
||||
- Feature request → "Feature"
|
||||
- Otherwise → do not change the issue type
|
||||
|
||||
2. **If `needs_owner` is true**:
|
||||
- Use `assign_gtech_owner_to_issue` to assign an owner.
|
||||
|
||||
|
||||
Do NOT add a component label if `needs_component_label` is false.
|
||||
Do NOT assign an owner if `needs_owner` is false.
|
||||
|
||||
Response quality requirements:
|
||||
- Summarize the issue in your own words without leaving template
|
||||
placeholders (never output text like "[fill in later]").
|
||||
- Justify the chosen label with a short explanation referencing the issue
|
||||
details.
|
||||
- Mention the assigned owner only when you actually assign one.
|
||||
- If no label is applied, clearly state why.
|
||||
|
||||
Present the following 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, if you assign the issue to an owner
|
||||
""",
|
||||
tools=[
|
||||
list_untriaged_issues,
|
||||
add_label_to_issue,
|
||||
assign_gtech_owner_to_issue,
|
||||
change_issue_type,
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
# OAuth Sample
|
||||
|
||||
## Introduction
|
||||
|
||||
This sample data science agent uses Agent Engine Code Execution Sandbox to execute LLM generated code.
|
||||
|
||||
|
||||
## How to use
|
||||
|
||||
* 1. Follow https://docs.cloud.google.com/agent-builder/agent-engine/code-execution/quickstart#create-an-agent-engine-instance to create an agent engine instance. Replace the AGENT_ENGINE_RESOURCE_NAME with the one you just created. A new sandbox environment under this agent engine instance will be created for each session with TTL of 1 year. But sandbox can only main its state for up to 14 days. This is the recommended usage for production environments.
|
||||
|
||||
* 2. For testing or protyping purposes, create a sandbox environment by following this guide: https://docs.cloud.google.com/agent-builder/agent-engine/code-execution/quickstart#create_a_sandbox. Replace the SANDBOX_RESOURCE_NAME with the one you just created. This will be used as the default sandbox environment for all the code executions throughout the lifetime of the agent. As the sandbox is re-used across sessions, all sessions will share the same Python environment and variable values."
|
||||
|
||||
|
||||
## Sample prompt
|
||||
|
||||
* Can you write a function that calculates the sum from 1 to 100.
|
||||
* The dataset is given as below. Store,Date,Weekly_Sales,Holiday_Flag,Temperature,Fuel_Price,CPI,Unemployment Store 1,2023-06-01,1000,0,70,3.0,200,5 Store 2,2023-06-02,1200,1,80,3.5,210,6 Store 3,2023-06-03,1400,0,90,4.0,220,7 Store 4,2023-06-04,1600,1,70,4.5,230,8 Store 5,2023-06-05,1800,0,80,5.0,240,9 Store 6,2023-06-06,2000,1,90,5.5,250,10 Store 7,2023-06-07,2200,0,90,6.0,260,11 Plot a scatter plot showcasing the relationship between Weekly Sales and Temperature for each store, distinguishing stores with a Holiday Flag.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,93 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""Data science agent."""
|
||||
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
from google.adk.code_executors.agent_engine_sandbox_code_executor import AgentEngineSandboxCodeExecutor
|
||||
|
||||
|
||||
def base_system_instruction():
|
||||
"""Returns: data science agent system instruction."""
|
||||
|
||||
return """
|
||||
# Guidelines
|
||||
|
||||
**Objective:** Assist the user in achieving their data analysis goals within the context of a Python Colab notebook, **with emphasis on avoiding assumptions and ensuring accuracy.** Reaching that goal can involve multiple steps. When you need to generate code, you **don't** need to solve the goal in one go. Only generate the next step at a time.
|
||||
|
||||
**Code Execution:** All code snippets provided will be executed within the Colab environment.
|
||||
|
||||
**Statefulness:** All code snippets are executed and the variables stays in the environment. You NEVER need to re-initialize variables. You NEVER need to reload files. You NEVER need to re-import libraries.
|
||||
|
||||
**Output Visibility:** Always print the output of code execution to visualize results, especially for data exploration and analysis. For example:
|
||||
- To look at the shape of a pandas.DataFrame do:
|
||||
```tool_code
|
||||
print(df.shape)
|
||||
```
|
||||
The output will be presented to you as:
|
||||
```tool_outputs
|
||||
(49, 7)
|
||||
|
||||
```
|
||||
- To display the result of a numerical computation:
|
||||
```tool_code
|
||||
x = 10 ** 9 - 12 ** 5
|
||||
print(f'{{x=}}')
|
||||
```
|
||||
The output will be presented to you as:
|
||||
```tool_outputs
|
||||
x=999751168
|
||||
|
||||
```
|
||||
- You **never** generate ```tool_outputs yourself.
|
||||
- You can then use this output to decide on next steps.
|
||||
- Print just variables (e.g., `print(f'{{variable=}}')`.
|
||||
|
||||
**No Assumptions:** **Crucially, avoid making assumptions about the nature of the data or column names.** Base findings solely on the data itself. Always use the information obtained from `explore_df` to guide your analysis.
|
||||
|
||||
**Available files:** Only use the files that are available as specified in the list of available files.
|
||||
|
||||
**Data in prompt:** Some queries contain the input data directly in the prompt. You have to parse that data into a pandas DataFrame. ALWAYS parse all the data. NEVER edit the data that are given to you.
|
||||
|
||||
**Answerability:** Some queries may not be answerable with the available data. In those cases, inform the user why you cannot process their query and suggest what type of data would be needed to fulfill their request.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
name="agent_engine_code_execution_agent",
|
||||
instruction=base_system_instruction() + """
|
||||
|
||||
|
||||
You need to assist the user with their queries by looking at the data and the context in the conversation.
|
||||
You final answer should summarize the code and code execution relevant to the user query.
|
||||
|
||||
You should include all pieces of data to answer the user query, such as the table from code execution results.
|
||||
If you cannot answer the question directly, you should follow the guidelines above to generate the next step.
|
||||
If the question can be answered directly with writing any code, you should do that.
|
||||
If you doesn't have enough data to answer the question, you should ask for clarification from the user.
|
||||
|
||||
You should NEVER install any package on your own like `pip install ...`.
|
||||
When plotting trends, you should make sure to sort and order the data by the x-axis.
|
||||
|
||||
|
||||
""",
|
||||
code_executor=AgentEngineSandboxCodeExecutor(
|
||||
# Replace with your sandbox resource name if you already have one. Only use it for testing or prototyping purposes, because this will use the same sandbox for all requests.
|
||||
# "projects/vertex-agent-loadtest/locations/us-central1/reasoningEngines/6842889780301135872/sandboxEnvironments/6545148628569161728",
|
||||
sandbox_resource_name=None,
|
||||
# Replace with agent engine resource name used for creating sandbox environment.
|
||||
agent_engine_resource_name=None,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,99 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""Data science agent."""
|
||||
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
from google.adk.code_executors.built_in_code_executor import BuiltInCodeExecutor
|
||||
|
||||
|
||||
def base_system_instruction():
|
||||
"""Returns: data science agent system instruction."""
|
||||
|
||||
return """
|
||||
# Guidelines
|
||||
|
||||
**Objective:** Assist the user in achieving their data analysis goals within the context of a Python Colab notebook, **with emphasis on avoiding assumptions and ensuring accuracy.** Reaching that goal can involve multiple steps. When you need to generate code, you **don't** need to solve the goal in one go. Only generate the next step at a time.
|
||||
|
||||
**Code Execution:** All code snippets provided will be executed within the Colab environment.
|
||||
|
||||
**Statefulness:** All code snippets are executed and the variables stays in the environment. You NEVER need to re-initialize variables. You NEVER need to reload files. You NEVER need to re-import libraries.
|
||||
|
||||
**Imported Libraries:** The following libraries are ALREADY imported and should NEVER be imported again:
|
||||
|
||||
```tool_code
|
||||
import io
|
||||
import math
|
||||
import re
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import scipy
|
||||
```
|
||||
|
||||
**Output Visibility:** Always print the output of code execution to visualize results, especially for data exploration and analysis. For example:
|
||||
- To look at the shape of a pandas.DataFrame do:
|
||||
```tool_code
|
||||
print(df.shape)
|
||||
```
|
||||
The output will be presented to you as:
|
||||
```tool_outputs
|
||||
(49, 7)
|
||||
|
||||
```
|
||||
- To display the result of a numerical computation:
|
||||
```tool_code
|
||||
x = 10 ** 9 - 12 ** 5
|
||||
print(f'{{x=}}')
|
||||
```
|
||||
The output will be presented to you as:
|
||||
```tool_outputs
|
||||
x=999751168
|
||||
|
||||
```
|
||||
- You **never** generate ```tool_outputs yourself.
|
||||
- You can then use this output to decide on next steps.
|
||||
- Print just variables (e.g., `print(f'{{variable=}}')`.
|
||||
|
||||
**No Assumptions:** **Crucially, avoid making assumptions about the nature of the data or column names.** Base findings solely on the data itself. Always use the information obtained from `explore_df` to guide your analysis.
|
||||
|
||||
**Available files:** Only use the files that are available as specified in the list of available files.
|
||||
|
||||
**Data in prompt:** Some queries contain the input data directly in the prompt. You have to parse that data into a pandas DataFrame. ALWAYS parse all the data. NEVER edit the data that are given to you.
|
||||
|
||||
**Answerability:** Some queries may not be answerable with the available data. In those cases, inform the user why you cannot process their query and suggest what type of data would be needed to fulfill their request.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
name="data_science_agent",
|
||||
instruction=base_system_instruction() + """
|
||||
|
||||
|
||||
You need to assist the user with their queries by looking at the data and the context in the conversation.
|
||||
You final answer should summarize the code and code execution relevant to the user query.
|
||||
|
||||
You should include all pieces of data to answer the user query, such as the table from code execution results.
|
||||
If you cannot answer the question directly, you should follow the guidelines above to generate the next step.
|
||||
If the question can be answered directly with writing any code, you should do that.
|
||||
If you doesn't have enough data to answer the question, you should ask for clarification from the user.
|
||||
|
||||
You should NEVER install any package on your own like `pip install ...`.
|
||||
When plotting trends, you should make sure to sort and order the data by the x-axis.
|
||||
|
||||
|
||||
""",
|
||||
code_executor=BuiltInCodeExecutor(),
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""A Python coding agent using the GkeCodeExecutor for secure execution."""
|
||||
|
||||
from google.adk.agents import LlmAgent
|
||||
from google.adk.code_executors import GkeCodeExecutor
|
||||
|
||||
|
||||
def gke_agent_system_instruction():
|
||||
"""Returns: The system instruction for the GKE-based coding agent."""
|
||||
return """You are a helpful and capable AI agent that can write and execute Python code to answer questions and perform tasks.
|
||||
|
||||
When a user asks a question, follow these steps:
|
||||
1. Analyze the request.
|
||||
2. Write a complete, self-contained Python script to accomplish the task.
|
||||
3. Your code will be executed in a secure, sandboxed environment.
|
||||
4. Return the full and complete output from the code execution, including any text, results, or error messages."""
|
||||
|
||||
|
||||
gke_executor = GkeCodeExecutor(
|
||||
# This must match the namespace in your deployment_rbac.yaml where the
|
||||
# agent's ServiceAccount and Role have permissions.
|
||||
namespace="agent-sandbox",
|
||||
# Setting an explicit timeout prevents a stuck job from running forever.
|
||||
timeout_seconds=600,
|
||||
)
|
||||
|
||||
root_agent = LlmAgent(
|
||||
name="gke_coding_agent",
|
||||
description=(
|
||||
"A general-purpose agent that executes Python code in a secure GKE"
|
||||
" Sandbox."
|
||||
),
|
||||
instruction=gke_agent_system_instruction(),
|
||||
code_executor=gke_executor,
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
# Custom Code Executor Agent Sample
|
||||
|
||||
This directory contains a sample agent that demonstrates how to customize a
|
||||
`CodeExecutor` to perform environment setup before executing code. The specific
|
||||
example shows how to add support for Japanese fonts in `matplotlib` plots by
|
||||
subclassing `VertexAiCodeExecutor`.
|
||||
|
||||
## Overview
|
||||
|
||||
This sample showcases a powerful pattern for customizing code execution
|
||||
environments. By extending a base `CodeExecutor`, you can inject setup code to
|
||||
prepare the environment before a user's code is run. This enables advanced use
|
||||
cases that require specific configurations, in this case, adding custom fonts.
|
||||
|
||||
## Key Concept: `CodeExecutor` Customization
|
||||
|
||||
The Agent Development Kit (ADK) allows for powerful customization of code
|
||||
execution by extending existing `CodeExecutor` classes. By subclassing an
|
||||
executor (e.g., `VertexAiCodeExecutor`) and overriding its `execute_code`
|
||||
method, you can inject custom logic to:
|
||||
|
||||
- Modify the code before it's executed.
|
||||
- Add files to the execution environment.
|
||||
- Process the results after execution.
|
||||
|
||||
## Example: Adding Japanese Font Support
|
||||
|
||||
The `CustomCodeExecutor` in this sample solves a common issue where non-Latin
|
||||
characters do not render correctly in plots generated by `matplotlib` due to
|
||||
missing fonts in the execution environment.
|
||||
|
||||
It achieves this by: 1. **Subclassing `VertexAiCodeExecutor`**: It inherits all
|
||||
the functionality of the standard Vertex AI code executor. 2. **Overriding
|
||||
`execute_code`**: Before calling the parent's `execute_code` method, it performs
|
||||
the following steps: a. Downloads a Japanese font file (`NotoSerifJP`). b. Adds
|
||||
the font file to the list of files to be uploaded to the execution environment.
|
||||
c. Prepends a Python code snippet to the user's code. This snippet uses
|
||||
`matplotlib.font_manager` to register the newly available font file, making it
|
||||
available for plotting. 3. **Executing the modified code**: The combined code
|
||||
(setup snippet + original code) is then executed in the Vertex AI environment,
|
||||
which now has the Japanese font available for `matplotlib`.
|
||||
|
||||
This ensures that any plots generated during the agent's session can correctly
|
||||
display Japanese characters.
|
||||
|
||||
## How to use
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Ensure you have configured your environment for using
|
||||
[Google Cloud Vertex AI](https://google.github.io/adk-docs/get-started/quickstart/#gemini---google-cloud-vertex-ai).
|
||||
You will need to have a Google Cloud Project with the Vertex AI API enabled.
|
||||
|
||||
### Running the agent
|
||||
|
||||
You can run this agent using the ADK CLI.
|
||||
|
||||
To interact with the agent through the command line:
|
||||
|
||||
```bash
|
||||
adk run contributing/samples/custom_code_execution "Plot a bar chart with these categories and values: {'リンゴ': 10, 'バナナ': 15, 'オレンジ': 8}. Title the chart '果物の在庫' (Fruit Stock)."
|
||||
```
|
||||
|
||||
To use the web interface:
|
||||
|
||||
```bash
|
||||
adk web contributing/samples/
|
||||
```
|
||||
|
||||
Then select `custom_code_execution` from the list of agents and interact with
|
||||
it.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,165 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""Data science agent, with a custom code executor that enables Japanese fonts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Optional
|
||||
import urllib.request
|
||||
|
||||
from google.adk.agents.invocation_context import InvocationContext
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
from google.adk.code_executors.code_execution_utils import CodeExecutionInput
|
||||
from google.adk.code_executors.code_execution_utils import CodeExecutionResult
|
||||
from google.adk.code_executors.code_execution_utils import File
|
||||
from google.adk.code_executors.vertex_ai_code_executor import VertexAiCodeExecutor
|
||||
from typing_extensions import override
|
||||
|
||||
# The Python code snippet to be prepended to the user's code.
|
||||
# This will register the Japanese font with matplotlib.
|
||||
_FONT_SETUP_CODE = """
|
||||
import matplotlib.font_manager as fm
|
||||
|
||||
font_path = "NotoSerifJP[wght].ttf"
|
||||
try:
|
||||
fm.fontManager.addfont(font_path)
|
||||
prop = fm.FontProperties(fname=font_path)
|
||||
plt.rcParams['font.family'] = prop.get_name()
|
||||
print("Japanese font enabled for matplotlib.")
|
||||
except Exception as e:
|
||||
print(f"Failed to set Japanese font: {e}")
|
||||
"""
|
||||
|
||||
|
||||
def _load_font_file(font_url: str, font_filename: str) -> Optional[File]:
|
||||
"""Downloads a font file and returns it as a File object."""
|
||||
try:
|
||||
with urllib.request.urlopen(font_url) as response:
|
||||
font_bytes = response.read()
|
||||
except Exception as e:
|
||||
print(f"Failed to download font: {e}")
|
||||
return None
|
||||
|
||||
# Base64-encode the font content.
|
||||
font_content = base64.b64encode(font_bytes).decode("utf-8")
|
||||
return File(name=font_filename, content=font_content)
|
||||
|
||||
|
||||
class CustomCodeExecutor(VertexAiCodeExecutor):
|
||||
"""A Vertex AI code executor that automatically enables Japanese fonts."""
|
||||
|
||||
@override
|
||||
def execute_code(
|
||||
self,
|
||||
invocation_context: InvocationContext,
|
||||
code_execution_input: CodeExecutionInput,
|
||||
) -> CodeExecutionResult:
|
||||
font_url = "https://github.com/notofonts/noto-cjk/raw/refs/heads/main/google-fonts/NotoSerifJP%5Bwght%5D.ttf"
|
||||
font_filename = "NotoSerifJP[wght].ttf"
|
||||
font_file = _load_font_file(font_url, font_filename)
|
||||
# If the font download fails, execute the original code without it.
|
||||
if font_file is not None:
|
||||
# Add the font file to the input files.
|
||||
code_execution_input.input_files.append(font_file)
|
||||
|
||||
# Prepend the font setup code to the user's code.
|
||||
code_execution_input.code = (
|
||||
f"{_FONT_SETUP_CODE}\n\n{code_execution_input.code}"
|
||||
)
|
||||
|
||||
# Execute the modified code.
|
||||
return super().execute_code(invocation_context, code_execution_input)
|
||||
|
||||
|
||||
def base_system_instruction():
|
||||
"""Returns: data science agent system instruction."""
|
||||
|
||||
return """
|
||||
# Guidelines
|
||||
|
||||
**Objective:** Assist the user in achieving their data analysis goals within the context of a Python Colab notebook, **with emphasis on avoiding assumptions and ensuring accuracy.** Reaching that goal can involve multiple steps. When you need to generate code, you **don't** need to solve the goal in one go. Only generate the next step at a time.
|
||||
|
||||
**Code Execution:** All code snippets provided will be executed within the Colab environment.
|
||||
|
||||
**Statefulness:** All code snippets are executed and the variables stays in the environment. You NEVER need to re-initialize variables. You NEVER need to reload files. You NEVER need to re-import libraries.
|
||||
|
||||
**Imported Libraries:** The following libraries are ALREADY imported and should NEVER be imported again:
|
||||
|
||||
```tool_code
|
||||
import io
|
||||
import math
|
||||
import re
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import scipy
|
||||
```
|
||||
|
||||
**Output Visibility:** Always print the output of code execution to visualize results, especially for data exploration and analysis. For example:
|
||||
- To look at the shape of a pandas.DataFrame do:
|
||||
```tool_code
|
||||
print(df.shape)
|
||||
```
|
||||
The output will be presented to you as:
|
||||
```tool_outputs
|
||||
(49, 7)
|
||||
|
||||
```
|
||||
- To display the result of a numerical computation:
|
||||
```tool_code
|
||||
x = 10 ** 9 - 12 ** 5
|
||||
print(f'{{x=}}')
|
||||
```
|
||||
The output will be presented to you as:
|
||||
```tool_outputs
|
||||
x=999751168
|
||||
|
||||
```
|
||||
- You **never** generate ```tool_outputs yourself.
|
||||
- You can then use this output to decide on next steps.
|
||||
- Print just variables (e.g., `print(f'{{variable=}}')`.
|
||||
|
||||
**No Assumptions:** **Crucially, avoid making assumptions about the nature of the data or column names.** Base findings solely on the data itself. Always use the information obtained from `explore_df` to guide your analysis.
|
||||
|
||||
**Available files:** Only use the files that are available as specified in the list of available files.
|
||||
|
||||
**Data in prompt:** Some queries contain the input data directly in the prompt. You have to parse that data into a pandas DataFrame. ALWAYS parse all the data. NEVER edit the data that are given to you.
|
||||
|
||||
**Answerability:** Some queries may not be answerable with the available data. In those cases, inform the user why you cannot process their query and suggest what type of data would be needed to fulfill their request.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
name="data_science_agent",
|
||||
instruction=base_system_instruction() + """
|
||||
|
||||
|
||||
You need to assist the user with their queries by looking at the data and the context in the conversation.
|
||||
You final answer should summarize the code and code execution relevant to the user query.
|
||||
|
||||
You should include all pieces of data to answer the user query, such as the table from code execution results.
|
||||
If you cannot answer the question directly, you should follow the guidelines above to generate the next step.
|
||||
If the question can be answered directly with writing any code, you should do that.
|
||||
If you doesn't have enough data to answer the question, you should ask for clarification from the user.
|
||||
|
||||
You should NEVER install any package on your own like `pip install ...`.
|
||||
When plotting trends, you should make sure to sort and order the data by the x-axis.
|
||||
|
||||
|
||||
""",
|
||||
code_executor=CustomCodeExecutor(),
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
# Vertex AI Code Execution Agent Sample
|
||||
|
||||
This directory contains a sample agent that demonstrates how to use the
|
||||
`VertexAiCodeExecutor` for data science tasks.
|
||||
|
||||
## Overview
|
||||
|
||||
The agent is designed to assist with data analysis in a Python environment. It
|
||||
can execute Python code to perform tasks like data manipulation, analysis, and
|
||||
visualization. This agent is particularly useful for tasks that require a secure
|
||||
and sandboxed code execution environment with common data science libraries
|
||||
pre-installed.
|
||||
|
||||
This sample is a direct counterpart to the
|
||||
[code execution sample](../code_execution/) which uses the
|
||||
`BuiltInCodeExecutor`. The key difference in this sample is the use of
|
||||
`VertexAiCodeExecutor`.
|
||||
|
||||
## `VertexAiCodeExecutor`
|
||||
|
||||
The `VertexAiCodeExecutor` leverages the
|
||||
[Vertex AI Code Interpreter Extension](https://cloud.google.com/vertex-ai/generative-ai/docs/extensions/code-interpreter)
|
||||
to run Python code. This provides several advantages:
|
||||
|
||||
- **Security**: Code is executed in a sandboxed environment on Google Cloud,
|
||||
isolating it from your local system.
|
||||
- **Pre-installed Libraries**: The environment comes with many common Python
|
||||
data science libraries pre-installed, such as `pandas`, `numpy`, and
|
||||
`matplotlib`.
|
||||
- **Stateful Execution**: The execution environment is stateful, meaning
|
||||
variables and data from one code execution are available in subsequent
|
||||
executions within the same session.
|
||||
|
||||
## How to use
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Ensure you have configured your environment for using
|
||||
[Google Cloud Vertex AI](https://google.github.io/adk-docs/get-started/quickstart/#gemini---google-cloud-vertex-ai).
|
||||
You will need to have a Google Cloud Project with the Vertex AI API enabled.
|
||||
|
||||
### Running the agent
|
||||
|
||||
You can run this agent using the ADK CLI from the root of the repository.
|
||||
|
||||
To interact with the agent through the command line:
|
||||
|
||||
```bash
|
||||
adk run contributing/samples/vertex_code_execution "Plot a sine wave from 0 to 10"
|
||||
```
|
||||
|
||||
To use the web interface:
|
||||
|
||||
```bash
|
||||
adk web contributing/samples/
|
||||
```
|
||||
|
||||
Then select `vertex_code_execution` from the list of agents and interact with
|
||||
it.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2026 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,99 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""Data science agent that uses Vertex AI code interpreter."""
|
||||
|
||||
from google.adk.agents.llm_agent import Agent
|
||||
from google.adk.code_executors.vertex_ai_code_executor import VertexAiCodeExecutor
|
||||
|
||||
|
||||
def base_system_instruction():
|
||||
"""Returns: data science agent system instruction."""
|
||||
|
||||
return """
|
||||
# Guidelines
|
||||
|
||||
**Objective:** Assist the user in achieving their data analysis goals within the context of a Python Colab notebook, **with emphasis on avoiding assumptions and ensuring accuracy.** Reaching that goal can involve multiple steps. When you need to generate code, you **don't** need to solve the goal in one go. Only generate the next step at a time.
|
||||
|
||||
**Code Execution:** All code snippets provided will be executed within the Colab environment.
|
||||
|
||||
**Statefulness:** All code snippets are executed and the variables stays in the environment. You NEVER need to re-initialize variables. You NEVER need to reload files. You NEVER need to re-import libraries.
|
||||
|
||||
**Imported Libraries:** The following libraries are ALREADY imported and should NEVER be imported again:
|
||||
|
||||
```tool_code
|
||||
import io
|
||||
import math
|
||||
import re
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import scipy
|
||||
```
|
||||
|
||||
**Output Visibility:** Always print the output of code execution to visualize results, especially for data exploration and analysis. For example:
|
||||
- To look at the shape of a pandas.DataFrame do:
|
||||
```tool_code
|
||||
print(df.shape)
|
||||
```
|
||||
The output will be presented to you as:
|
||||
```tool_outputs
|
||||
(49, 7)
|
||||
|
||||
```
|
||||
- To display the result of a numerical computation:
|
||||
```tool_code
|
||||
x = 10 ** 9 - 12 ** 5
|
||||
print(f'{{x=}}')
|
||||
```
|
||||
The output will be presented to you as:
|
||||
```tool_outputs
|
||||
x=999751168
|
||||
|
||||
```
|
||||
- You **never** generate ```tool_outputs yourself.
|
||||
- You can then use this output to decide on next steps.
|
||||
- Print just variables (e.g., `print(f'{{variable=}}')`.
|
||||
|
||||
**No Assumptions:** **Crucially, avoid making assumptions about the nature of the data or column names.** Base findings solely on the data itself. Always use the information obtained from `explore_df` to guide your analysis.
|
||||
|
||||
**Available files:** Only use the files that are available as specified in the list of available files.
|
||||
|
||||
**Data in prompt:** Some queries contain the input data directly in the prompt. You have to parse that data into a pandas DataFrame. ALWAYS parse all the data. NEVER edit the data that are given to you.
|
||||
|
||||
**Answerability:** Some queries may not be answerable with the available data. In those cases, inform the user why you cannot process their query and suggest what type of data would be needed to fulfill their request.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
root_agent = Agent(
|
||||
name="data_science_agent",
|
||||
instruction=base_system_instruction() + """
|
||||
|
||||
|
||||
You need to assist the user with their queries by looking at the data and the context in the conversation.
|
||||
You final answer should summarize the code and code execution relevant to the user query.
|
||||
|
||||
You should include all pieces of data to answer the user query, such as the table from code execution results.
|
||||
If you cannot answer the question directly, you should follow the guidelines above to generate the next step.
|
||||
If the question can be answered directly with writing any code, you should do that.
|
||||
If you doesn't have enough data to answer the question, you should ask for clarification from the user.
|
||||
|
||||
You should NEVER install any package on your own like `pip install ...`.
|
||||
When plotting trends, you should make sure to sort and order the data by the x-axis.
|
||||
|
||||
|
||||
""",
|
||||
code_executor=VertexAiCodeExecutor(),
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
# Basic Config-based Agent
|
||||
|
||||
This sample only covers:
|
||||
|
||||
- name
|
||||
- description
|
||||
- model
|
||||
@@ -0,0 +1,23 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/google/adk-python/refs/heads/main/src/google/adk/agents/config_schemas/AgentConfig.json
|
||||
name: assistant_agent
|
||||
model: gemini-2.5-flash
|
||||
description: A helper agent that can answer users' questions.
|
||||
instruction: |
|
||||
You are an agent to help answer users' various questions.
|
||||
|
||||
1. If the user's intention is not clear, ask clarifying questions to better understand their needs.
|
||||
2. Once the intention is clear, provide accurate and helpful answers to the user's questions.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 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.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from google.genai import types
|
||||
|
||||
|
||||
async def before_agent_callback(callback_context):
|
||||
print('@before_agent_callback')
|
||||
return None
|
||||
|
||||
|
||||
async def after_agent_callback(callback_context):
|
||||
print('@after_agent_callback')
|
||||
return None
|
||||
|
||||
|
||||
async def before_model_callback(callback_context, llm_request):
|
||||
print('@before_model_callback')
|
||||
return None
|
||||
|
||||
|
||||
async def after_model_callback(callback_context, llm_response):
|
||||
print('@after_model_callback')
|
||||
return None
|
||||
|
||||
|
||||
def after_agent_callback1(callback_context):
|
||||
print('@after_agent_callback1')
|
||||
|
||||
|
||||
def after_agent_callback2(callback_context):
|
||||
print('@after_agent_callback2')
|
||||
# ModelContent (or Content with role set to 'model') must be returned.
|
||||
# Otherwise, the event will be excluded from the context in the next turn.
|
||||
return types.ModelContent(
|
||||
parts=[
|
||||
types.Part(
|
||||
text='(stopped) after_agent_callback2',
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def after_agent_callback3(callback_context):
|
||||
print('@after_agent_callback3')
|
||||
|
||||
|
||||
def before_agent_callback1(callback_context):
|
||||
print('@before_agent_callback1')
|
||||
|
||||
|
||||
def before_agent_callback2(callback_context):
|
||||
print('@before_agent_callback2')
|
||||
|
||||
|
||||
def before_agent_callback3(callback_context):
|
||||
print('@before_agent_callback3')
|
||||
|
||||
|
||||
def before_tool_callback1(tool, args, tool_context):
|
||||
print('@before_tool_callback1')
|
||||
|
||||
|
||||
def before_tool_callback2(tool, args, tool_context):
|
||||
print('@before_tool_callback2')
|
||||
|
||||
|
||||
def before_tool_callback3(tool, args, tool_context):
|
||||
print('@before_tool_callback3')
|
||||
|
||||
|
||||
def after_tool_callback1(tool, args, tool_context, tool_response):
|
||||
print('@after_tool_callback1')
|
||||
|
||||
|
||||
def after_tool_callback2(tool, args, tool_context, tool_response):
|
||||
print('@after_tool_callback2')
|
||||
return {'test': 'after_tool_callback2', 'response': tool_response}
|
||||
|
||||
|
||||
def after_tool_callback3(tool, args, tool_context, tool_response):
|
||||
print('@after_tool_callback3')
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/google/adk-python/refs/heads/main/src/google/adk/agents/config_schemas/AgentConfig.json
|
||||
name: hello_world_agent
|
||||
model: gemini-2.5-flash
|
||||
description: hello world agent that can roll a dice and check prime numbers.
|
||||
instruction: |
|
||||
You roll dice and answer questions about the outcome of the dice rolls.
|
||||
You can roll dice of different sizes.
|
||||
You can use multiple tools in parallel by calling functions in parallel(in one request and in one round).
|
||||
It is ok to discuss previous dice roles, and comment on the dice rolls.
|
||||
When you are asked to roll a die, you must call the roll_die tool with the number of sides. Be sure to pass in an integer. Do not pass in a string.
|
||||
You should never roll a die on your own.
|
||||
When checking prime numbers, call the check_prime tool with a list of integers. Be sure to pass in a list of integers. You should never pass in a string.
|
||||
You should not check prime numbers before calling the tool.
|
||||
When you are asked to roll a die and check prime numbers, you should always make the following two function calls:
|
||||
1. You should first call the roll_die tool to get a roll. Wait for the function response before calling the check_prime tool.
|
||||
2. After you get the function response from roll_die tool, you should call the check_prime tool with the roll_die result.
|
||||
2.1 If user asks you to check primes based on previous rolls, make sure you include the previous rolls in the list.
|
||||
3. When you respond, you must include the roll_die result from step 1.
|
||||
You should always perform the previous 3 steps when asking for a roll and checking prime numbers.
|
||||
You should not rely on the previous history on prime results.
|
||||
tools:
|
||||
- name: core_callback_config.tools.roll_die
|
||||
- name: core_callback_config.tools.check_prime
|
||||
before_agent_callbacks:
|
||||
- name: core_callback_config.callbacks.before_agent_callback1
|
||||
- name: core_callback_config.callbacks.before_agent_callback2
|
||||
- name: core_callback_config.callbacks.before_agent_callback3
|
||||
after_agent_callbacks:
|
||||
- name: core_callback_config.callbacks.after_agent_callback1
|
||||
- name: core_callback_config.callbacks.after_agent_callback2
|
||||
- name: core_callback_config.callbacks.after_agent_callback3
|
||||
before_model_callbacks:
|
||||
- name: core_callback_config.callbacks.before_model_callback
|
||||
after_model_callbacks:
|
||||
- name: core_callback_config.callbacks.after_model_callback
|
||||
before_tool_callbacks:
|
||||
- name: core_callback_config.callbacks.before_tool_callback1
|
||||
- name: core_callback_config.callbacks.before_tool_callback2
|
||||
- name: core_callback_config.callbacks.before_tool_callback3
|
||||
after_tool_callbacks:
|
||||
- name: core_callback_config.callbacks.after_tool_callback1
|
||||
- name: core_callback_config.callbacks.after_tool_callback2
|
||||
- name: core_callback_config.callbacks.after_tool_callback3
|
||||
@@ -0,0 +1,62 @@
|
||||
# Copyright 2026 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 random
|
||||
|
||||
from google.adk.tools.tool_context import ToolContext
|
||||
|
||||
|
||||
def roll_die(sides: int, tool_context: ToolContext) -> int:
|
||||
"""Roll a die and return the rolled result.
|
||||
|
||||
Args:
|
||||
sides: The integer number of sides the die has.
|
||||
|
||||
Returns:
|
||||
An integer of the result of rolling the die.
|
||||
"""
|
||||
result = random.randint(1, sides)
|
||||
if not 'rolls' in tool_context.state:
|
||||
tool_context.state['rolls'] = []
|
||||
|
||||
tool_context.state['rolls'] = tool_context.state['rolls'] + [result]
|
||||
return result
|
||||
|
||||
|
||||
def check_prime(nums: list[int]) -> str:
|
||||
"""Check if a given list of numbers are prime.
|
||||
|
||||
Args:
|
||||
nums: The list of numbers to check.
|
||||
|
||||
Returns:
|
||||
A str indicating which number is prime.
|
||||
"""
|
||||
primes = set()
|
||||
for number in nums:
|
||||
number = int(number)
|
||||
if number <= 1:
|
||||
continue
|
||||
is_prime = True
|
||||
for i in range(2, int(number**0.5) + 1):
|
||||
if number % i == 0:
|
||||
is_prime = False
|
||||
break
|
||||
if is_prime:
|
||||
primes.add(number)
|
||||
return (
|
||||
'No prime numbers found.'
|
||||
if not primes
|
||||
else f"{', '.join(str(num) for num in primes)} are prime numbers."
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 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.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Copyright 2026 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 keyword import kwlist
|
||||
from typing import Any
|
||||
from typing import AsyncGenerator
|
||||
from typing import ClassVar
|
||||
from typing import Dict
|
||||
from typing import Type
|
||||
|
||||
from google.adk.agents import BaseAgent
|
||||
from google.adk.agents.base_agent_config import BaseAgentConfig
|
||||
from google.adk.agents.invocation_context import InvocationContext
|
||||
from google.adk.events.event import Event
|
||||
from google.genai import types
|
||||
from pydantic import ConfigDict
|
||||
from typing_extensions import override
|
||||
|
||||
|
||||
class MyCustomAgentConfig(BaseAgentConfig):
|
||||
model_config = ConfigDict(
|
||||
extra="forbid",
|
||||
)
|
||||
agent_class: str = "core_custom_agent_config.my_agents.MyCustomAgent"
|
||||
my_field: str = ""
|
||||
|
||||
|
||||
class MyCustomAgent(BaseAgent):
|
||||
my_field: str = ""
|
||||
|
||||
config_type: ClassVar[type[BaseAgentConfig]] = MyCustomAgentConfig
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
def _parse_config(
|
||||
cls: Type[MyCustomAgent],
|
||||
config: MyCustomAgentConfig,
|
||||
config_abs_path: str,
|
||||
kwargs: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
if config.my_field:
|
||||
kwargs["my_field"] = config.my_field
|
||||
return kwargs
|
||||
|
||||
async def _run_async_impl(
|
||||
self, ctx: InvocationContext
|
||||
) -> AsyncGenerator[Event, None]:
|
||||
yield Event(
|
||||
invocation_id=ctx.invocation_id,
|
||||
author=self.name,
|
||||
content=types.ModelContent(
|
||||
parts=[
|
||||
types.Part(
|
||||
text=f"I feel good! value in my_field: `{self.my_field}`"
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/google/adk-python/refs/heads/main/src/google/adk/agents/config_schemas/AgentConfig.json
|
||||
name: working_agent
|
||||
agent_class: core_custom_agent_config.my_agents.MyCustomAgent
|
||||
description: Handles all the work.
|
||||
my_field: my_field_value
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/google/adk-python/refs/heads/main/src/google/adk/agents/config_schemas/AgentConfig.json
|
||||
name: search_agent
|
||||
model: gemini-2.5-flash
|
||||
description: 'an agent whose job it is to perform Google search queries and answer questions about the results.'
|
||||
instruction: You are an agent whose job is to perform Google search queries and answer questions about the results.
|
||||
tools:
|
||||
- name: google_search
|
||||
generate_content_config:
|
||||
temperature: 0.1
|
||||
max_output_tokens: 2000
|
||||
thinking_config:
|
||||
include_thoughts: true
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user