Skip to content

Add metadata field to LLMRequest for simplified user-specific data handling in callbacks #2978

@donggyun112

Description

@donggyun112

Is your feature request related to a problem? Please describe.

In multi-user applications, it is often necessary to inject user-specific context into the system prompt on a per-request basis to personalize responses. A common example of this is injecting long-term memory, such as conversation summaries. However, the current methods for accessing this context within callbacks are brittle and lead to boilerplate code.

  • Brittle Context Access: Accessing user context inside callbacks (e.g., before_model_callback) requires repeated parsing of context.session.user_id with null checks, which is error-prone.
  • Multimodal Input Issues: JSON-wrapping inputs to carry metadata breaks when using audio or other multimodal inputs.
  • Unsuitable RunConfig: The RunConfig object is not a suitable context carrier for arbitrary per-request data due to its schema (extra='forbid').

These issues cause boilerplate code, subtle personalization failures, and slow down development.

Proposed solution

We propose adding a canonical, per-request context channel on the input side of the ADK.

  • Add a metadata field to LlmRequest:
    • LlmRequest: metadata: dict[str, Any] = Field(default_factory=dict)
  • Update the Runner methods to accept this metadata:
    • Runner.run_async(..., metadata: dict | None = None)
    • Runner.run_live(..., metadata: dict | None = None)
  • The Runner will merge the provided metadata into the underlying LlmRequest, allowing callbacks to reliably access per-request context:
    • llm_request.metadata.get("user_id")
    • llm_request.metadata.get("memctx_key")

Conceptual API

# In models/llm_request.py
class LlmRequest(BaseModel):
    ...
    metadata: Dict[str, Any] = Field(
        default_factory=dict, 
        description="Per-request context for callbacks/memory"
    )
    
    model_config = ConfigDict(extra='ignore') # Keep ignoring unknown fields

# In Runner
async def run_async(self, ..., metadata: dict | None = None, ...):
    # If provided, merge into request.metadata
    ...

async def run_live(self, ..., metadata: dict | None = None, ...):
    ...

Minimal Reproduction Case

  • Today's Problem: A before_model_callback that needs user_id for memory injection must perform null checks everywhere and fails when a session is missing.
  • Example Scenario:
    1. Define a before_model_callback that requires a user_id.
    2. Call an agent with an audio input (where JSON-wrapped metadata is not possible).
    3. Observation: It's not possible to reliably pass the user_id into the callback without brittle wiring or misusing RunConfig.

Alternatives Considered

  • Parse context.session.user_id in every callback: This is repetitive and brittle.
  • JSON-wrap inputs with metadata: This approach breaks for audio/multimodal inputs and adds unnecessary parsing overhead.
  • Subclass RunConfig: The schema forbids arbitrary fields, and the config object is not designed to be a per-request context carrier.
  • Use LlmResponse.custom_metadata: This is on the output side only and does not solve the input-side context problem.

Backward Compatibility

This is a non-breaking change:

  • The new LlmRequest.metadata field defaults to an empty dictionary ({}).
  • The Runner methods accept an optional metadata parameter; omitting it results in no change to the current behavior.
  • Extra fields remain ignored where applicable (ConfigDict(extra='ignore')).

Security & Privacy

  • The request metadata can carry user-scoped keys (e.g., user_id, memctx_key).
  • Applications should avoid storing sensitive PII in the metadata itself. We recommend using short-TTL keys for any Redis-backed memory contexts.

Performance Impact

  • The performance impact is negligible.
  • It involves an O(1) dictionary merge with no additional network calls.
  • No measurable overhead was observed in our local tests (100+ executions on ADK v1.2.0).

Test Plan (Upstream)

  • Unit: Verify that metadata round-trips correctly into LlmRequest on run_async and run_live.
  • Unit: Ensure before_model_callback can see request.metadata for both normal and streaming flows.
  • Unit: Confirm isolation across concurrent runs (no metadata bleed).
  • Unit: Check that metadata defaults to {} when omitted.
  • Integration: Test a memory injection callback using metadata.memctx_key in a full streaming session.

Open Questions

  • Should metadata be propagated to all nested flows and tools automatically?
  • Should there be any size limits or filtering for the metadata dictionary?

How We Solved It Locally (Workaround)

We have shipped a safe, runtime-only workaround that mirrors this proposal without modifying ADK source code, demonstrating its viability.

  • ContextVar Carrier (app/adk/memctx.py): Uses current_run_metadata to hold context.
  • Web Server Propagation (app/api/custom_adk_web_server.py): Wraps runner methods to set and reset current_run_metadata from request headers.
  • LlmRequest Monkey Patch (app/adk/llm_request_patch.py): Ensures request.metadata exists and merges the ContextVar on LlmRequest construction.
  • Callback Implementation (app/agents/common/dynamic_callback.py): Reads llm_request.metadata['memctx_key'], loads data from Redis, and injects it into the system_instruction.
  • Memory Count Policy (app/agents/prompts/prompt_loader.py): Respects a memory_max_items count from context so that backend-selected items are injected as-is.

Environment

  • Python 3.12+
  • ADK v1.2.0

We are happy to contribute a PR that aligns with the proposal above.

Metadata

Metadata

Assignees

Labels

core[Component] This issue is related to the core interface and implementation

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions