DEV Community

Cover image for Callback the Police: Enforcing Business Rules in AI Agents ๐Ÿ‘ฎโ€โ™‚๏ธ
Claret Ibeawuchi
Claret Ibeawuchi

Posted on

Callback the Police: Enforcing Business Rules in AI Agents ๐Ÿ‘ฎโ€โ™‚๏ธ

Table of Contents


The Traffic Cop Analogy

Imagine a busy intersection without traffic lights or stop signs, just drivers making their own decisions. Most would probably stop and check. Some might slow down. A few might just go, assuming others will yield.

That's your AI agent without callbacks.

Now add a traffic cop who:

  • Blocks unsafe moves before they happen
  • Redirects traffic when routes are closed
  • Enforces rules consistently, regardless of driver intent

That's your AI agent with callbacks.

The difference? One relies on hoping drivers (the LLM) make good decisions. The other enforces good decisions with authority.


What Are Callbacks in AI Agents?

Callbacks are functions that run at specific points in your agent's lifecycle, giving you programmatic control over agent behavior.

Think of them as hooks where you can:

  • โœ… Intercept actions before they execute
  • โœ… Modify parameters or route to different functions
  • โœ… Block unauthorized operations
  • โœ… Inject additional context
  • โœ… Validate outputs before users see them

The Callback Lifecycle

User: "Create an order for me"
         โ†“
    [LLM reasoning...]
         โ†“
    Decides to call: process_payment(amount=250)
         โ†“
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚  before_tool_callback()            โ”‚  โ† YOU ARE HERE
    โ”‚  "Should this be allowed?"         โ”‚    (Pre-execution)
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ†“
    [Tool executes: process_payment()]
         โ†“
    [LLM generates response...]
         โ†“
    โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
    โ”‚  after_model_callback()            โ”‚  โ† YOU ARE HERE
    โ”‚  "Is this response correct?"       โ”‚    (Post-generation)
    โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
         โ†“
    User sees: "Payment of $250 processed successfully"
Enter fullscreen mode Exit fullscreen mode

Callbacks sit between the LLM's decisions and their execution/output, giving you the final say.


The before_tool_callback: Your First Line of Defense

The before_tool_callback runs before every tool call the LLM tries to make. This is where you enforce business rules.

Signature and Components

def before_tool_callback(
    tool: BaseTool,           # The tool about to be called
    args: dict,               # Arguments the LLM provided
    tool_context: ToolContext # Session state, auth token, etc.
) -> Optional[dict]:
    """
    Returns:
    - None: Proceed with normal tool execution
    - dict: Skip execution and return this result instead
    """
    pass
Enter fullscreen mode Exit fullscreen mode

What You Can Do

def before_tool_callback(tool, args, tool_context):
    tool_name = tool.func.__name__

    # 1. BLOCK unauthorized actions
    if tool_name == "delete_user" and not is_admin(tool_context):
        return {
            "error": True,
            "message": "Unauthorized: Admin access required"
        }

    # 2. ROUTE to different implementations
    if tool_name == "get_price":
        if is_premium_tier(tool_context):
            return calculate_premium_price(**args)
        else:
            return calculate_standard_price(**args)

    # 3. MODIFY parameters before execution
    if tool_name == "search_database":
        # Add tenant filter for multi-tenant systems
        args["tenant_id"] = tool_context.state.get("tenant_id")

    # 4. INJECT missing context
    if tool_name == "create_order" and not args.get("user_id"):
        args["user_id"] = tool_context.state.get("current_user_id")

    return None  # Proceed with (possibly modified) execution
Enter fullscreen mode Exit fullscreen mode

The callback can:

  • Return a result โ†’ Skip tool execution entirely, use your result
  • Modify args in place โ†’ Tool executes with modified parameters
  • Return None โ†’ Tool executes normally

Real-World Pattern: Admin Payment Exemption

Problem: Our agent was asking admin users to pay for services. Admins should create orders for free on behalf of customers.

Why it happened: The LLM doesn't understand business roles, it just follows the general user flow.

Solution: Intercept payment tool calls and block them for admin users.

Implementation

def before_tool_callback(tool, args, tool_context):
    tool_name = tool.func.__name__

    # Get or cache admin status
    is_admin = tool_context.state.get("session:is_admin")

    if is_admin is None:
        # First time - check auth token and cache result
        auth_token = tool_context.state.get("auth_token")
        is_admin = check_admin_status(auth_token)
        tool_context.state["session:is_admin"] = is_admin  # Cache it

    # ========================================================================
    # ADMIN PAYMENT EXEMPTION
    # Block payment tools for admin users - admins create orders for free
    # ========================================================================
    if tool_name in ["process_payment", "charge_card"]:
        if is_admin:
            logger.info(f"๐Ÿ” ADMIN USER: Blocking {tool_name} - no payment required")

            # Mark session as payment exempt
            tool_context.state["session:payment_status"] = "admin_exempt"

            # Return success WITHOUT executing payment
            return {
                "success": True,
                "message": "โœ… Admin user - no payment required. You can proceed immediately.",
                "admin_exemption": True,
                "payment_status": "admin_exempt"
            }

    return None  # Non-payment tools execute normally
Enter fullscreen mode Exit fullscreen mode

What Happens

Before callbacks:

Admin: "Create an order"
Agent: "I'll need to process your payment first..."
Admin: /frustration/ "I'm an admin!"
Enter fullscreen mode Exit fullscreen mode

After callbacks:

Admin: "Create an order"
[Callback intercepts payment tool call]
[Returns success without executing payment]
Agent: "โœ… Admin user - no payment required. Creating your order now..."
Admin: /happy/
Enter fullscreen mode Exit fullscreen mode

Key Insights

  1. Admin status is cached in session state, we only check once per session
  2. Multiple payment tool names are covered (any payment-related tool)
  3. Logs include context (๐Ÿ” emoji for security actions)
  4. Session state updated to track exemption status
  5. Clean response that explains why (for debugging) without confusing the user

Performance Optimization: Caching Expensive Checks

def before_tool_callback(tool, args, tool_context):
    # Check cache first
    is_admin = tool_context.state.get("session:is_admin")

    if is_admin is None:
        # First time - check auth and cache result
        auth_token = tool_context.state.get("auth_token")
        is_admin = check_admin_status(auth_token)  # Expensive: JWT decode + lookup

        # Cache for entire session
        tool_context.state["session:is_admin"] = is_admin
        logger.info(f"๐Ÿ” Cached admin status: {is_admin}")

    # Use cached value
    if tool_name in PAYMENT_TOOLS and is_admin:
        return {"success": True, "admin_exempt": True}

    return None
Enter fullscreen mode Exit fullscreen mode

Performance impact:

  • Before: Auth check on every tool call (~50-100ms each ร— 10 calls = 500-1000ms)
  • After: Auth check once per session (~50ms total)

What to cache:

  • โœ… User roles/permissions
  • โœ… Tenant/organization IDs
  • โœ… Feature flags
  • โŒ Dynamic data (current balance, order status)

Real-World Pattern: Tool Interception and Routing

Problem: The LLM sometimes calls the wrong pricing tool:

  • Calls regular pricing for admin users โ†’ Wrong (too expensive)
  • Calls admin pricing for regular users โ†’ Security violation

Why it happened: The LLM chooses tools based on context and phrasing, which is inconsistent.

Solution: Intercept pricing tool calls and route to the correct implementation.

Implementation

def before_tool_callback(tool, args, tool_context):
    tool_name = tool.func.__name__

    # Get user type from session
    is_admin = tool_context.state.get("session:is_admin", False)

    # ========================================================================
    # DETERMINISTIC PRICING ROUTING
    # If LLM calls wrong pricing tool, redirect to correct one
    # ========================================================================

    if tool_name == "get_standard_price" and is_admin:
        # Admin called standard pricing โ†’ Route to premium
        logger.warning(
            f"๐Ÿ”„ LLM called standard pricing for ADMIN user - "
            f"intercepting and routing to premium pricing"
        )

        # Import and call correct function
        from app.tools.pricing import get_premium_price

        # Pass through all args + context
        args["tool_context"] = tool_context
        return get_premium_price(**args)

    elif tool_name == "get_premium_price" and not is_admin:
        # Regular user called admin pricing โ†’ Security violation, route to standard
        logger.warning(
            f"๐Ÿ”„ LLM called premium pricing for REGULAR user - "
            f"intercepting and routing to standard pricing"
        )

        from app.tools.pricing import get_standard_price
        args["tool_context"] = tool_context
        return get_standard_price(**args)

    return None
