Skip to content
abhinavbhatia
Sheet 02 / 05 — Blogs← Archive

Production-ready agents, Part 2.5: Inside ToolContext, the object every tool receives

Every ADK tool can ask for one extra parameter that the model never sees: tool_context. This part opens it up from the ground up: how ADK finds and fills it, what ToolContext | None = None means, what {findings?} means in an instruction, what is inside, the difference between session state and the other kinds of state, how a tool reaches Memory Bank through it, and the usage patterns that keep credentials and user identity safe.

Part 2 built the marketing agent’s tools. Each one had a parameter that the model never filled in: tool_context. Part 1 gave it one short section, and since then it has turned up in almost every part: Part 3’s guards read the agent’s name from it, Part 7’s supply tool reads Sam’s Databricks token from it, Part 8 writes preferences through it, and Part 11.5 reads a time budget from it.

That is not an accident. tool_context is the line between what the model decides and what code decides. The model chooses a tool and writes its arguments. Everything that must not depend on the model (who the user is, which credential to use, what the session remembers) arrives through tool_context, filled in by code. If you understand this one object well, a lot of production safety follows. So this part gives it the space it deserves.

I checked every behaviour in this part against the ADK Python source and by running small experiments on ADK Python 2.13.0, with a stand-in model that returns fixed tool calls (no API key, no network). Where I quote an output, it is the real output.

Where are we in the architecture?

This part adds no new box. It opens up the object ADK passes to every tool, starting with the marketing agent: tool_context, with its state and memory. Part 1.25 walks through the whole picture, layer by layer.

ACME ARCHITECTURE · PART 2.5 LOCAL FIRST: ADK WEB · AGENT RUNTIME IN PART 11 each agent is its own engine with its own identity Shopper Acme's customers P11.75 GE for CX customer chat, voice, reps P11.75 Legal edition industry skills, connectors P11.75 User Maria in marketing P1 Gemini Enterprise where people use agents P12 Other front doors own app (AG-UI), Chat, Slack P12.5 Plugins + Model Armor · checks around every step P9 Router agent Workflow: extract, route, write P1 Supply agent Databricks specialist P3 Marketing agent BigQuery specialist P2 Code sandbox runs generated code P3.5 Skills versioned in Agent Registry P4 Sessions + Memory Bank what it remembers about each user P8 AGENT GATEWAY · EGRESS (FORWARD PROXY) · P12 Gemini models the reasoning engine P1 Other models Model Garden P13 Knowledge RAG and search P14 BigQuery marketing data P2 Databricks supply data P3 Market watch managed agent, public web P1.75 Agent Identity one per agent P7 Auth manager acts for the user P7 Secret Manager no secrets in code P11 Human approval for the one write P9 Cloud Build CI on every change P11 Eval gate blocks a worse agent P10 Cloud Trace one per question P11 Cloud Logging redacted events P11 Agent Analytics BigQuery plugin P9 IDENTITY AND SAFETY SHIPPING MODELS, KNOWLEDGE, DATA OBSERVABILITY deploy this part earlier parts later parts Pn = the part that adds it

where Part 2.5 sits in the Acme architecture: highlighted boxes are the subject of this part, solid boxes come from earlier parts, faded boxes arrive later

What do I need to know first?

Four pieces of Python, each in one line. The Python basics prerequisite covers them more slowly.

  • A parameter is a named input of a function: in def get_spend(brand), brand is a parameter.
  • A type hint says what kind of value a parameter expects: brand: str means a string. Python does not enforce it, but tools (and ADK) can read it.
  • A default value makes a parameter optional: in def f(x=None), calling f() sets x to None.
  • None is Python’s value for “nothing here”.

And one idea from software design. Dependency injection means a framework passes your function the things it needs, instead of your function going to fetch them. ADK does this for tool_context: you ask for it in the signature, and ADK hands it over.

What is ToolContext?

ToolContext is an object that ADK builds for each tool call and passes to your tool. It lets the tool see and change what is going on around the conversation: the session’s state, who the user is, which agent is running, files saved as artifacts, the user’s long-term memory, and requests to the person such as a login or an approval. The model writes the arguments; ADK writes the context.

Start with a restaurant. You tell the waiter “the risotto, please”. That is all you choose. Before the order reaches the kitchen, the waiter’s ticket adds things you never wrote: the table number, the allergy note from when you sat down, and which bill it goes on. The chef reads both. You never see the ticket, and you could not change the table number by asking nicely.

Map it across: you are the model, “the risotto” is the arguments, the waiter is ADK, the ticket is tool_context, and the chef is your tool. Where people get tripped up is thinking the model passes tool_context along with the arguments, as if the diner filled in the ticket. It does not; code does, every time.

How does this apply to Acme? Here is one call of the marketing agent’s get_spend tool, from the inside:

Gemini (the model) function_call: get_spend(brand="Acme Pure") ADK adds tool_context=<one fresh object per call> Your tool get_spend(brand, tool_context) tool_context.state ["user:home_region"] = "Western Europe" read and write Event actions.state_delta = {"user:home_region": ...} saved with the session Back to the model function_response: {"spend_eur": 412300} never tool_context The model chooses the tool and fills in brand. Code fills in tool_context. The model never sees tool_context: it is not in the tool's schema, and the tool returns only data. That line, model-filled versus code-filled, is where credentials and user identity stay safe.

one tool call from the inside: the model writes only the arguments; ADK builds a fresh tool_context and passes it in; the tool reads and writes state through it; ADK records the writes as a state delta on the event and saves them with the session; the model sees only the result

Read it left to right, then back:

  1. Maria asks “What did we spend on Acme Pure last month?”. Gemini decides to call get_spend and writes one argument: brand="Acme Pure".
  2. ADK builds a fresh ToolContext for this call and passes it in as tool_context.
  3. The tool reads or writes state through it, for example remembering Maria’s region.
  4. ADK records every state write as a state delta (the list of changes) on the event for this step, and the session service saves it with the session.
  5. The model receives only what the tool returned: rows and numbers, never tool_context.

A note on names. In ADK 2.x, ToolContext and CallbackContext are both names for one class, Context; the ADK source literally says ToolContext = Context. The ADK context documentation recommends Context for new code and keeps the old names for compatibility. Acme’s build, like most code you will read today, still writes ToolContext, so I use that name in this part. Everything here works the same with Context.

How does ADK know which parameter to fill?

ADK looks for the type hint first, then the name tool_context, and hides whichever it finds from the model.

Think of a paper application form with a box marked “For office use only”. The applicant fills in name and address; the clerk fills in the reference number and the date stamp. Now imagine the copy handed to applicants is printed without that box at all. They cannot write in it, because for them it does not exist. A tool’s schema (the description of its inputs that the model reads) is the applicant’s copy, and tool_context is the office box that ADK leaves off it.

How does this apply to Acme? ADK inspects your function’s signature before the first call. It looks for a parameter whose type hint is Context or one of its aliases, such as ToolContext. If there is none, it falls back to a parameter literally named tool_context. Whichever it finds, it leaves that parameter out of the tool’s schema, the description the model sees.

I checked this with three versions of the same tool:

def get_spend(brand: str, tool_context: ToolContext | None = None) -> dict: ...
def get_spend2(brand: str, ctx: ToolContext) -> dict: ...      # another name, same type
def get_spend3(brand: str, tool_context) -> dict: ...          # no type hint at all

ADK built the same schema for all three. This is the real output:

get_spend  declared params: {'properties': {'brand': {'title': 'Brand', 'type': 'string'}}, 'required': ['brand'], ...}
get_spend2 declared params: {'properties': {'brand': {'title': 'Brand', 'type': 'string'}}, 'required': ['brand'], ...}
get_spend3 declared params: {'properties': {'brand': {'title': 'Brand', 'type': 'string'}}, 'required': ['brand'], ...}

Only brand is visible to the model. That is the property everything else in this part relies on: the model cannot see, invent or overwrite tool_context. If a prompt injection tells the model “use the admin token”, there is no argument through which it could pass one.

My advice: always write the type hint and use the name tool_context. The type hint is what ADK looks for first and what your editor understands. The name is the convention every reader expects, and it still works if someone deletes the hint.

What does ToolContext | None = None mean?

It means “a ToolContext, or nothing, and nothing if you do not pass one”. ADK always passes one; the None is there so you can call the tool yourself.

The same idea on a function with no ADK in it:

def greet(name: str, title: str | None = None) -> str:
    return f"Hello {title} {name}" if title else f"Hello {name}"

