Skip to main content

Command Palette

Search for a command to run...

Debug and Inject Data/Session to the Context in ADK

Published
13 min read
Debug and Inject Data/Session to the Context in ADK
J

Senior AI Engineer | Building & Telling Stories about AI/ML Systems | Software Engineer

Ever stared at your AI agent wondering why on earth it gave that response? You're not alone. Debugging LLM-powered agents feels like shouting into a void—you send a prompt, something mysterious happens, and out comes... chaos.

Here's the problem: most developers have zero visibility into what actually reaches the model. Session state? Invisible. System instructions? A black box. You're left guessing, tweaking, and burning hours on trial-and-error debugging.

The fix is simpler than you think. Google's ADK provides a powerful before_model_callback hook that lets you intercept, inspect, and inject data into every LLM request.

In this guide, you'll learn how to:

  • Peek inside the full context being sent to your model

  • Inject dynamic data like timestamps and session state

  • Build a reusable debug utility in under 50 lines of Python

Let's crack open that black box.

The Hidden Challenge of AI Agent Development

Debugging traditional software follows predictable patterns. You set breakpoints, inspect variables, trace execution flows, and identify where things went wrong. The logic is deterministic—given the same inputs, you get the same outputs. AI agents break all these assumptions.

When you build an agent with ADK, multiple layers sit between your code and the LLM. Your instruction gets combined with conversation history, session state, tool definitions, and system-level configurations. The final context that reaches the model might look nothing like what you originally wrote. A missing piece of context or an unexpected value can completely derail your agent's behavior.

The real pain manifests in several ways. You might spend hours wondering why your agent doesn't "remember" information you thought was in its context. Or you discover that session state you expected to persist simply isn't there. Perhaps the agent ignores temporal context because it has no concept of the current time. These issues compound when you're working with complex multi-turn conversations or stateful workflows.

Traditional debugging approaches fall short here. Print statements in your application code only show you what you send—not what the LLM actually receives. Logging the response tells you something went wrong but not why. You need a way to intercept and inspect the complete request before it hits the model.

Understanding ADK's Callback Architecture

Google's ADK provides an elegant solution through its callback system. The framework exposes several hook points where you can inject custom logic into the agent execution pipeline. The most powerful of these is the before_model_callback—a function that runs immediately before every LLM request.

This callback receives two critical objects. The CallbackContext contains metadata about the current execution: agent name, user ID, session information, and the current state dictionary. The LlmRequest holds the actual request being sent to the model, including the system instruction, conversation history, and configuration parameters.

The beauty of this design lies in its flexibility. You can inspect these objects without modifying them, giving you pure visibility into the agent's behavior. Or you can modify them on the fly, injecting additional context, transforming instructions, or augmenting the conversation history. The framework doesn't care—it simply uses whatever you return.

This callback pattern enables several powerful use cases. You can build comprehensive debugging utilities that log every request. You can inject dynamic runtime data that the agent needs but can't access otherwise. You can implement sophisticated context management strategies that go beyond ADK's defaults. The possibilities extend as far as your requirements demand.

Building the Core Debug Callback

The heart of our solution is a single callback function that handles both debugging and injection. This function intercepts every LLM request, augments it with additional context, and prints detailed information about what's being sent. Let's build it step by step.

First, import the required modules:

import asyncio
from datetime import datetime

from google.genai import types
from google.adk.agents import Agent
from google.adk.agents.callback_context import CallbackContext
from google.adk.models import LlmRequest
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService

These imports give you access to ADK's core components. The Agent class creates your AI agent, while Runner handles execution. InMemorySessionService provides simple session management for development. The CallbackContext and LlmRequest types define what your callback receives.

Now define the callback function signature:

def debug_and_inject_context(
    callback_context: CallbackContext,
    llm_request: LlmRequest
):

This function takes exactly two parameters—no more, no less. ADK calls it automatically before every model invocation. Whatever modifications you make to llm_request persist into the actual API call. The function can return None to continue normal execution or return a response to short-circuit the model call entirely.

Injecting Dynamic Datetime Information

One of the most common issues with LLM agents is temporal awareness. The model has no inherent knowledge of the current date and time. If your agent needs to answer questions about "today" or "this week," it's flying blind without explicit context.

Injecting datetime information solves this problem elegantly:

# ===== INJECT DATETIME =====
now = datetime.now()
datetime_text = f"""
Current Date and Time: {now.strftime("%A, %B %d, %Y at %I:%M:%S %p")}
"""

This code captures the current timestamp and formats it in a human-readable way. The format includes the day of the week, full date, and precise time. You place this at the beginning of the system instruction so the model sees it first.