Enter fullscreen mode Exit fullscreen mode

What Happens

Regular User: "How much for this service?"
LLM: [Decides to call get_premium_price() - WRONG TOOL]
      โ†“
[Callback intercepts]
      โ†“
[Routes to get_standard_price() instead]
      โ†“
User sees correct standard pricing โœ…

Logs show:
"๐Ÿ”„ LLM called premium pricing for REGULAR user - intercepting and routing"
Enter fullscreen mode Exit fullscreen mode

Benefits

  1. Zero reliance on LLM choosing the right tool
  2. Security enforced in code, not prompts
  3. Transparent logging of routing decisions
  4. No user-visible errors - routing happens silently
  5. Consistent behavior regardless of how user phrases request

Real-World Pattern: Auto-Injecting Context

Problem: After getting a quote, users would say "proceed with order" and the agent would re-ask for all the parameters, even though all that information was already collected.

Why it happened: The LLM didn't pass previous context to the order creation tool.

Solution: Auto-inject stored quote details when order tools are called.

Implementation

def before_tool_callback(tool, args, tool_context):
    tool_name = tool.func.__name__

    # ========================================================================
    # AUTO-INJECT QUOTE DETAILS INTO ORDER
    # If order tool is called without params, fill from stored quote
    # ========================================================================

    if tool_name == "create_order":
        # Get previously stored quote from session
        latest_quote = tool_context.state.get("session:latest_quote")

        if latest_quote:
            # Auto-inject missing required parameters
            for key in ["param_a", "param_b", "param_c", "total_price"]:
                if not args.get(key) and latest_quote.get(key):
                    args[key] = latest_quote[key]
                    logger.info(f"โœ… Auto-injected {key}: {args[key]}")

    return None  # Proceed with augmented args
Enter fullscreen mode Exit fullscreen mode

Storing Quote Data

When a quote is generated, store it:

def calculate_quote(params, tool_context):
    # Calculate quote
    price = api.get_price(params)

    # Store complete quote in session state
    quote_data = {
        "param_a": params.get("param_a"),
        "param_b": params.get("param_b"),
        "param_c": params.get("param_c"),
        "total_price": price,
        "timestamp": time.time()
    }

    tool_context.state["session:latest_quote"] = quote_data

    return {"price": price, ...}
Enter fullscreen mode Exit fullscreen mode

User Experience

Before callbacks:

User: "Quote for service from A to B"
Agent: "That'll be $1,200"
User: "Book it"
Agent: "I'll need parameter A"
User: "I just told you!"
Agent: "I need the exact value..."
Enter fullscreen mode Exit fullscreen mode

After callbacks:

User: "Quote for service from A to B"
Agent: "That'll be $1,200"
[Quote stored in session state]
User: "Book it"
[Callback injects all required params from session]
Agent: "Great! Creating your order now..."
Enter fullscreen mode Exit fullscreen mode

Logs show:

โœ… Auto-injected param_a: value_a
โœ… Auto-injected param_b: value_b
โœ… Auto-injected param_c: value_c
โœ… Auto-injected price: $1200.00
Enter fullscreen mode Exit fullscreen mode

Key Insights

  1. Session state bridges turns - Data persists across messages
  2. LLM doesn't need to remember - We do it for them
  3. Only inject if missing - Don't override if LLM provided it
  4. Log every injection - Helps debugging and monitoring
  5. Structured storage - Use consistent keys for retrieval

Common Mistakes and How to Avoid Them

โŒ Mistake 1: Not Returning Anything