greet("Maria", "Dr")   # "Hello Dr Maria"
greet("Maria")         # "Hello Maria": title defaulted to None, and the function coped

title may be a string or None, and if the caller leaves it out it is None. The function’s job is to behave sensibly in both cases. Swap title for tool_context and you have the Acme signature: ADK is the caller that always passes it, and a unit test is the caller that leaves it out.

How does this apply to Acme? This is the part of the signature people ask about most. Other languages write the same idea with a question mark, for example ToolContext? in Kotlin or toolContext?: ToolContext in TypeScript. Python spells it out:

def get_spend(brand: str, tool_context: ToolContext | None = None) -> dict: tool_context the parameter name ADK also finds it by this name : ToolContext the type hint ADK finds the context by this type | None or nothing Python 3.10 union, like ToolContext? = None the default so the caller may leave it out In production ADK always passes a real ToolContext. None only happens when you call the tool yourself, for example in a unit test: get_spend("Acme Pure") works with no ADK, no session. The price: the tool must cope with None, e.g. return "not_authorized" instead of crashing.

reading tool_context: ToolContext | None = None from left to right: the parameter name, the type hint, the bar meaning or, None meaning no value, and the default that makes the argument optional, so a unit test can call the tool without ADK

Piece by piece:

  • tool_context is the parameter name.
  • : ToolContext is the type hint: “this is a ToolContext”.
  • | None means “or None”. The bar is Python’s way of writing a union type, “either this or that”, added in Python 3.10 by PEP 604. The older spelling, Optional[ToolContext], means the same thing. ADK recognises both.
  • = None is the default value, so a caller may leave the argument out entirely.

Why write it this way, when ADK always passes a real ToolContext in production? For tests. A unit test can then call the tool directly, with no ADK, no session and no model. An illustrative test in the style of Part 2’s:

def test_get_spend_rejects_unknown_brand():
    result = get_spend("Not A Brand")        # no tool_context: it defaults to None
    assert result["status"] == "error"

With a required parameter, that test would fail with a TypeError before reaching your logic. I confirmed the optional version: get_spend("Acme Pure") runs and returns, with no ADK involved.

The price is that the tool must cope with None. Part 7’s supply tool shows the pattern:

TOKEN_KEY = "temp:databricks_token"

def _token(tool_context: ToolContext | None) -> str:
    return (tool_context.state.get(TOKEN_KEY) if tool_context else "") or ""

If there is no context, there is no token, and the query engine refuses with not_authorized: the user has not connected Databricks yet. It does not crash, and it does not fall back to some other credential.

My view: make tool_context optional when the tool has useful logic that can be tested without a session, such as validation, SQL building or formatting, and make the None path a safe refusal. Make it required when the tool means nothing without state, and test it with a small fake object instead. Both are fine. What is not fine is an optional context whose None path quietly does something less safe, for example reading a shared token from an environment variable.

A quick test to decide, in order:

1. Does the tool do useful work without state (validation, SQL building, formatting)?
     yes -> ToolContext | None = None, and the None path refuses safely
2. Does the tool mean nothing without state or identity (it only reads a token, writes a preference)?
     yes -> tool_context: ToolContext, required, tested with a small fake object
3. Would the None path ever use a different, shared or stronger credential?
     yes -> stop: make it refuse instead, whichever signature you chose

What is inside ToolContext?

Six groups: who is asking, what the session remembers, how to steer the run, where files go, the user’s long-term memory, and a way to ask the person.

Picture a courier’s job sheet for one delivery. It names the customer and the job number (who and where). It carries notes from earlier in the round, such as “van 3 is parked on Mill Lane” (state). It has a radio button to tell dispatch “stop the round, I’m done” (actions). Anything too big to carry goes into a parcel locker, and the sheet just says which locker (artifacts). There is the customer’s file, “prefers the side door”, which the courier looks up rather than remembers (memory). And there is the doorbell, for when a signature is needed (ask the person). One sheet per delivery, written by the office, never by the customer.

How does this apply to Acme? The object has many fields, and they fall into the same six groups:

Who and where user_id, agent_name, invocation_id, function_call_id used for: logs, traces, idempotency (Part 8) State tool_context.state[...] four scopes by prefix used for: preferences, temp values, tokens Actions actions.escalate, skip_summarization, transfer used for: steer the loop (Part 1.5) Artifacts save_artifact, load_artifact, list_artifacts used for: big files kept out of the prompt Memory search_memory(query), add_memory, add_session_to_memory used for: Memory Bank, per user (Part 8) Ask the person request_credential, request_confirmation used for: login (Part 7), approval (Part 9) Blue = the two this part spends most time on. One object per tool call, built by ADK, never by the model.

