Tool Hooks¶
Tool hooks allow you to execute custom logic before and after tool execution. Use hooks for logging, validation, policy checks, and monitoring.
Hook types¶
- Pre-hooks: Execute before the tool callable runs. Receive the
ToolCall. - Post-hooks: Execute after the tool callable completes. Receive both the
ToolCallandToolResult.
Pre-hooks¶
Pre-hooks receive the tool call before execution:
from blackgeorge.tools import ToolPreHook, tool
from blackgeorge.core.tool_call import ToolCall
def log_call(call: ToolCall) -> None:
print(f"Calling {call.name} with {call.arguments}")
@tool(pre=(log_call,))
def calculate(x: int, y: int) -> int:
return x + y
Pre-hook signature¶
from typing import Any
from blackgeorge.core.tool_call import ToolCall
ToolPreHook = Callable[[ToolCall], Any]
A pre-hook can: - Inspect the tool call - Log metadata - Validate arguments
Pre-hook examples¶
Argument validation¶
from blackgeorge.tools import tool
def validate_positive(call):
for key, value in call.arguments.items():
if isinstance(value, (int, float)) and value < 0:
raise ValueError(f"{key} must be positive, got {value}")
@tool(pre=(validate_positive,))
def square_root(x: float) -> float:
return x ** 0.5
Timing¶
import time
from blackgeorge.tools import tool
timing_data = {}
def start_timer(call):
timing_data[call.id] = time.time()
@tool(pre=(start_timer,))
def expensive_operation(n: int) -> int:
return sum(range(n))
Access control¶
from blackgeorge.tools import tool
ALLOWED_USERS = {"admin", "supervisor"}
def check_permission(call):
user = call.arguments.get("requested_by", "anonymous")
if user not in ALLOWED_USERS:
raise PermissionError(f"User {user} not allowed to call {call.name}")
@tool(pre=(check_permission,), requires_confirmation=True)
def delete_file(file_path: str, requested_by: str) -> str:
import os
os.remove(file_path)
return f"Deleted {file_path}"
Post-hooks¶
Post-hooks receive the tool call and result after execution:
from blackgeorge.tools import ToolPostHook, tool
from blackgeorge.tools.base import ToolResult
def log_result(call: ToolCall, result: ToolResult) -> None:
status = "success" if not result.error else "failed"
print(f"{call.name} {status}: {result.content}")
@tool(post=(log_result,))
def calculate(x: int, y: int) -> int:
return x + y
Post-hook signature¶
from typing import Any
from blackgeorge.core.tool_call import ToolCall
from blackgeorge.tools.base import ToolResult
ToolPostHook = Callable[[ToolCall, ToolResult], Any]
A post-hook can: - Log results - Add metrics - Trigger side effects - Inspect error conditions
Post-hook examples¶
Result logging¶
from blackgeorge.tools import tool
def log_completion(call, result):
if result.timed_out:
print(f"WARNING: {call.name} timed out")
elif result.cancelled:
print(f"WARNING: {call.name} was cancelled")
elif result.error:
print(f"ERROR in {call.name}: {result.error}")
else:
print(f"SUCCESS: {call.name} returned {result.content}")
@tool(
timeout=5.0,
post=(log_completion,),
)
def fetch_data(url: str) -> str:
import requests
return requests.get(url).text
Result enrichment (side effects)¶
from datetime import datetime
from blackgeorge.tools import tool
result_audit = {}
def record_timestamp(call, result):
if result.content:
result_audit[call.id] = {
"recorded_at": datetime.now().isoformat(),
"content_preview": result.content[:80],
}
@tool(post=(record_timestamp,))
def get_status() -> str:
return "OK"
Metrics collection¶
from collections import defaultdict
from blackgeorge.tools import tool
tool_metrics = defaultdict(lambda: {"calls": 0, "errors": 0, "total_time": 0})
def track_metrics(call, result):
metrics = tool_metrics[call.name]
metrics["calls"] += 1
if result.error:
metrics["errors"] += 1
@tool(post=(track_metrics,))
def process_data(data: str) -> str:
return data.upper()
Combining pre and post hooks¶
You can use both pre and post hooks on the same tool:
from blackgeorge.tools import tool
def before(call):
print(f"Starting {call.name}")
def after(call, result):
print(f"Finished {call.name}: {result.content}")
@tool(pre=(before,), post=(after,))
def multiply(a: int, b: int) -> int:
return a * b
Async hooks¶
Async hooks are supported in async tool execution paths:
import asyncio
from blackgeorge.tools import tool
async def async_pre_hook(call):
await asyncio.sleep(0.1)
print(f"Async pre-hook for {call.name}")
async def async_post_hook(call, result):
await asyncio.sleep(0.1)
print(f"Async post-hook for {call.name}")
@tool(pre=(async_pre_hook,), post=(async_post_hook,))
async def async_tool(value: str) -> str:
await asyncio.sleep(0.1)
return value.upper()
Multiple hooks¶
You can chain multiple hooks:
from blackgeorge.tools import tool
def log_start(call):
print(f"Start: {call.name}")
def validate_args(call):
if "x" not in call.arguments:
raise ValueError("Missing 'x' argument")
def log_end(call, result):
print(f"End: {call.name} -> {result.content}")
def check_error(call, result):
if result.error:
print(f"Error detected: {result.error}")
@tool(
pre=(log_start, validate_args),
post=(log_end, check_error),
)
def compute(x: int, y: int) -> int:
return x + y
Hooks are executed in the order they are defined.
Hook context and state¶
Hooks can share state through closures or class attributes:
class ToolMonitor:
def __init__(self):
self.calls = []
self.errors = []
def pre_hook(self, call):
self.calls.append(call)
def post_hook(self, call, result):
if result.error:
self.errors.append((call, result.error))
monitor = ToolMonitor()
@tool(pre=(monitor.pre_hook,), post=(monitor.post_hook,))
def risky_operation(value: int) -> int:
if value < 0:
raise ValueError("Negative values not allowed")
return value * 2
Advanced patterns¶
Conditional hooks¶
from blackgeorge.tools import tool
def conditional_pre(call):
actor = call.arguments.get("requested_by", "unknown")
if call.name.startswith("admin_"):
print(f"Admin tool {call.name} called by {actor}")
@tool(pre=(conditional_pre,))
def admin_delete_user(user_id: str) -> str:
return f"Deleted {user_id}"
Retry-aware hooks¶
from blackgeorge.tools import tool
attempt_count = {}
def count_attempts(call):
attempt_count[call.id] = attempt_count.get(call.id, 0) + 1
print(f"Attempt {attempt_count[call.id]} for {call.name}")
@tool(retries=3, pre=(count_attempts,))
def flaky_operation(value: str) -> str:
import random
if random.random() < 0.5:
raise Exception("Random failure")
return value
Cache telemetry with hooks¶
from blackgeorge.tools import tool
cache_stats = {"hits": 0, "misses": 0}
cache = set()
def cache_pre(call):
key = f"{call.name}:{frozenset(call.arguments.items())}"
if key in cache:
cache_stats["hits"] += 1
else:
cache_stats["misses"] += 1
def cache_post(call, result):
if result.error is None:
key = f"{call.name}:{frozenset(call.arguments.items())}"
cache.add(key)
@tool(pre=(cache_pre,), post=(cache_post,))
def expensive_computation(n: int) -> int:
return sum(range(n))
Tool result inspection¶
Post-hooks can inspect detailed result information:
from blackgeorge.tools import tool, ToolResult
def inspect_result(call, result):
print(f"Tool: {call.name}")
print(f"Content: {result.content}")
print(f"Data: {result.data}")
print(f"Error: {result.error}")
print(f"Timed out: {result.timed_out}")
print(f"Cancelled: {result.cancelled}")
@tool(timeout=10.0, post=(inspect_result,))
async def long_running_task(seconds: int) -> str:
import asyncio
await asyncio.sleep(seconds)
return f"Slept for {seconds} seconds"
Error handling in hooks¶
Exceptions in pre-hooks prevent tool execution:
from blackgeorge.tools import tool
def strict_validation(call):
if call.arguments.get("value", 0) < 0:
raise ValueError("Value must be non-negative")
@tool(pre=(strict_validation,))
def process(value: int) -> int:
return value * 2
# This will fail with ValueError if value < 0
Exceptions in post-hooks propagate and fail tool execution:
def failing_post_hook(call, result):
raise Exception("Post-hook failure")
@tool(post=(failing_post_hook,))
def my_tool(value: str) -> str:
return value
Best practices¶
- Keep hooks fast: Hooks run synchronously, so avoid blocking operations
- Use hooks for cross-cutting concerns: Logging, metrics, validation
- Don't rely on hook return values: Pre-hook returns are ignored
- Handle exceptions in post-hooks: Unhandled post-hook errors fail tool execution
- Use closures for state: Maintain hook-specific state with function closures or classes
- Chain hooks carefully: Order matters when using multiple hooks
- Consider async for I/O: Use async hooks for network or database operations