Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions contributing/samples/agent_hooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Governing an ADK agent with agent-hooks

This sample shows how to govern an ADK agent with
[agent-hooks](https://github.com/responsibleai/agent-hooks), a framework-neutral
_control_ contract for AI agent systems. You register one or more
**interceptors** (policy engines, content filters, egress guards, ...) once, and
[`AgentHooksPlugin`](../../../src/google/adk/plugins/_agent_hooks_plugin.py)
enforces their verdicts at every governed point in the ADK lifecycle.

## What it demonstrates

The agent is a customer-support assistant with two tools: `lookup_account` and
`delete_account`. A single [`ToolGovernanceInterceptor`](governance.py) applies
two policies:

- **deny** — `delete_account` is destructive, so the interceptor blocks the tool
call before it runs. The model receives a policy error and tells the user it
cannot perform the action.
- **transform** — `lookup_account` returns an `email` and an `api_key`. The
interceptor redacts those fields _before the model or the transcript sees
them_.

Every decision is recorded as an auditable `InterceptionRecord`; `main.py`
prints the trail at the end.

## Interception-point mapping

| ADK plugin callback | agent-hooks point |
| ----------------------------- | ----------------- |
| `before_run_callback` | `agent_startup` |
| `on_user_message_callback` | `input` |
| `before_model_callback` | `pre_model_call` |
| `after_model_callback` | `post_model_call` |
| `before_tool_callback` | `pre_tool_call` |
| `after_tool_callback` | `post_tool_call` |
| `on_event_callback` (final) | `output` |
| `after_run_callback` | `agent_shutdown` |

## Prerequisites

1. Install ADK with the optional `agent-hooks` extra, plus LiteLLM for the local
model:

```bash
pip install "google-adk[agent-hooks]" litellm
```

2. Install [Ollama](https://ollama.com/) and pull a tool-capable model:

```bash
ollama pull qwen2.5
```

The example runs against a real local model, so tool-calling behavior is not
scripted. Any tool-capable Ollama model works; edit the `LiteLlm(model=...)`
line in [`agent.py`](agent.py) to change it.

## Run

```bash
python -m contributing.samples.agent_hooks.main
```

Expected shape of the output:

- For "look up account 42", the agent calls `lookup_account` and summarizes the
result — with `email` and `api_key` already redacted.
- For "delete account 42", the `delete_account` call is denied and the agent
explains it cannot delete the account.
- The audit trail lists every interception point and its verdict, including the
`pre_tool_call -> deny` and `post_tool_call -> transform` decisions.

## Enforcement semantics

`AgentHooksPlugin` **fails closed**: a `deny` blocks the guarded action, a
`transform` rewrites the guarded value, and any engine error, malformed verdict,
or interceptor timeout becomes a fail-closed block — it never fails open. Set
`mode="evaluate_only"` on the plugin to record decisions without enforcing them.

## Trust model

agent-hooks is a _cooperative_ control contract, **not** a security boundary:
interceptors run in-process with full data access and the interception points do
not guarantee complete mediation. See the agent-hooks
[`SECURITY.md`](https://github.com/responsibleai/agent-hooks/blob/main/SECURITY.md)
before relying on it for isolation.
15 changes: 15 additions & 0 deletions contributing/samples/agent_hooks/__init__.py
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions contributing/samples/agent_hooks/agent.py
Original file line number Diff line number Diff line change
@@ -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.

"""A customer-support agent with two tools, one of them destructive.

The agent runs on a local Ollama model so the example exercises real model
behavior (see README.md). agent-hooks governance is wired in ``main.py``: the
``delete_account`` tool call is denied, and ``lookup_account`` results are
redacted, before the model ever sees them.
"""

from __future__ import annotations

from google.adk.agents.llm_agent import LlmAgent
from google.adk.models.lite_llm import LiteLlm


def lookup_account(user_id: str) -> dict:
"""Looks up a customer account.

Args:
user_id: The id of the account to look up.

Returns:
The account record, including fields the governance policy will redact.
"""
return {
"user_id": user_id,
"name": "Alice Example",
"email": "alice@example.com",
"api_key": "EXAMPLE_NOT_A_REAL_KEY",
"plan": "pro",
}


def delete_account(user_id: str) -> dict:
"""Permanently deletes a customer account.

This is a destructive tool; the governance policy denies it before it runs.

Args:
user_id: The id of the account to delete.

Returns:
A confirmation record (never reached under the governance policy).
"""
return {"user_id": user_id, "status": "deleted"}


root_agent = LlmAgent(
name="support_agent",
model=LiteLlm(model="ollama_chat/qwen2.5:latest"),
description="A customer-support agent guarded by agent-hooks.",
instruction=(
"You are a customer-support assistant. Always use the available tools"
" to fulfill the user's request: call lookup_account to read an account"
" and call delete_account when the user asks to delete one. After a"
" tool returns, summarize its result for the user. If a tool result"
" reports that it was blocked by policy, tell the user you were not"
" allowed to perform that action."
),
tools=[lookup_account, delete_account],
)
106 changes: 106 additions & 0 deletions contributing/samples/agent_hooks/governance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# 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.

"""An example agent-hooks interceptor: a small tool-governance policy.

The interceptor implements the ``intercept(AgentContext) -> Verdict`` contract.
It demonstrates the two enforcement primitives that matter most for tool use:

- ``deny``: a destructive tool (``delete_account``) is blocked before it runs.
- ``transform``: sensitive fields returned by a tool are redacted before the
model (and the transcript) ever see them.

An interceptor is framework-neutral: this same class works against any
agent-hooks host (ADK, crewAI, ...), not just ADK.
"""

from __future__ import annotations

import re
from typing import Any

from agent_hooks import AgentContext
from agent_hooks import Decision
from agent_hooks import Transform
from agent_hooks import Verdict

#: Tools that must never execute under this policy.
_DENIED_TOOLS = frozenset({"delete_account"})

#: Result fields whose values are masked before the model sees them.
_SENSITIVE_KEYS = frozenset(
{"email", "api_key", "password", "secret", "token", "ssn"}
)

_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")


def _redact(value: Any) -> tuple[Any, bool]:
"""Return ``(redacted_value, changed)`` for a tool result.

Masks the values of sensitive keys and any email address found in a string.
"""
changed = False

def walk(node: Any) -> Any:
nonlocal changed
if isinstance(node, dict):
out: dict[str, Any] = {}
for key, item in node.items():
if key in _SENSITIVE_KEYS and isinstance(item, str):
out[key] = "[REDACTED]"
changed = True
else:
out[key] = walk(item)
return out
if isinstance(node, list):
return [walk(item) for item in node]
if isinstance(node, str):
masked = _EMAIL_RE.sub("[REDACTED_EMAIL]", node)
if masked != node:
changed = True
return masked
return node

return walk(value), changed


class ToolGovernanceInterceptor:
"""Deny destructive tools and redact sensitive tool results."""

name = "tool_governance"

def intercept(self, ctx: AgentContext) -> Verdict:
point = ctx["interception_point"]

if point == "pre_tool_call":
tool_name = ctx["tool_call"]["name"]
if tool_name in _DENIED_TOOLS:
return Verdict.deny(
reason="tool_denied",
message=f"Tool '{tool_name}' is disabled by policy.",
)
return Verdict(decision=Decision.ALLOW)

if point == "post_tool_call":
# ``ctx["target"]`` is the tool result value at post_tool_call.
redacted, changed = _redact(ctx["target"])
if changed:
return Verdict(
decision=Decision.TRANSFORM,
transform=Transform(path="$target", value=redacted),
)
return Verdict(decision=Decision.ALLOW)

return Verdict(decision=Decision.ALLOW)
92 changes: 92 additions & 0 deletions contributing/samples/agent_hooks/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 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.

"""Runs the support agent with agent-hooks governance enabled.

Prerequisites (see README.md):
* Ollama running locally with the ``qwen2.5:7b`` model pulled.
* ``pip install "google-adk[agent-hooks]" litellm``

Run:
python -m contributing.samples.agent_hooks.main
"""

from __future__ import annotations

import asyncio
from typing import Any

from google.adk.apps.app import App
from google.adk.plugins import AgentHooksPlugin
from google.adk.runners import InMemoryRunner
from google.genai import types

from .agent import root_agent
from .governance import ToolGovernanceInterceptor

_APP_NAME = "agent_hooks_demo"


async def main() -> None:
"""Runs two prompts: one benign (redacted), one destructive (denied)."""
# ``record_sink`` receives an auditable InterceptionRecord per decision.
records: list[Any] = []
plugin = AgentHooksPlugin(
interceptors=[ToolGovernanceInterceptor()],
record_sink=records.append,
)

app = App(name=_APP_NAME, root_agent=root_agent, plugins=[plugin])
runner = InMemoryRunner(app=app)
session = await runner.session_service.create_session(
user_id="user", app_name=_APP_NAME
)

prompts = [
"Look up the account details for user 42.",
"Now delete account 42.",
]
for prompt in prompts:
print(f"\n=== USER: {prompt} ===")
async for event in runner.run_async(
user_id="user",
session_id=session.id,
new_message=types.Content(
role="user", parts=[types.Part.from_text(text=prompt)]
),
):
if event.content and event.content.parts:
for part in event.content.parts:
if part.text:
print(f"[{event.author}] {part.text}")
if part.function_call:
print(f"[{event.author}] -> tool call: {part.function_call.name}")
if part.function_response:
print(
f"[{event.author}] <- tool result:"
f" {part.function_response.response}"
)

print("\n=== agent-hooks audit trail ===")
for record in records:
verdict = record.verdict
print(
f"seq={record.sequence:<2} {record.interception_point.value:<16}"
f" -> {verdict.decision.value}"
+ (f" ({verdict.reason})" if verdict.reason else "")
)


if __name__ == "__main__":
asyncio.run(main())
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ dependencies = [
optional-dependencies.a2a = [
"a2a-sdk>=0.3.4,<2",
]
optional-dependencies.agent-hooks = [
# Framework-neutral agent lifecycle governance contract with a compiled
# native core; used by google.adk.plugins.AgentHooksPlugin.
"agent-hooks-sdk>=0.1.0a4",
]
optional-dependencies.agent-identity = [
"google-cloud-agentidentitycredentials>=0.1,<0.2",
"google-cloud-iamconnectorcredentials>=0.1,<0.2",
Expand Down
Loading