what is inside tool_context, in six groups: who and where (user, agent, invocation and call ids), state, actions that steer the run, artifacts for files, memory search, and asking the person for a login or a confirmation

With one Acme use for each:

  • Who and where. user_id ("u-maria"), agent_name ("marketing"), invocation_id (one per question Maria asks) and function_call_id (one per tool call, so two parallel calls can be told apart). Part 3’s guards log agent_name; Part 8 uses invocation_id as an idempotency key so a retried write is not applied twice.
  • State. tool_context.state, the session’s key-value store, read and written like a dictionary. The next section covers it in detail.
  • Actions. tool_context.actions lets a tool steer the run. escalate = True leaves a loop (Part 1.5). skip_summarization = True tells ADK not to send the tool’s result back to the model for a summary, so the result itself becomes the answer. Writes to state show up here too, as actions.state_delta.
  • Artifacts. save_artifact(filename, part) stores a file, such as a CSV of 50,000 rows, in the artifact service and returns its version number; load_artifact and list_artifacts read them back. Large results belong here, not in the model’s context. The Runner must be given an artifact service, or the call raises Artifact service is not initialized.
  • Memory. search_memory(query) searches the current user’s long-term memory; add_memory and add_session_to_memory write to it. A section below covers Memory Bank.
  • Ask the person. request_credential starts a login flow (Part 7), and request_confirmation pauses the tool until a person approves (Part 9).

What is the difference between session state and the other kinds of state?

This is the most important distinction in this part, so let me build it up slowly. The bottom line first: there is one dictionary, but the prefix on each key decides how long the value lives and who else can see it. “Session state” is only the keys with no prefix.

Start with an office. During one phone call you scribble a number on a sticky note and bin it when you hang up. For a whole meeting you keep notes on a pad that stays in that meeting room. Your own notebook goes with you to every meeting, but nobody else reads it. And the noticeboard by the lifts is read by everyone in the building. Same pen, four very different places to write.

Map it across: the sticky note is temp:, the meeting pad is an unprefixed (session) key, your notebook is user:, and the noticeboard is app:. The common mistake is calling all of tool_context.state “session state” and assuming it all lives and dies with one conversation; two of the four scopes outlive it, and one is gone before the next turn.

How does this apply to Acme?

State is a small key-value store that ADK keeps with each session. A session is one conversation: Maria opening a chat and asking several questions is one session; coming back tomorrow and starting a new chat is a second session. ADK gives you one dictionary, tool_context.state, but the prefix on the key decides where the value is stored and who can see it:

Prefix   Example key                Lives for                     Shared with
(none)   last_region                this session (conversation)   nobody else
user:    user:home_region           every session of this user    this user's other chats
app:     app:fiscal_year_start      the whole app                 every user of the app
temp:    temp:raw_rows              this invocation only          nobody; never saved

So “session state” means the unprefixed keys: they belong to one conversation and disappear from view when Maria starts a new one. The other three are not really “session” state at all, even though you reach them through the session’s dictionary. ADK’s session service stores user: and app: values separately and merges them in when a session loads.

temp: deserves a precise definition. An invocation is everything that happens between Maria sending one message and the agent finishing its answer: model calls, tool calls, sub-agents. The ADK state documentation says temp: values are discarded when the invocation completes, and that sub-agents in the same invocation share them. That is why Part 7 keeps Sam’s Databricks token in temp:: it lives for one question and is never written to the session store. It is also why the supply agent has to copy the token into its own temp: state: an A2A call to another service starts a separate invocation there.

Words are easy to misread here, so I ran it. Maria’s tool, in her first conversation s1, writes four keys:

def save_prefs(region: str, tool_context: ToolContext) -> dict:
    tool_context.state["last_region"] = region             # session
    tool_context.state["user:home_region"] = region        # this user, every session
    tool_context.state["app:fiscal_year_start"] = "April"  # every user
    tool_context.state["temp:raw_rows"] = 1234             # this invocation only
    return {"saved": True, "delta": dict(tool_context.actions.state_delta)}

Then another tool reads the same four keys back in four places: the next turn of s1, a new conversation s2 for Maria, and a conversation s3 for Sam. This is the real output:

save   (maria, s1): {'saved': True, 'delta': {'last_region': 'Western Europe', 'user:home_region': 'Western Europe',
                     'app:fiscal_year_start': 'April', 'temp:raw_rows': 1234}}
recall (maria, s1): {'last_region': 'Western Europe', 'user:home_region': 'Western Europe', 'app:fiscal_year_start': 'April', 'temp:raw_rows': None}
recall (maria, s2): {'last_region': None, 'user:home_region': 'Western Europe', 'app:fiscal_year_start': 'April', 'temp:raw_rows': None}
recall (sam,   s3): {'last_region': None, 'user:home_region': None, 'app:fiscal_year_start': 'April', 'temp:raw_rows': None}
saved session state s1: {'last_region': 'Western Europe', 'app:fiscal_year_start': 'April', 'user:home_region': 'Western Europe'}

As a picture, including the memory search from the next section:

WRITTEN BY MARIA'S TOOL IN CONVERSATION s1, THEN READ BACK same call next turn Maria, new Sam (s1) (s1) (s2) (s3) temp:raw_rows one invocation 1234 None None None last_region one conversation W. Europe W. Europe None None user:home_region one user, all chats W. Europe W. Europe W. Europe None app:fiscal_year_start every user April April April April memory search one user, all chats n/a n/a 1 match 0 matches Blue = the value is visible there. Memory is not state: it is searched, not looked up by key. The memory row searched Maria's saved conversation for "Western Europe".

the same four keys read back in four places, from a real run on ADK Python 2.13.0: temp is gone by the next turn, the unprefixed session key stays in Maria's first conversation, the user key follows Maria into a new conversation but not to Sam, and the app key is seen by everyone

Four lessons from those lines:

  • temp: is in the delta but never saved. The tool saw it, and it appears in actions.state_delta, but the saved session state has no temp:raw_rows, and the next turn reads None.
  • Unprefixed means this conversation only. Maria’s new conversation s2 cannot see last_region.
  • user: follows the user, not the conversation. Maria sees user:home_region in s2; Sam never does.
  • app: is shared with everyone. Put nothing personal in it. If someone writes app:last_region by mistake, Sam’s agent starts filtering by Maria’s region.

Two rules about writing state, both from Part 8. Write through tool_context.state (or an event’s state_delta), never by changing a session object you loaded yourself, because only the delta path gets saved. And keep values small: state is stored with every session and loaded on every turn, so a 50,000-row result belongs in an artifact, with only its file name in state.

To pick a prefix, start with the shortest life and the smallest audience, and move down only when you must:

1. Is it needed only while answering this one message (a token, rows passed between tools)?   temp:
2. Is it needed for the rest of this conversation only (the region we are discussing)?        no prefix
3. Must it follow this user into tomorrow's conversations (home region, currency)?            user:
4. Is it the same for every user, and harmless if anyone sees it (fiscal year start)?         app:
Anything personal never goes in app:. Anything secret never goes beyond temp:.

Session events, state, memory and artifacts: four different things

People often blur these. A GP’s surgery keeps the same four kinds of thing: the record of today’s consultation, in order (events); fields on your chart such as blood group, read by exact name (state); what the practice knows about you over years, found by looking through your history (memory); and X-rays, filed separately and pulled out only when needed (artifacts). Here they are side by side for Maria:

Session events  everything that happened in one chat, in order       "Maria asked X; get_spend returned Y"
State           small named values, looked up by exact key           user:home_region = "Western Europe"
Memory          facts about one user, found by search, across chats  "Usually asks about Western Europe"
Artifacts       files, versioned, kept out of the prompt             spend_by_week.csv, version 2

The important split is between state and memory. State is exact: code reads user:home_region and gets one value, or nothing. Memory is fuzzy: you search with a question and get back the facts that seem relevant. Part 8’s rule of thumb: if code makes a decision with it, use user: state; if it is a preference the model should take into account, use memory.

What does {findings?} mean in an instruction?

{findings} is a blank in the instruction that ADK fills from state before each model call; the ? says “if there is nothing to fill it with, leave it empty instead of failing”.

It is the mail merge you may have used for letters. The template says “Dear {first_name}, your order {order_id} has shipped”, and each row of a spreadsheet fills the blanks. If a row has no order_id, you want one of two behaviours: stop, because a letter with a missing order number is a mistake, or carry on with the blank empty, because the field is genuinely optional. The ? is how you choose, per blank.

How does this apply to Acme? This is a different question mark from ToolContext | None, and it belongs to state, so it fits here. An agent’s instruction can contain placeholders in curly braces. Before every model call, ADK replaces each one with a value from the session’s state. This is called instruction templating. The question mark after the name makes the placeholder optional.

Here is a writer agent in the style of Acme’s, where earlier steps have put the specialists’ findings and Maria’s region into state:

writer = Agent(
    name="writer",
    model="gemini-flash-latest",
    instruction="Write one answer for {user:home_region}. Use only these findings: {findings?}",
)

I ran this on ADK Python 2.13.0 with the stand-in model, and captured the instruction the model actually received in each case:

1. findings and user:home_region both in state
   model sees: "Write one answer for Western Europe. Use only these findings: OTIF 89.18"
2. findings missing, placeholder written {findings?}
   model sees: "... Use only these findings: "            (replaced with an empty string)
3. findings missing, placeholder written {findings}  (no question mark)
   the run fails: KeyError: Context variable not found: `findings` in agent 'writer'.
4. braces that are not a state name, e.g. {"a": 1} or {not a key}
   left exactly as written, so JSON examples in an instruction are safe
5. instruction given as a function instead of a string
   "{findings?}" reaches the model literally: ADK does not fill in placeholders for you

So the rules are:

  • {name} puts the value of state key name into the instruction, and fails the run if the key is missing. Use it when a missing value means a bug you want to see.
  • {name?} does the same, but a missing key becomes an empty string. Use it when the value legitimately may not exist yet, such as the critic’s {draft?} in Part 6.5 on the first pass.
  • Prefixes work: {user:home_region}, {app:fiscal_year_start}, {temp:...}.
  • {artifact.notes.txt} inserts the text of an artifact instead of a state value, with the same optional ?.
  • A function instruction (an InstructionProvider) skips all of this. If you build the instruction in code, call inject_session_state(template, readonly_context) yourself, or format the values in directly.

Placeholders are usually filled by output_key. Set output_key="findings" on an agent and ADK saves that agent’s final text into state under findings. In the run above, an agent with output_key="draft" left {'draft': 'Spend fell 12%.'} in state, ready for a later agent’s {draft?}. That pairing, one agent’s output_key feeding the next agent’s placeholder, is how the sequential and loop topologies in Part 6.5 pass work along without a tool in between.

One warning that ties back to the rest of this part. A placeholder puts the value into the prompt, where the model can see and repeat it. tool_context.state["temp:databricks_token"] is safe because only code reads it; {temp:databricks_token} in an instruction would hand Sam’s token straight to the model. Template only values you would be happy for the model to quote, and remember that values that came from data, such as findings, are untrusted text inside your instruction.

How does a tool use Memory Bank through ToolContext?

A tool searches memory through tool_context, and the search is always scoped to the current user; the tool cannot ask about anyone else.

A library is a good picture. You hand over your library card and ask “what was that book on sourdough I borrowed last spring?”. The librarian searches your borrowing history, by topic rather than exact title, and finds it. Ask about your neighbour’s history and the answer is no: the card decides whose records are searched, not the question. In ADK, the card is the user_id that tool_context carries, the librarian is the memory service, and the borrowing history is the user’s memories.

How does this apply to Acme?

Memory Bank is the managed memory service on Agent Platform. Part 8 explains it fully: a model extracts facts worth keeping from conversations, merges them with what it already knows about the user, and later finds relevant ones by similarity search (matching meaning rather than exact words).

A tool never talks to Memory Bank directly. You give the Runner a memory service once, and tool_context reaches it for you:

from google.adk.memory import VertexAiMemoryBankService

memory = VertexAiMemoryBankService(project=PROJECT, location=REGION, agent_engine_id=ENGINE_ID)
runner = Runner(app_name="acme", agent=agent, session_service=sessions, memory_service=memory)

Inside a tool, three methods matter:

  • await tool_context.search_memory(query) searches the memories of the current user in the current app. The ADK source passes app_name and user_id from the invocation; the tool cannot choose another user, which is exactly what you want.
  • await tool_context.add_session_to_memory() sends the whole current session to the memory service, which for Memory Bank means “extract facts from this conversation”.
  • await tool_context.add_memory(memories=[...]) writes explicit memory items for this user.