def before_tool_callback(tool, args, tool_context):
    if should_block_tool(tool):
        logger.error("Blocking tool")
        # BUG: Forgot to return! Tool will still execute
Enter fullscreen mode Exit fullscreen mode

Fix:

def before_tool_callback(tool, args, tool_context):
    if should_block_tool(tool):
        logger.error("Blocking tool")
        return {"error": True, "message": "Unauthorized"}  # โœ…
Enter fullscreen mode Exit fullscreen mode

โŒ Mistake 2: Modifying Args Without In-Place Updates

def before_tool_callback(tool, args, tool_context):
    # BUG: Creates new dict, doesn't modify original
    args = {**args, "tenant_id": "abc123"}
    return None
Enter fullscreen mode Exit fullscreen mode

Fix:

def before_tool_callback(tool, args, tool_context):
    # โœ… Modifies dict in-place
    args["tenant_id"] = "abc123"
    return None
Enter fullscreen mode Exit fullscreen mode

โŒ Mistake 3: Forgetting Tool Context Parameter

# Original tool signature
def my_tool(param1, param2):
    pass

# In callback - BUG: Doesn't pass tool_context
return my_other_tool(param1=args["param1"])
Enter fullscreen mode Exit fullscreen mode

Fix:

# If tool needs context, include it
return my_other_tool(
    param1=args["param1"],
    tool_context=tool_context  # โœ…
)
Enter fullscreen mode Exit fullscreen mode

โŒ Mistake 4: Missing Tool Name Variants

def before_tool_callback(tool, args, tool_context):
    # BUG: Only catches one tool name, misses variants
    if tool.func.__name__ == "charge_payment":
        if is_admin(tool_context):
            return {"success": True, "admin_exempt": True}
Enter fullscreen mode Exit fullscreen mode

Problem: Your system might have multiple payment-related tools:

  • charge_payment
  • initiate_payment
  • process_card_payment

Missing one means the callback doesn't catch it!

Fix:

# Define at module level for easy maintenance
PAYMENT_TOOLS = [
    "charge_payment",
    "initiate_payment", 
    "process_card_payment",
    "charge_card"
]

def before_tool_callback(tool, args, tool_context):
    tool_name = tool.func.__name__

    # โœ… Catches all variants
    if tool_name in PAYMENT_TOOLS:
        if is_admin(tool_context):
            return {"success": True, "admin_exempt": True}

    return None
Enter fullscreen mode Exit fullscreen mode

Maintenance tip: When adding new tools, update the list once. All callbacks automatically cover it.

โŒ Mistake 5: Catching All Tools Too Broadly

def before_tool_callback(tool, args, tool_context):
    # BUG: Every tool will get user_id added
    args["user_id"] = get_user_id(tool_context)
    return None
Enter fullscreen mode Exit fullscreen mode

Fix:

def before_tool_callback(tool, args, tool_context):
    # โœ… Only inject for tools that need it
    if tool.func.__name__ in ["create_order", "update_profile"]:
        args["user_id"] = get_user_id(tool_context)
    return None
Enter fullscreen mode Exit fullscreen mode

โŒ Mistake 6: No Logging

def before_tool_callback(tool, args, tool_context):
    if is_admin(tool_context):
        return admin_version(**args)
    return None
Enter fullscreen mode Exit fullscreen mode

Fix:

def before_tool_callback(tool, args, tool_context):
    if is_admin(tool_context):
        logger.info(f"๐Ÿ”„ Routing {tool.func.__name__} to admin version")
        return admin_version(**args)
    return None
Enter fullscreen mode Exit fullscreen mode

Logging is critical for debugging "why did this happen?" in production.


Real-World Pattern: Detecting Admin Message Leaks

Even with perfect tool routing and parameter injection, the LLM can still leak admin-only information in its natural language responses.

The Problem

Admin sees:
"Using admin access for premium pricing... Your cost: $1,200"

Non-admin sees:
"Using admin access for premium pricing... Total: $1,500" โŒ
Enter fullscreen mode Exit fullscreen mode

The tool correctly returned different prices, but the LLM copy-pasted admin messaging into non-admin responses!

