Skip to content

Commit 831f30c

Browse files
amabitoamabito
authored andcommitted
feat: add governance plugin (budget, circuit breaker, degradation)
Adds src/google/adk_community/governance/ with VeronicaGovernancePlugin, a BasePlugin that enforces per-agent and org-level budget limits, isolates failing agents via circuit breaker, blocks disallowed tools, and degrades to cheaper models when budget runs low. 56 tests, isort + pyink formatted, no extra dependencies.
1 parent 0d10dd9 commit 831f30c

15 files changed

Lines changed: 1891 additions & 0 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Governance Plugin Example
2+
3+
Budget enforcement, circuit breaking, and model degradation for ADK agents.
4+
5+
## Quickstart
6+
7+
```python
8+
from google.adk_community.governance import GovernanceConfig, VeronicaGovernancePlugin
9+
10+
plugin = VeronicaGovernancePlugin(GovernanceConfig(max_cost_usd=1.0))
11+
12+
runner = Runner(agent=agent, session_service=session_service, plugins=[plugin])
13+
```
14+
15+
That's it. The plugin intercepts model and tool callbacks automatically.
16+
17+
## Setup
18+
19+
```bash
20+
pip install google-adk-community
21+
export GOOGLE_API_KEY="your-key"
22+
```
23+
24+
## Run the full example
25+
26+
```bash
27+
python main.py
28+
```
29+
30+
## What it does
31+
32+
The example creates three agents (orchestrator, researcher, summarizer) and
33+
registers a `VeronicaGovernancePlugin` on the Runner. The plugin:
34+
35+
- Enforces a $0.50 org budget and $0.25 per-agent budget
36+
- Blocks the `shell_exec` tool
37+
- Degrades to `gemini-2.0-flash-lite` when budget hits 70%
38+
- Disables `web_search` during degradation
39+
- Trips the circuit breaker after 3 consecutive failures
40+
41+
After the run completes, the plugin logs a summary:
42+
43+
```
44+
[GOVERNANCE] Run complete in 2.3s. Model calls: 4, Tool calls: 0.
45+
[GOVERNANCE] Budget: $0.0023 / $0.5000 (0.5% used).
46+
[GOVERNANCE] Agent 'researcher': $0.0012 / $0.2500.
47+
[GOVERNANCE] Agent 'summarizer': $0.0008 / $0.2500.
48+
```
49+
50+
If degradation triggers, the summary includes:
51+
52+
```
53+
[GOVERNANCE] Degradation events (1):
54+
[GOVERNANCE] Agent 'researcher' at 72.0% -- degraded gemini-2.5-flash -> gemini-2.0-flash-lite.
55+
```
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Example: multi-agent workflow with governance plugin.
16+
17+
This example shows how to register VeronicaGovernancePlugin on an ADK
18+
Runner to enforce per-agent budgets, block tools, and degrade to a
19+
cheaper model when budget runs low.
20+
21+
Usage:
22+
export GOOGLE_API_KEY="your-key"
23+
python main.py
24+
"""
25+
26+
import asyncio
27+
import logging
28+
29+
from google.adk import Runner
30+
from google.adk.agents import Agent
31+
from google.adk.sessions import InMemorySessionService
32+
33+
from google.adk_community.governance import (
34+
GovernanceConfig,
35+
VeronicaGovernancePlugin,
36+
)
37+
38+
logging.basicConfig(level=logging.INFO, format="%(message)s")
39+
40+
41+
def main():
42+
# Configure governance limits
43+
config = GovernanceConfig(
44+
max_cost_usd=0.50, # org-level: 50 cents
45+
agent_max_cost_usd=0.25, # per-agent: 25 cents
46+
failure_threshold=3, # circuit breaker after 3 failures
47+
recovery_timeout_s=30.0,
48+
degradation_threshold=0.7, # degrade at 70% budget
49+
fallback_model="gemini-2.0-flash-lite",
50+
blocked_tools=["shell_exec"],
51+
disable_tools_on_degrade=["web_search"],
52+
)
53+
54+
plugin = VeronicaGovernancePlugin(config=config)
55+
56+
# Define agents
57+
researcher = Agent(
58+
model="gemini-2.5-flash",
59+
name="researcher",
60+
instruction=(
61+
"You are a research assistant. Answer questions using your"
62+
" knowledge. Be concise."
63+
),
64+
)
65+
66+
summarizer = Agent(
67+
model="gemini-2.5-flash",
68+
name="summarizer",
69+
instruction=(
70+
"You summarize text provided to you. Keep summaries to 2-3"
71+
" sentences."
72+
),
73+
)
74+
75+
# Orchestrator delegates to sub-agents
76+
orchestrator = Agent(
77+
model="gemini-2.5-flash",
78+
name="orchestrator",
79+
instruction=(
80+
"You coordinate research tasks. Use the researcher agent to"
81+
" find information, then the summarizer to condense it."
82+
),
83+
sub_agents=[researcher, summarizer],
84+
)
85+
86+
# Create runner with governance plugin
87+
session_service = InMemorySessionService()
88+
runner = Runner(
89+
agent=orchestrator,
90+
app_name="governance_demo",
91+
session_service=session_service,
92+
plugins=[plugin],
93+
)
94+
95+
async def run():
96+
session = await session_service.create_session(
97+
app_name="governance_demo",
98+
user_id="demo_user",
99+
)
100+
101+
from google.genai import types
102+
103+
user_message = types.Content(
104+
role="user",
105+
parts=[types.Part(text="What is agent governance?")],
106+
)
107+
108+
async for event in runner.run_async(
109+
session_id=session.id,
110+
user_id="demo_user",
111+
new_message=user_message,
112+
):
113+
if event.content and event.content.parts:
114+
for part in event.content.parts:
115+
if part.text:
116+
print(f"[{event.author}] {part.text[:200]}")
117+
118+
# After run, the plugin logs a governance summary automatically.
119+
# You can also inspect programmatically:
120+
snap = plugin.budget.snapshot()
121+
print(f"\nTotal spent: ${snap.org_spent_usd:.4f}")
122+
for agent, spent in snap.agent_spent.items():
123+
print(f" {agent}: ${spent:.4f}")
124+
125+
asyncio.run(run())
126+
127+
128+
if __name__ == "__main__":
129+
main()

src/google/adk_community/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from . import governance
1516
from . import memory
1617
from . import sessions
1718
from . import version
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Community governance plugins for ADK."""
16+
17+
from .veronica_governance_plugin import GovernanceConfig
18+
from .veronica_governance_plugin import VeronicaGovernancePlugin
19+
20+
__all__ = [
21+
"GovernanceConfig",
22+
"VeronicaGovernancePlugin",
23+
]
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Budget tracking for governance plugin."""
16+
17+
from __future__ import annotations
18+
19+
from dataclasses import dataclass
20+
from dataclasses import field
21+
import threading
22+
23+
24+
@dataclass
25+
class BudgetSnapshot:
26+
"""Read-only snapshot of current budget state."""
27+
28+
org_spent_usd: float
29+
org_limit_usd: float
30+
agent_spent: dict[str, float]
31+
agent_limit_usd: float
32+
33+
@property
34+
def org_utilization(self) -> float:
35+
if self.org_limit_usd <= 0:
36+
return 1.0
37+
return self.org_spent_usd / self.org_limit_usd
38+
39+
40+
class BudgetTracker:
41+
"""Thread-safe budget tracker with per-agent and org-level limits."""
42+
43+
def __init__(
44+
self,
45+
*,
46+
org_limit_usd: float,
47+
agent_limit_usd: float,
48+
cost_per_1k_input_tokens: float,
49+
cost_per_1k_output_tokens: float,
50+
) -> None:
51+
self._org_limit_usd = org_limit_usd
52+
self._agent_limit_usd = agent_limit_usd
53+
self._cost_per_1k_input = cost_per_1k_input_tokens
54+
self._cost_per_1k_output = cost_per_1k_output_tokens
55+
self._org_spent_usd: float = 0.0
56+
self._agent_spent: dict[str, float] = {}
57+
self._lock = threading.Lock()
58+
59+
def estimate_cost(
60+
self,
61+
input_tokens: int,
62+
output_tokens: int,
63+
) -> float:
64+
"""Estimate cost from token counts (clamped to non-negative)."""
65+
raw = (
66+
max(input_tokens, 0) / 1000.0 * self._cost_per_1k_input
67+
+ max(output_tokens, 0) / 1000.0 * self._cost_per_1k_output
68+
)
69+
return max(raw, 0.0)
70+
71+
def check(self, agent_name: str) -> tuple[bool, str]:
72+
"""Check if agent is within budget. Returns (allowed, reason)."""
73+
with self._lock:
74+
if self._org_spent_usd >= self._org_limit_usd:
75+
return False, (
76+
f"Org budget exhausted: ${self._org_spent_usd:.4f}"
77+
f" / ${self._org_limit_usd:.4f}"
78+
)
79+
agent_spent = self._agent_spent.get(agent_name, 0.0)
80+
if agent_spent >= self._agent_limit_usd:
81+
return False, (
82+
f"Agent '{agent_name}' budget exhausted:"
83+
f" ${agent_spent:.4f} / ${self._agent_limit_usd:.4f}"
84+
)
85+
return True, ""
86+
87+
def record(self, agent_name: str, cost_usd: float) -> None:
88+
"""Record cost for an agent."""
89+
with self._lock:
90+
self._org_spent_usd += cost_usd
91+
self._agent_spent[agent_name] = (
92+
self._agent_spent.get(agent_name, 0.0) + cost_usd
93+
)
94+
95+
def utilization(self) -> float:
96+
"""Current org-level budget utilization (0.0 to 1.0+)."""
97+
with self._lock:
98+
if self._org_limit_usd <= 0:
99+
return 1.0
100+
return self._org_spent_usd / self._org_limit_usd
101+
102+
def snapshot(self) -> BudgetSnapshot:
103+
"""Return a read-only snapshot of current budget state."""
104+
with self._lock:
105+
return BudgetSnapshot(
106+
org_spent_usd=self._org_spent_usd,
107+
org_limit_usd=self._org_limit_usd,
108+
agent_spent=dict(self._agent_spent),
109+
agent_limit_usd=self._agent_limit_usd,
110+
)

0 commit comments

Comments
 (0)