Locally you can use InMemoryMemoryService, which matches keywords instead of meaning, and switch to VertexAiMemoryBankService in production without changing the tool. I ran the in-memory version: after Maria’s first conversation was added to memory, a tool in her new conversation searched for “Western Europe”, and a tool in Sam’s conversation ran the same search:

memory (maria, s2): {'n': 1, 'first': 'I look after Western Europe'}
memory (sam,   s3): {'n': 0, 'first': None}

Same tool, same query, different user: the memory belongs to Maria, so only Maria’s search finds it.

When should a tool search memory itself? Acme’s router does not; Part 8 uses PreloadMemoryTool, which searches memory in code before every model call, so a preference never depends on the model remembering to look. Searching from inside a tool makes sense when the tool’s own logic needs it. For example, a report tool might look up “how does this user like reports formatted?” before building a CSV. Even then, if the answer drives a hard decision (which region to filter by), prefer user: state, which gives an exact value, over memory, which gives a best guess.

The same advice as a short decision guide, cheapest option first:

1. Does code make a hard decision with it (which region to filter by)?   user: state, read by key
2. Should the model simply take it into account on every turn?            PreloadMemoryTool (Part 8)
3. Does one tool's own logic need it, only sometimes?                     tool_context.search_memory

What are the usage patterns?

Every pattern below is the same rule applied: anything the model must not decide or see travels through tool_context, and the tool returns only data. Part 1 introduced that split in one section; these are the forms it takes in Acme’s build, from Part 3’s guards to Part 11.5’s time budget.

Here are the patterns I use, each with the Acme line that shows it.

1. Credentials come from state, never from arguments. The model cannot see tool_context, so a token read from it cannot leak through the model or be swapped by a prompt (Part 7):

token = tool_context.state.get("temp:databricks_token") if tool_context else None

2. Hand intermediate values to the next tool with temp:. One tool fetches rows and another formats them, in the same question, without passing the rows through the model:

tool_context.state["temp:last_rows"] = rows          # tool A
rows = tool_context.state.get("temp:last_rows", [])  # tool B, same invocation

3. Remember a user’s setting with user:. Maria says “always show euros”, and a tool records it for every future conversation:

tool_context.state["user:currency"] = "EUR"

4. Tag logs and writes with the IDs. Every log line and every write carries tool_context.invocation_id, so one question can be traced end to end and a retried write is recognised as a repeat (Parts 8 and 11).

5. Steer the run with actions. tool_context.actions.escalate = True ends a loop early (Part 1.5); skip_summarization = True returns a tool’s result as the answer without another model call.

6. Keep big results out of the prompt with artifacts. Save the full table, return a summary and the file name:

version = await tool_context.save_artifact("spend_by_week.csv", csv_part)
return {"status": "ok", "rows": 52, "file": "spend_by_week.csv", "version": version}

7. Ask the person, from inside the tool. request_credential for a login (Part 7) and request_confirmation before a write (Part 9). The tool pauses; ADK resumes it with the answer.

When a new value turns up and you are unsure where it goes, run it through this guide:

Value                                     Goes in                  Why
A credential or token                     temp: state              code-only, never saved (Part 7)
Rows passed from one tool to the next     temp: state              same question only
A setting that drives code                user: state              exact, follows the user
A soft preference for the model           memory                   searched, taken into account (Part 8)
A large table or file                     artifact                 kept out of the prompt and state
Something the model must reason over      the tool's return value  the only part of a call the model sees

And the anti-patterns, each of which I have seen in real code:

Token in unprefixed or user: state        saved with the session; leaks into storage and exports
Returning tool_context or state in a result   the model now sees everything you kept from it
Per-user data under app:                  every user sees it
Relying on temp: in the next turn         it is gone; the run reads None
Changing a loaded session object directly not saved; only the state delta path is
50,000 rows in state                      loaded on every turn; use an artifact

One honest gap: ADK does not stop you from making any of these mistakes. State keys are plain strings, a typo such as usr:currency silently creates an unprefixed session key, and nothing warns you. In Acme’s build, the keys that matter are constants in one module and a unit test reads them back after a run, the same way the experiment above does. Until frameworks check scopes for you, that test is the guard.