The Solution: Response Content Validation

Use after_model_callback to detect and clean role-specific messaging:

def after_model_callback(callback_context, llm_response):
    """Detect and remove admin-only phrases from non-admin responses"""

    # Get user role from session
    is_admin = callback_context.state.get("session:is_admin", False)

    if is_admin:
        return None  # Admin can see admin messages

    # Extract response text
    response_text = llm_response.content.parts[0].text

    # Define admin-only phrases
    admin_only_phrases = [
        "using admin access",
        "premium pricing",
        "admin access for",
        "your premium cost"
    ]

    # Check if any admin phrase leaked
    has_admin_phrase = any(
        phrase.lower() in response_text.lower() 
        for phrase in admin_only_phrases
    )

    if has_admin_phrase:
        logger.warning(
            f"๐Ÿšจ Admin message leaked to non-admin user! "
            f"Cleaning response..."
        )

        # Get correct price from session
        display_price = callback_context.state.get("session:last_display_price")

        # Override with clean, role-appropriate message
        clean_response = f"Total shipping cost: ${display_price:.2f} ๐Ÿšš"

        from google.genai import types
        return LlmResponse(
            content=types.Content(
                role="model",
                parts=[types.Part(text=clean_response)]
            )
        )

    return None  # Response is clean
Enter fullscreen mode Exit fullscreen mode

Why This Happens

LLMs learn patterns from context. If they recently saw:

  1. Admin messages in earlier conversation
  2. Admin-formatted responses in training data
  3. Tool responses that mention "premium" or "admin"

They might echo these patterns even for non-admin users.

When to Use This Pattern

โœ… Use when:

  • Different user roles see different information
  • LLM has access to role-specific tool responses
  • Sensitive information must never leak

โŒ Don't use when:

  • All users see the same information
  • No role-based differentiation needed

Lesson learned: Callbacks protect against both wrong tool execution AND wrong LLM messaging. Defense in depth applies to language, not just code.


When to Use Which Callback

Google ADK provides several callback types. Here's when to use each:

before_tool_callback - Pre-Execution Enforcement

Use when:

  • โœ… Blocking unauthorized actions
  • โœ… Routing to different implementations
  • โœ… Injecting authentication context
  • โœ… Auto-filling parameters from session
  • โœ… Validating parameters before execution

Example use cases:

  • Admin payment exemption
  • Multi-tenant data isolation
  • Rate limiting
  • Parameter validation

after_model_callback - Post-Generation Validation

Use when:

  • โœ… Validating LLM output accuracy
  • โœ… Sanitizing responses (removing sensitive data)
  • โœ… Formatting output consistently
  • โœ… Correcting known LLM mistakes

Example use cases:

  • Price validation (next article!)
  • PII detection and removal
  • Response format enforcement
  • Output translation

Comparison

Callback Type When It Runs What to Use It For
before_tool_callback Before tool execution Business rule enforcement, routing, injection
after_tool_callback After tool execution Result transformation, logging
after_model_callback After LLM generates response Output validation, correction

Most common pattern: Use before_tool_callback for enforcement and after_model_callback for validation.


Key Takeaways

  1. Callbacks are enforcers - They don't ask, they tell
  2. Use them for business logic - Don't rely on LLM prompt-following
  3. Log everything - You'll need it for debugging
  4. Cache expensive checks - Store results in session state
  5. Return early for blocks - Don't execute what shouldn't happen
  6. Modify args in-place - When you want execution to proceed with changes
  7. Test edge cases - Callbacks run on EVERY tool call

The Enforcement Hierarchy

Strongest:   Code enforcement (callbacks)
             โ†‘
Medium:      Tool filtering by role
             โ†‘
Weakest:     LLM instructions
Enter fullscreen mode Exit fullscreen mode

Put your critical business logic in callbacks, not instructions.


Resources


Previous: "How Reliable Are Your AI Agents?"

Next: "The Ground Truth Principle: Session State and Output Validation"


What business rules are you enforcing with callbacks? Share your patterns!

Top comments (0)