The benefits extend beyond simple date questions. Your agent can now make time-sensitive decisions. It can understand relative references like "yesterday" or "next Monday." It can appropriately handle requests that depend on business hours or seasonal context. A single injection transforms a temporally ignorant agent into one that understands when it is.

Injecting Session State Into Context

Session state represents persistent information about the user or conversation. ADK stores this state and makes it available through the callback context. However, the LLM doesn't automatically see this state—you must explicitly inject it.

The injection code walks through the state dictionary and formats it for the model:

# ===== INJECT SESSION STATE =====
state_text = "\nSession State:\n"
state_dict = callback_context.state.to_dict() if callback_context.state else {}
if state_dict:
    for key, value in state_dict.items():
        if not key.startswith("_"):  # Skip internal keys
            state_text += f"  - {key}: {value}\n"
else:
    state_text += "  (empty)\n"

state_text += "\n" + "="*70 + "\n"

This code performs several important tasks. First, it safely converts the state object to a dictionary, handling cases where state might be None. Then it iterates through the dictionary, formatting each key-value pair. The underscore prefix check skips internal ADK state that shouldn't influence the model. Finally, it adds a visual separator to clearly delineate injected context from the original instruction.

With session state injected, your agent gains powerful personalization capabilities. It can remember user preferences across conversation turns. It can reference information like location, membership level, or previous choices. The model receives this context naturally, as if it were always part of the instruction.

Modifying the System Instruction

Now you combine the datetime and state injections with the original system instruction. This modification happens in place on the llm_request object:

# ===== MODIFY SYSTEM INSTRUCTION =====
original_instruction = llm_request.config.system_instruction or ""
llm_request.config.system_instruction = (
    datetime_text + state_text + original_instruction
)

The code preserves any existing system instruction. It prepends the injected context, ensuring the model sees dynamic information first. The order matters—information at the beginning of a prompt typically receives more attention from the model.

This approach maintains clean separation between your injection logic and your agent definition. Your agent's instruction remains focused on its core behavior. The callback handles cross-cutting concerns like datetime and state. You can enable or disable the callback without touching agent code.

Creating Comprehensive Debug Output

The debugging portion of the callback prints detailed information about every request. This visibility proves invaluable during development and troubleshooting. Let's examine each debug section:

# ===== DEBUG OUTPUT =====
print("\n" + "="*70)
print("🔍 DEBUG: LLM REQUEST")
print("="*70)

Clear visual separators make debug output easy to spot in your terminal. The emoji adds quick visual identification when scrolling through logs. You'll thank yourself for this formatting when debugging complex interactions.

Next, print the execution metadata:

print(f"\n📌 Agent: {callback_context.agent_name}")
print(f"📌 User: {callback_context.user_id}")
print(f"📌 Session: {callback_context.session.id if callback_context.session else 'N/A'}")

This information helps you track which agent is executing and for whom. In multi-agent systems, knowing the agent name prevents confusion. The session ID lets you correlate debug output with specific conversation instances.

Display the modified system instruction:

print(f"\n📋 System Instruction:")
print("-" * 70)
# Show first 500 chars to avoid clutter
instruction_preview = llm_request.config.system_instruction[:500]
if len(llm_request.config.system_instruction) > 500:
    instruction_preview += "..."
print(instruction_preview)
print("-" * 70)

Truncation keeps output manageable while still showing the most important content. The 500-character limit captures your injected context and the beginning of the original instruction. For full inspection, you can increase this limit or remove truncation entirely.

Show the current session state:

print(f"\n💾 Session State: {callback_context.state.to_dict()}")

This quick dump confirms what state exists at execution time. Comparing this with your expected state often reveals the source of bugs. Missing or incorrect state values become immediately obvious.

Finally, display the conversation history:

print(f"\n💬 Messages ({len(llm_request.contents)}):")
for i, content in enumerate(llm_request.contents):
    role_emoji = "👤" if content.role == "user" else "🤖"
    print(f"  [{i}] {role_emoji} {content.role}:", end=" ")

    if content.parts:
        for part in content.parts:
            if part.text:
                text = part.text[:80] + "..." if len(part.text) > 80 else part.text
                print(f"{text}")
            elif part.function_call:
                print(f"[Tool: {part.function_call.name}]")
            elif part.function_response:
                print(f"[Tool Result: {part.function_response.name}]")

This section reveals the full conversation context. You see every message in the history, including their roles and content. Tool calls and responses appear clearly labeled. Truncated text previews keep output readable while showing message structure.

Building the Conversation Turn Handler

With the callback complete, you need a helper function to execute conversation turns. This function sends a user message and processes the response:

async def run_turn(runner: Runner, user_id: str, session_id: str, query: str):
    """
    Execute a single conversation turn with the agent.

    Args:
        runner: The Runner instance to execute the query
        user_id: The user identifier
        session_id: The session identifier
        query: The user's query text
    """
    print(f"\n{'─'*70}")
    print(f"👤 USER: {query}")
    print('─'*70)

    msg = types.Content(role='user', parts=[types.Part(text=query)])

    async for event in runner.run_async(
        user_id=user_id,
        session_id=session_id,
        new_message=msg
    ):
        if event.is_final_response():
            response_text = event.content.parts[0].text if event.content and event.content.parts else "No response"
            print(f"\n🤖 AGENT: {response_text}\n")

The function creates a Content object with the user's message. It uses run_async to execute the conversation turn asynchronously. The async iterator yields events as the agent processes the request. Checking is_final_response() filters for the actual model output.

This pattern handles streaming responses gracefully. ADK emits multiple events during execution—tool calls, intermediate steps, and the final response. By filtering for final responses, you capture just the output you want to display.

Wiring Everything Together

The main function assembles all components into a working application. It creates the agent, configures the runner, sets up initial session state, and runs an interactive loop:

async def main():
    """
    Simple example demonstrating context debugging.
    """

    print("\n" + "="*70)
    print("🚀 SIMPLE ADK CONTEXT DEBUGGER")
    print("="*70)
    print("This example shows how to debug the LLM context.")
    print("="*70 + "\n")

    # Create agent with debug callback
    agent = Agent(
        name="debug_agent",
        model="gemini-2.0-flash-exp",
        instruction="You are a helpful assistant. Use the information provided above to answer questions.",
        before_model_callback=debug_and_inject_context
    )
    # Create runner with in-memory sessions
    runner = Runner(
        app_name="simple_debug_app",
        agent=agent,
        session_service=InMemorySessionService()
    )
    user_id = "user123"
    session_id = "session456"

    # Create session with some initial state
    await runner.session_service.create_session(
        app_name="simple_debug_app",
        user_id=user_id,
        session_id=session_id,
        state={
            "location": "San Francisco",
            "membership_level": "premium"
        }
    )
    # Interactive loop
    print("Type 'exit' to quit\n")

    while True:
        query = input("👤 YOU: ").strip()

        if query.lower() == 'exit':
            break

        if query:  # Only process non-empty input
            await run_turn(runner, user_id, session_id, query)


if __name__ == "__main__":
    asyncio.run(main())

The before_model_callback parameter accepts your function directly. ADK automatically invokes it at the right point in the execution pipeline. The instruction references "information provided above" because your callback prepends context.

InMemorySessionService provides lightweight session storage perfect for development. For production, you'd swap this for a persistent implementation. The app name creates a namespace for your sessions.

Initialize a session with starting state. Pre-populating state demonstrates how injected values appear to the model. The location and membership level represent typical personalization data. Your callback will inject these into every request automatically.

The loop accepts user input continuously until they type "exit." Empty inputs get skipped to prevent unnecessary API calls. The asyncio.run() call bootstraps the async event loop.

Best Practices for Context Management

Effective context management requires thoughtful design. These practices help you build maintainable and reliable agents.

Keep Injected Context Concise: Every character you inject consumes model context window. Focus on information the model actually needs to perform its task. Remove redundant or rarely-used state values from injection.

Use Clear Separators: Visual separators help the model understand context structure. The example uses equals signs and dashes to delineate sections. Consistent formatting reduces confusion and improves response quality.

Order Context Strategically: Place the most important information early in the context. Models often attend more strongly to content near the beginning. Put dynamic, request-specific data before static instructions.

Document Your Injections: Future maintainers need to understand what gets injected. Comment your callback code explaining each injection's purpose. Keep a README describing the full context structure.

Test With and Without Injections: Verify that your agent works correctly both ways. Disable injections temporarily to test baseline behavior. Compare responses to ensure injections improve rather than confuse.

Conclusion

Debugging AI agents doesn't have to feel like shouting into a void. Google's ADK provides powerful hooks that let you see exactly what reaches your model and inject the dynamic context your agent needs. The before_model_callback pattern offers both visibility and control in a clean, maintainable way.

You've learned how to build a comprehensive debug utility from scratch. The callback injects datetime information so your agent understands temporal context. It injects session state so the model can personalize its responses. It prints detailed debug information so you always know what's happening.

Take this code and adapt it to your needs. Add new injection types for your specific use cases. Implement the advanced patterns for production deployments. Build the observability tooling that lets you understand and improve your agents over time.

The black box is open. Now go build something amazing.

Complete Source Code

For reference, here's the complete implementation:

"""
Simple ADK Context Debugger

A minimal example showing how to debug and inspect the LLM context
with datetime injection and session state visibility.
"""