Try it

You can run the scope experiment on your own machine with no Google Cloud project. Install ADK (pip install google-adk), then put a stand-in model behind an agent so that each turn calls the tool you choose:

class Fake(BaseLlm):
    model: str = "fake"
    script: list = []
    async def generate_content_async(self, llm_request, stream=False):
        last = llm_request.contents[-1]
        if last.parts and last.parts[0].function_response:   # tool done: finish the turn
            yield LlmResponse(content=types.Content(role="model", parts=[types.Part(text="done")]))
            return
        name, args = self.script.pop(0)                       # otherwise call the next scripted tool
        yield LlmResponse(content=types.Content(role="model", parts=[
            types.Part(function_call=types.FunctionCall(name=name, args=args))]))

Give a Runner an InMemorySessionService and an InMemoryMemoryService, create sessions s1 and s2 for u-maria and s3 for u-sam, and script one save_prefs call followed by recall calls in each session. Your output should match the table above. Then change one prefix at a time and predict the result before you run it; that is the fastest way I know to make the scopes stick.

What can go wrong

  • A secret is saved. A token written without temp: ends up in the session store, in exports and possibly in memory. Keep tokens in temp: only, and test that the saved session has none.
  • A value leaks between users. Per-user data under app: is visible to everyone. Review every app: key.
  • A value disappears. Code expects a temp: value in the next turn, or an unprefixed value in a new conversation, and reads None. Pick the prefix from how long the value must live.
  • The None path is unsafe. An optional tool_context whose None branch falls back to a shared credential turns a test convenience into a security hole. The None path should refuse.
  • The model sees the context. A tool returns dict(tool_context.state) for debugging, and the token goes with it. Return only the data the model needs.
  • No service configured. save_artifact or search_memory raises because the Runner has no artifact or memory service. Configure both in every environment, including tests that use them.

What code did we write in this part?

Every part ends with the same map of Acme’s build: which files exist so far, and the code this part added, explained line by line. The map grows with each part.

This part adds no code to Acme’s build. It opens up tool_context, the object ADK passes to every tool, and its snippets are small experiments and illustrations of state scopes, instruction placeholders, memory search and artifacts; the one build snippet it quotes, the supply tool’s _token helper, arrives in Part 7.

Where does it live?

marketing/                        the marketing specialist, on BigQuery
  app/agent.py                    P1,2        the marketing Agent: instruction, model with retries, tools
  app/tools.py                    P1,2        three narrow tools: measures, unserved demand, SQL fallback
  tests/unit/test_marketing.py    P2          unit tests for the tools, with a fake context and database
shared/                           code all three services share
  registry.py                     P2          reads semantic.yaml: measure names, computable measures
  routing.py                      P1          classify: picks the specialist in code, no model
  semantic.yaml                   P1,2        registry of measures, owners and published formulas

+ added in this part   ~ changed in this part   Pn = the parts that touched the file

The post’s honest gap still stands: ADK does not check state prefixes, so a unit test that reads the important keys back after a run is the guard.

What did we learn?

  • tool_context is filled in by ADK, not by the model, and is left out of the tool’s schema; that makes it the safe channel for identity, credentials and session data.
  • ADK finds it by its type hint (ToolContext or Context) and falls back to the name tool_context; write both.
  • ToolContext | None = None means “a ToolContext, or nothing, defaulting to nothing”: it lets unit tests call the tool directly, and the None path must be a safe refusal.
  • Session state is the unprefixed keys of one conversation. user: follows a user across conversations, app: is shared by everyone, and temp: lasts one invocation and is never saved.
  • {findings?} in an instruction is filled from state before each model call; without the ? a missing key fails the run, and anything templated is visible to the model, so never template a secret.
  • Memory is not state: it is searched rather than looked up, and search_memory is always scoped to the current user. Memory Bank sits behind the same calls in production.
  • Use artifacts for big results, actions to steer the run, and the IDs for tracing and idempotency.

What comes next?

Part 3 adds the supply agent and the guards around its data, and those guards are callbacks that receive the same context object. For state and memory in depth, including Memory Bank topics and compaction, see Part 8.

Sources

Opinions are my own. Every fact in this post comes from the public documentation and source code linked above, and from the experiments described in it. Acme Consumer Brands and its data are fictional.