import asyncio
from datetime import datetime

from google.genai import types
from google.adk.agents import Agent
from google.adk.agents.callback_context import CallbackContext
from google.adk.models import LlmRequest
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService


def debug_and_inject_context(
    callback_context: CallbackContext,
    llm_request: LlmRequest
):
    """
    Single callback that:
    1. Injects current datetime into system instruction
    2. Injects session state into system instruction
    3. Prints debug information about the request
    """

    # ===== INJECT DATETIME =====
    now = datetime.now()
    datetime_text = f"""
Current Date and Time: {now.strftime("%A, %B %d, %Y at %I:%M:%S %p")}
"""

    # ===== INJECT SESSION STATE =====
    state_text = "\nSession State:\n"
    state_dict = callback_context.state.to_dict() if callback_context.state else {}
    if state_dict:
        for key, value in state_dict.items():
            if not key.startswith("_"):  # Skip internal keys
                state_text += f"  - {key}: {value}\n"
    else:
        state_text += "  (empty)\n"

    state_text += "\n" + "="*70 + "\n"

    # ===== MODIFY SYSTEM INSTRUCTION =====
    original_instruction = llm_request.config.system_instruction or ""
    llm_request.config.system_instruction = (
        datetime_text + state_text + original_instruction
    )

    # ===== DEBUG OUTPUT =====
    print("\n" + "="*70)
    print("🔍 DEBUG: LLM REQUEST")
    print("="*70)

    print(f"\n📌 Agent: {callback_context.agent_name}")
    print(f"📌 User: {callback_context.user_id}")
    print(f"📌 Session: {callback_context.session.id if callback_context.session else 'N/A'}")

    print(f"\n📋 System Instruction:")
    print("-" * 70)
    instruction_preview = llm_request.config.system_instruction[:500]
    if len(llm_request.config.system_instruction) > 500:
        instruction_preview += "..."
    print(instruction_preview)
    print("-" * 70)

    print(f"\n💾 Session State: {callback_context.state.to_dict()}")

    print(f"\n💬 Messages ({len(llm_request.contents)}):")
    for i, content in enumerate(llm_request.contents):
        role_emoji = "👤" if content.role == "user" else "🤖"
        print(f"  [{i}] {role_emoji} {content.role}:", end=" ")

        if content.parts:
            for part in content.parts:
                if part.text:
                    text = part.text[:80] + "..." if len(part.text) > 80 else part.text
                    print(f"{text}")
                elif part.function_call:
                    print(f"[Tool: {part.function_call.name}]")
                elif part.function_response:
                    print(f"[Tool Result: {part.function_response.name}]")

    print("\n" + "="*70 + "\n")

    return None


async def run_turn(runner: Runner, user_id: str, session_id: str, query: str):
    """
    Execute a single conversation turn with the agent.
    """
    print(f"\n{'─'*70}")
    print(f"👤 USER: {query}")
    print('─'*70)

    msg = types.Content(role='user', parts=[types.Part(text=query)])

    async for event in runner.run_async(
        user_id=user_id,
        session_id=session_id,
        new_message=msg
    ):
        if event.is_final_response():
            response_text = event.content.parts[0].text if event.content and event.content.parts else "No response"
            print(f"\n🤖 AGENT: {response_text}\n")


async def main():
    """
    Simple example demonstrating context debugging.
    """

    print("\n" + "="*70)
    print("🚀 SIMPLE ADK CONTEXT DEBUGGER")
    print("="*70)
    print("This example shows how to debug the LLM context.")
    print("="*70 + "\n")

    agent = Agent(
        name="debug_agent",
        model="gemini-2.0-flash-exp",
        instruction="You are a helpful assistant. Use the information provided above to answer questions.",
        before_model_callback=debug_and_inject_context
    )

    runner = Runner(
        app_name="simple_debug_app",
        agent=agent,
        session_service=InMemorySessionService()
    )

    user_id = "user123"
    session_id = "session456"

    await runner.session_service.create_session(
        app_name="simple_debug_app",
        user_id=user_id,
        session_id=session_id,
        state={
            "location": "San Francisco",
            "membership_level": "premium"
        }
    )

    print("Type 'exit' to quit\n")

    while True:
        query = input("👤 YOU: ").strip()

        if query.lower() == 'exit':
            break

        if query:
            await run_turn(runner, user_id, session_id, query)


if __name__ == "__main__":
    asyncio.run(main())

More from this blog

Juan C Olamendy

122 posts

🤖 Talk about AI/ML · AI-preneur 🛠️ Build AI tools 🚀 Share my journey 𓀙 🔗 pixela.io · 🛍️ shoppingbot.ai