aiml
SearchMy learning
Menu

Lesson 8 of 20Module 2

Save and load files with Artifacts

Store an uploaded ship.toml and versioned reviews as ADK artifacts on disk, load files only when needed, and see which survive a restart.

Prerequisites

An ADK agent with function tools, run through a Runner, and session state scopes such as user:.

By the end, you can

  • Store uploaded and generated files as artifacts instead of message text, with an artifact service on the Runner.
  • Save, version and load artifacts from tools, and let the model load a file only in the turn that needs it.
  • Choose session or user: scope for a file, and handle missing files and unsafe filenames.
Runtime
Python 3.12+, a Gemini API key (or Ollama with gemma4:e2b)
Dependencies
google-adk 2.12.0; litellm 1.102.4 for the Ollama alternative

Lab code, tests and fixtures (ZIP, 37 KB)Quiz check your understanding

Progress is saved in this browser.

A developer attaches a ship.toml and asks the docs assistant to review it before a production deploy. If the file travels as message text, it is stored in the session and sent to the model again on every later turn, and anything the assistant writes back is only chat text. Google ADK Artifacts are named, versioned files kept by an artifact service next to the session. Here the assistant stores the upload as an artifact, reads it only when it needs it, saves its review as a versioned Markdown file, and keeps a checklist in the developer’s user scope. Then a new process opens the same store. In one recorded run on Gemini gemini-3.8-flash, the new session found only the user-scoped file:

When you need this. A developer uploads configuration for the docs assistant to review and wants to retrieve earlier versions of its report. Artifacts store those files outside the conversation; load_artifacts adds their content to the model request when needed.

Original recording. The saved output below uses the earlier fictional shipctl scenario. Current lab instructions and inputs use docuctl; this preserved output is not a recording of those renamed inputs.

Recorded: run 2, a new process and session on the same storeRecorded output (excerpt) · textFull output
# run 2, step 5
> Which files do you have for me? Show me my checklist.
call   load_artifacts {"artifact_names": ["user:checklist.md"]}
listed ['user:checklist.md']
answer I have one file available: **`user:checklist.md`**.  Here is your checklist:  # Production Deploy Checklist  1. **Syntax Check**: Ensure `[service]` is used instead of deprecated `[deploy]` in `ship.toml`. 2. **Secrets Configuration**: Confirm no plaintext credentials exist and all sensitive values 
assistant.py · SaveFilesAsArtifactsPlugin · FileArtifactService · recorded in recorded-runs.json
data flowreturnfailurerun 1: one sessiondeveloperSaveFilesAsArtifactsPluginfile → placeholdership.tomldocs_assistantload_artifacts,save_text_fileplaceholderFileArtifactService, on disksession filesreview.md ×2ship.toml ×1user: fileschecklist.md ×1save uploadsaveloadsave user:run 2: new process, new sessiondocs_assistantlists onlyuser:checklist.mduser: filesInputValidationErrora filename with ../ or a rooted path../../ship.tomldata flowreturnfailurerun 1: one sessiondevelopership.tomlSaveFilesAsArtifactsPluginplaceholderdocs_assistantload, savesaveload../refusedInputValid-ationErrorsession filesreview.md ×2ship.toml ×1save user:user: fileschecklist.md ×1user: filesrun 2: new sessiondocs_assistantlists onlyuser:checklist.md
The plugin turns an attached file into an artifact before the model sees the message, so the file is stored once instead of in every request. Tools save and load through the Runner's artifact service. FileArtifactService writes one folder per version, under the session for plain filenames and under the user for user: names, which is why only the checklist reaches the new session in run 2. Model-chosen filenames that climb out of the store are refused. File and version counts come from the recorded store listing.

Lab setup. Download the complete lab ZIP, extract it and open a terminal in adk-artifacts/. Keep the bundled code, tests and fixtures together. Create the pinned environment before running the commands below:

Prepare the lab environmentTerminal · bash
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt

1. Give the Runner an artifact store

The code reads GOOGLE_API_KEY; DOCS_MODEL=ollama_chat/gemma4:e2b runs it on Gemma through LiteLlm. The file under review has planted problems for the assistant to find:

ship.tomlSource · tomlDownload
[deploy]
name = "docs-api"
build = "make build"

[environments.staging]
cluster = "eu-west-staging"
replicas = 2

[environments.production]
cluster = "eu-west-prod"
replicas = 6

[env.production]
LOG_LEVEL = "info"
DATABASE_URL = "postgres://docs:hunter2@db.internal:5432/docs"
SEARCH_INDEX_URL = "secret://prod-search-index-url"

[hooks]
pre_deploy = "alembic upgrade head"
post_deploy = "make smoke-test"

ADK ships three artifact services. InMemoryArtifactService keeps files in a Python dict and loses them when the process exits; it suits tests. FileArtifactService writes each version to a folder on disk, which suits one host and local development. GcsArtifactService writes to a Cloud Storage bucket for deployments with several replicas. All three implement the same interface, so tools do not change when you switch. This lesson uses FileArtifactService, so a restart is visible on disk.

No artifact service. A Runner without artifact_service raised ValueError while it built the first request, because LoadArtifactsTool lists files there. Pass an artifact service to every Runner whose agent uses artifacts.

2. Save and load files from tools

assistant.pySource · pythonDownload
"""The docs assistant reviews an uploaded ship.toml and saves its reviews as versioned artifacts.

Uploaded files and saved reviews live in an artifact store on disk (FileArtifactService),
not in the conversation history: the model sees a file only in the turn that loads it.
"""
import asyncio
import os
import sys
from pathlib import Path

from google.adk import Agent, Runner
from google.adk.apps.app import App
from google.adk.artifacts import FileArtifactService
from google.adk.errors.input_validation_error import InputValidationError
from google.adk.models.lite_llm import LiteLlm
from google.adk.plugins.save_files_as_artifacts_plugin import SaveFilesAsArtifactsPlugin
from google.adk.sessions import InMemorySessionService
from google.adk.tools import ToolContext
from google.adk.tools.load_artifacts_tool import LoadArtifactsTool
from google.genai import types

HERE = Path(__file__).resolve().parent
# Gemini through ADK's native client; set DOCS_MODEL=ollama_chat/gemma4:e2b to run Gemma locally.
MODEL = os.environ.get("DOCS_MODEL", "gemini-3.8-flash")
STORE = Path(os.environ.get("DOCS_ARTIFACTS", HERE / "artifact-store"))
PAGES = sorted(p.stem for p in (HERE / "docs").glob("*.md"))


def read_page(page: str) -> dict:
    """Read one page of the docuctl manual, such as config, secrets, environments or upgrade-2x."""
    if page not in PAGES:
        return {"error": f"No page {page!r}. Pages: {', '.join(PAGES)}."}
    return {"page": page, "text": (HERE / "docs" / f"{page}.md").read_text(encoding="utf-8")}


async def save_text_file(filename: str, text: str, tool_context: ToolContext) -> dict:
    """Save a Markdown file for the developer. Saving the same filename again creates a new version.
    Prefix the filename with user: to keep it across all of the developer's sessions."""
    part = types.Part.from_bytes(data=text.encode("utf-8"), mime_type="text/markdown")
    try:
        version = await tool_context.save_artifact(filename, part)  # records artifact_delta on the event
    except InputValidationError as error:
        return {"error": str(error)}  # the model can choose a valid name in the same turn
    return {"saved": filename, "version": version}


INSTRUCTION = """You review docuctl configuration files for developers.
When the developer uploads a file, call load_artifacts to read it, then check it against the manual
pages you read with read_page. Name each problem with the line it is on and the fix.
When the developer asks you to save something, call save_text_file with Markdown text.
Keep chat answers under 120 words."""

root_agent = Agent(
    name="docs_assistant",
    model=LiteLlm(model=MODEL) if MODEL.startswith("ollama_chat/") else MODEL,
    instruction=INSTRUCTION,
    tools=[read_page, save_text_file, LoadArtifactsTool()],
    generate_content_config=types.GenerateContentConfig(
        temperature=0.2, thinking_config=types.ThinkingConfig(thinking_level="low")),
)


def make_runner(agent: Agent = root_agent, store: Path = STORE) -> Runner:
    # The plugin turns each uploaded file into an artifact and leaves a placeholder in the message.
    app = App(name="docs", root_agent=agent, plugins=[SaveFilesAsArtifactsPlugin()])
    return Runner(app=app, session_service=InMemorySessionService(),
                  artifact_service=FileArtifactService(root_dir=store))


def upload(path: Path, text: str) -> types.Content:
    """A user message with a file attached, as a chat client would send it."""
    blob = types.Blob(data=path.read_bytes(), mime_type="text/plain", display_name=path.name)
    return types.Content(role="user", parts=[types.Part(text=text), types.Part(inline_data=blob)])


async def ask(runner: Runner, user_id: str, session_id: str, message: types.Content | str) -> list:
    if isinstance(message, str):
        message = types.Content(role="user", parts=[types.Part(text=message)])
    return [e async for e in runner.run_async(user_id=user_id, session_id=session_id, new_message=message)]


async def main(text: str) -> None:
    runner = make_runner()
    session = await runner.session_service.create_session(app_name="docs", user_id="dana")
    for event in await ask(runner, "dana", session.id, upload(HERE / "ship.toml", text)):
        for call in event.get_function_calls():
            print("call", call.name, call.args)
        if event.is_final_response() and event.content:
            print("".join(p.text or "" for p in event.content.parts if not p.thought))
    await runner.close()


if __name__ == "__main__":
    asyncio.run(main(" ".join(sys.argv[1:]) or "Review this ship.toml before I deploy to production."))

Three pieces handle files, and each runs at a different moment:

  • SaveFilesAsArtifactsPlugin runs when a user message arrives. It saves each attached file, named by the blob’s display_name, and replaces it in the message with the placeholder [Uploaded Artifact: "ship.toml"]. It replaces the deprecated RunConfig.save_input_blobs_as_artifacts flag.
  • LoadArtifactsTool lists the available filenames in the model’s instructions. When the model calls load_artifacts, ADK adds the file contents to that one request only.
  • save_text_file is a function tool. tool_context.save_artifact returns the new version number and records an artifact_delta on the tool’s event, which is how the application learns that a file changed.

A plain filename belongs to the current session. A user: prefix stores the file for the user across all of their sessions in this app, the same rule as user: state. The application must supply the Runner’s user ID from its authenticated caller; a model-provided user name is not an identity check.

A filename that climbs out of the store. ../../ship.toml and /etc/hosts raised InputValidationError, and nothing was written. The tool catches InputValidationError and returns an error; ask the model for a filename inside the store.

A file that does not exist. load_artifact returned None, not an error. Check for None in every tool that loads a file, and tell the model which files exist.

The recorded messages:

recorded-runs.json: three failuresRecorded output · textDownload
InputValidationError: Artifact filename '../../ship.toml' must not contain parent traversal.
InputValidationError: Rooted or drive-qualified artifact filename '/etc/hosts' is not permitted; provide a path relative to the storage scope.
ValueError: Artifact service is not initialized.

The model chooses the filename. ADK’s file service rejects parent traversal; save_text_file catches that validation error and returns it as a tool result, so a bad name does not abort the conversation. test_an_invalid_model_filename_returns_a_tool_error runs that path through the Runner and checks that no file was saved. A custom artifact service needs its own path checks.

3. Run two processes against one store

record.py runs four turns in run 1, then starts a new Runner and session on the same folder for run 2.

Terminal commandsTerminal · bash
python record.py
Recorded: run 1 on Gemini, tool calls and first answer linesRecorded output (excerpt) · textFull output
# run 1, step 1 (ship.toml attached)
> Review this ship.toml before I deploy it to production.
call   load_artifacts {"artifact_names": ["ship.toml"]}
call   read_page {"page": "config"}
call   read_page {"page": "environments"}
call   read_page {"page": "secrets"}
call   load_artifacts {"artifact_names": ["ship.toml"]}
call   read_page {"page": "upgrade-2x"}
saved  {'ship.toml': 0}
listed ['ship.toml']
answer Here are the issues found in your `ship.toml`:  - **Line 1 (`[deploy]`)**: Deprecated 1.x syntax. Rename `[deploy]` to `[service]`. - **Line 9 (`[environments.production]`)**: Missing deployment safeguard. Add `approval = true` under `[environments.production]` so deployments require `shipctl approv

# run 1, step 2
> Save that review as review.md.
call   save_text_file {"filename": "review.md", "text": "# ship.toml Review\n\n- **Line 1 (`[deploy]`)**: Deprecated 1.x syntax. Ren
saved  {'review.md': 0}
listed ['review.md', 'ship.toml']
answer I have saved the review as `review.md`.

# run 1, step 3
> We will fix all three problems before the deploy. Add that decision to the review and save it again.
call   save_text_file {"filename": "review.md", "text": "# ship.toml Review\n\n- **Line 1 (`[deploy]`)**: Deprecated 1.x syntax. Ren
saved  {'review.md': 1}
listed ['review.md', 'ship.toml']
answer I have updated and saved `review.md` with the decision to fix all three problems before deploying.

# run 1, step 4
> Save a five-item production deploy checklist for me as user:checklist.md, so I have it next time.
call   save_text_file {"filename": "user:checklist.md", "text": "# Production Deploy Checklist\n\n1. **Syntax Check**: Ensure `[serv
saved  {'user:checklist.md': 0}
listed ['review.md', 'ship.toml', 'user:checklist.md']
answer I have saved the five-item production deploy checklist to `user:checklist.md`.

Read the run in order:

  1. The model called load_artifacts 2 times and read_page 4 times, then named the planted problems: the 1.x [deploy] table, the missing approval = true for production, and a database password in plain text. The plugin had saved the upload as version 0.
  2. Saving the review created review.md version 0.
  3. Saving it again under the same name created version 1. The earlier version is still in the store.
  4. The checklist went to user:checklist.md.
  5. In run 2, the session listed user:checklist.md only. The review and the upload still exist on disk, but they belong to the run 1 session.

A session file after a restart. In run 2, review.md was on disk but not listed. Use user: for files a user needs again, and reuse the session id when the conversation itself continues.

How artifact versions enter the model request

FileArtifactService lays files out by scope and version. After the two runs, the store held:

run-output.txt: the artifact store on disk (session id shortened)Recorded output (excerpt) · textFull output
# artifact store on disk
apps/docs/users/dana/artifacts/checklist.md/versions/0/checklist.md
apps/docs/users/dana/artifacts/checklist.md/versions/0/metadata.json
apps/docs/users/dana/sessions/<session-id>/artifacts/review.md/versions/0/metadata.json
apps/docs/users/dana/sessions/<session-id>/artifacts/review.md/versions/0/review.md
apps/docs/users/dana/sessions/<session-id>/artifacts/review.md/versions/1/metadata.json
apps/docs/users/dana/sessions/<session-id>/artifacts/review.md/versions/1/review.md
apps/docs/users/dana/sessions/<session-id>/artifacts/ship.toml/versions/0/metadata.json
apps/docs/users/dana/sessions/<session-id>/artifacts/ship.toml/versions/0/ship.toml

Each version is a folder with the file and a metadata.json that records its MIME type and creation time. Listing in a session merges the session’s files with the user’s user: files, which is why run 1 saw all three names.

The upload was in the model’s context only when the model asked for it. record.py counted, per turn, the model requests whose contents included the file’s text:

Turn Model requests Requests that contained ship.toml
1 4 2
2 2 0
3 2 0
4 2 0
5 2 0

In turn 1 the file appeared only in the requests after a load_artifacts call. In later turns it was not sent at all, although the session went on. Had the upload stayed inline, it would have been part of the session history and sent with every request.

Test the store without a model

test_assistant.py replays the recorded Gemini turns against a real FileArtifactService in a temporary folder.

Terminal commandsTerminal · bash
python -m unittest -v test_assistant.py
Recorded test outputRecorded output · textDownload
test_a_missing_file_loads_as_none (test_assistant.Artifacts.test_a_missing_file_loads_as_none) ... ok
test_a_parent_traversal_filename_is_refused (test_assistant.Artifacts.test_a_parent_traversal_filename_is_refused) ... ok
test_a_runner_without_an_artifact_service_fails (test_assistant.Artifacts.test_a_runner_without_an_artifact_service_fails) ... /Users/arjunprabhulal/aiml-lanes/envs/venv-agents/lib/python3.12/site-packages/google/adk/models/llm_request.py:306: UserWarning: [EXPERIMENTAL] feature FeatureName.JSON_SCHEMA_FOR_FUNC_DECL is enabled.
  declaration = tool._get_declaration()
ok
test_an_invalid_model_filename_returns_a_tool_error (test_assistant.Artifacts.test_an_invalid_model_filename_returns_a_tool_error) ... ok
test_an_upload_becomes_an_artifact_and_a_placeholder (test_assistant.Artifacts.test_an_upload_becomes_an_artifact_and_a_placeholder) ... ok
test_only_user_scoped_files_reach_a_new_session (test_assistant.Artifacts.test_only_user_scoped_files_reach_a_new_session) ... ok
test_saving_a_filename_again_creates_a_new_version (test_assistant.Artifacts.test_saving_a_filename_again_creates_a_new_version) ... ok
test_the_file_is_in_the_request_only_after_load_artifacts (test_assistant.Artifacts.test_the_file_is_in_the_request_only_after_load_artifacts) ... ok

----------------------------------------------------------------------
Ran 8 tests in 0.344s

OK

All 8 tests pass.

Read the test and recording scripts
test_assistant.pySource · pythonDownload
"""Replay the recorded Gemini turns against a real FileArtifactService in a temporary folder, offline.

Run: python -m unittest -v test_assistant.py
"""
import asyncio
import json
import logging
import tempfile
import unittest
import warnings
from pathlib import Path

warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)

from google.adk import Runner  # noqa: E402
from google.adk.artifacts import FileArtifactService  # noqa: E402
from google.adk.errors.input_validation_error import InputValidationError  # noqa: E402
from google.adk.models.base_llm import BaseLlm  # noqa: E402
from google.adk.models.llm_response import LlmResponse  # noqa: E402
from google.adk.sessions import InMemorySessionService  # noqa: E402
from google.genai import types  # noqa: E402

import assistant  # noqa: E402

HERE = Path(__file__).resolve().parent
RECORD = json.loads((HERE / "recorded-runs.json").read_text(encoding="utf-8"))


class ReplayLlm(BaseLlm):
    turns: list
    requests: list = []

    async def generate_content_async(self, llm_request, stream=False):
        self.requests.append(json.dumps([c.model_dump(mode="json", exclude_none=True) for c in llm_request.contents]))
        yield LlmResponse(content=types.Content.model_validate(self.turns.pop(0)))


class Artifacts(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.store = Path(self.tmp.name)

    def tearDown(self):
        self.tmp.cleanup()

    def replay(self, upto: int):
        """Replay steps 1..upto; a new run number means a new Runner and session on the same store."""
        async def go():
            out, runner, run = [], None, None
            for step in RECORD["steps"][:upto]:
                if step["run"] != run:
                    if runner:
                        await runner.close()
                    model = ReplayLlm(model="replay", turns=[], requests=[])
                    runner = assistant.make_runner(assistant.root_agent.clone(update={"model": model}), self.store)
                    session = await runner.session_service.create_session(app_name="docs", user_id="dana")
                    run = step["run"]
                model.turns[:] = step["model_turns"]
                start = len(model.requests)
                message = assistant.upload(HERE / "ship.toml", step["message"]) if step["kind"] == "upload" else step["message"]
                events = await assistant.ask(runner, "dana", session.id, message)
                keys = await runner.artifact_service.list_artifact_keys(app_name="docs", user_id="dana", session_id=session.id)
                out.append({"requests": model.requests[start:], "events": events, "keys": keys, "session": session.id})
            await runner.close()
            return out
        return asyncio.run(go())

    def test_an_upload_becomes_an_artifact_and_a_placeholder(self):
        step = self.replay(1)[0]
        self.assertEqual(step["keys"], ["ship.toml"])
        self.assertIn('[Uploaded Artifact: \\"ship.toml\\"]', step["requests"][0])
        self.assertNotIn("SEARCH_INDEX_URL", step["requests"][0])  # the file itself is not in the message

    def test_the_file_is_in_the_request_only_after_load_artifacts(self):
        steps = self.replay(5)
        counts = [sum("SEARCH_INDEX_URL" in r for r in s["requests"]) for s in steps]
        self.assertEqual(counts, [s["requests_with_file"] for s in RECORD["steps"]])
        self.assertEqual(counts[1:], [0, 0, 0, 0])

    def test_saving_a_filename_again_creates_a_new_version(self):
        steps = self.replay(3)
        service = FileArtifactService(root_dir=self.store)
        versions = asyncio.run(service.list_versions(app_name="docs", user_id="dana", session_id=steps[0]["session"],
                                                     filename="review.md"))
        self.assertEqual(versions, [0, 1])
        delta = {k: v for e in steps[2]["events"] for k, v in e.actions.artifact_delta.items()}
        self.assertEqual(delta, {"review.md": 1})

    def test_only_user_scoped_files_reach_a_new_session(self):
        steps = self.replay(5)
        self.assertEqual(steps[3]["keys"], ["review.md", "ship.toml", "user:checklist.md"])
        self.assertEqual(steps[4]["keys"], ["user:checklist.md"])  # review.md is still on disk, in session 1

    def test_a_parent_traversal_filename_is_refused(self):
        service = FileArtifactService(root_dir=self.store)
        with self.assertRaisesRegex(InputValidationError, "parent traversal"):
            asyncio.run(service.save_artifact(app_name="docs", user_id="dana", session_id="s",
                                              filename="../../ship.toml", artifact=types.Part(text="x")))

    def test_an_invalid_model_filename_returns_a_tool_error(self):
        model = ReplayLlm(model="replay", requests=[], turns=[
            {"role": "model", "parts": [{"function_call": {"name": "save_text_file",
             "args": {"filename": "../../outside.md", "text": "review"}, "id": "bad-name"}}]},
            {"role": "model", "parts": [{"text": "Choose a filename inside the artifact store."}]},
        ])

        async def go():
            runner = assistant.make_runner(assistant.root_agent.clone(update={"model": model}), self.store)
            session = await runner.session_service.create_session(app_name="docs", user_id="dana")
            try:
                events = await assistant.ask(runner, "dana", session.id, "Save the review.")
                keys = await runner.artifact_service.list_artifact_keys(
                    app_name="docs", user_id="dana", session_id=session.id)
                return events, keys
            finally:
                await runner.close()

        events, keys = asyncio.run(go())
        results = [r.response for event in events for r in event.get_function_responses()]
        self.assertIn("parent traversal", results[0]["error"])
        self.assertEqual(keys, [])
        self.assertTrue(events[-1].is_final_response())

    def test_a_missing_file_loads_as_none(self):
        service = FileArtifactService(root_dir=self.store)
        self.assertIsNone(asyncio.run(service.load_artifact(app_name="docs", user_id="dana", session_id="s",
                                                            filename="review.md")))

    def test_a_runner_without_an_artifact_service_fails(self):
        model = ReplayLlm(model="replay", turns=[], requests=[])
        runner = Runner(app_name="docs", agent=assistant.root_agent.clone(update={"model": model}),
                        session_service=InMemorySessionService(), auto_create_session=True)
        with self.assertRaisesRegex(ValueError, "Artifact service is not initialized"):
            asyncio.run(assistant.ask(runner, "dana", "s", "Save the text 'hello' as note.md."))
        self.assertEqual(model.requests, [])  # it fails while building the first request


if __name__ == "__main__":
    unittest.main()
record.pySource · pythonDownload
"""Record two runs of the docs assistant against one artifact store on disk, on Gemini.

Run from this folder with GOOGLE_API_KEY set: python record.py
Writes recorded-runs.json and run-output.txt. The tests replay the model turns offline.
"""
import asyncio
import json
import shutil
import warnings
from pathlib import Path

warnings.filterwarnings("ignore")

from google.adk import Runner  # noqa: E402
from google.adk.artifacts import FileArtifactService  # noqa: E402
from google.adk.sessions import InMemorySessionService  # noqa: E402
from google.genai import types  # noqa: E402

import assistant  # noqa: E402

HERE = Path(__file__).resolve().parent
STORE = HERE / "artifact-store"
STEPS = [  # (run, message): run 2 is a new process on the same store, with a new session
    (1, "upload", "Review this ship.toml before I deploy it to production."),
    (1, "text", "Save that review as review.md."),
    (1, "text", "We will fix all three problems before the deploy. Add that decision to the review and save it again."),
    (1, "text", "Save a five-item production deploy checklist for me as user:checklist.md, so I have it next time."),
    (2, "text", "Which files do you have for me? Show me my checklist."),
]


def recording_agent(turns: list, requests: list):
    def before(callback_context, llm_request):
        requests.append(json.dumps([c.model_dump(mode="json", exclude_none=True) for c in llm_request.contents]))

    def after(callback_context, llm_response):
        if llm_response.content:
            content = llm_response.content.model_dump(mode="json", exclude_none=True)
            for part in content.get("parts", []):
                part.pop("thought_signature", None)
            turns.append(content)
    return assistant.root_agent.clone(update={"before_model_callback": before, "after_model_callback": after})


def tree(store: Path) -> list[str]:
    return [str(p.relative_to(store)) for p in sorted(store.rglob("*")) if p.is_file()]


async def failures() -> dict:
    out = {}
    service = FileArtifactService(root_dir=STORE)
    # 1. A filename the model chose that walks out of the store is refused.
    for bad in ["../../ship.toml", "/etc/hosts"]:
        try:
            await service.save_artifact(app_name="docs", user_id="dana", session_id="x", filename=bad,
                                        artifact=types.Part(text="x"))
        except Exception as error:  # InputValidationError
            out.setdefault("traversal", []).append(f"{type(error).__name__}: {error}")
    # 2. Loading a file that does not exist returns None, not an error.
    out["missing"] = repr(await service.load_artifact(app_name="docs", user_id="dana", session_id="x",
                                                      filename="review.md"))
    # 3. No artifact service on the Runner: the tool's save_artifact call raises.
    runner = Runner(app_name="docs", agent=assistant.root_agent, session_service=InMemorySessionService(),
                    auto_create_session=True)
    try:
        await assistant.ask(runner, "dana", "no-store", "Save the text 'hello' as note.md.")
    except ValueError as error:
        out["no_service"] = f"{type(error).__name__}: {error}"
    await runner.close()
    return out


async def main() -> None:
    shutil.rmtree(STORE, ignore_errors=True)
    steps, runner, run = [], None, None
    for n, (run_no, kind, text) in enumerate(STEPS, 1):
        if run_no != run:
            if runner:
                await runner.close()
            turns, requests = [], []
            runner, run = assistant.make_runner(recording_agent(turns, requests)), run_no
            session = await runner.session_service.create_session(app_name="docs", user_id="dana")
        start_turns, start_requests = len(turns), len(requests)
        message = assistant.upload(HERE / "ship.toml", text) if kind == "upload" else text
        events = await assistant.ask(runner, "dana", session.id, message)
        answer = next(("".join(p.text or "" for p in e.content.parts if not p.thought).strip()
                       for e in reversed(events) if e.is_final_response() and e.content), "")
        steps.append({
            "step": n, "run": run_no, "kind": kind, "message": text,
            "calls": [{"name": c.name, "args": c.args} for e in events for c in e.get_function_calls()],
            "artifact_delta": {k: v for e in events for k, v in e.actions.artifact_delta.items()},
            "requests_with_file": sum("SEARCH_INDEX_URL" in r for r in requests[start_requests:]),
            "requests": len(requests) - start_requests,
            "model_turns": turns[start_turns:], "answer": answer,
            "listed": await runner.artifact_service.list_artifact_keys(
                app_name="docs", user_id="dana", session_id=session.id),
        })
    await runner.close()
    record = {"model": assistant.MODEL, "steps": steps, "store": tree(STORE),
              "failures": await failures()}
    (HERE / "recorded-runs.json").write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
    lines = []
    for s in steps:
        lines.append(f"# run {s['run']}, step {s['step']}" + (" (ship.toml attached)" if s["kind"] == "upload" else ""))
        lines.append(f"> {s['message']}")
        lines += [f"call   {c['name']} {json.dumps(c['args'])[:110]}" for c in s["calls"]]
        if s["artifact_delta"]:
            lines.append(f"saved  {s['artifact_delta']}")
        lines.append(f"listed {s['listed']}")
        lines.append("answer " + s["answer"].replace("\n", " ")[:300])
        lines.append("")
    lines.append("# artifact store on disk")
    lines += record["store"]
    (HERE / "run-output.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
    print("\n".join(lines))
    print(json.dumps(record["failures"], indent=2))


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

Decide who can see and keep each file

user: files are shared by every session of one user_id, so the user id must come from your authentication, never from the model or the message. Uploads are untrusted input: the plugin saves any attached file up to its inline limit, 20 MB in the 2.12.0 source, and turns a larger one into an upload error in the message; check type and size before a tool parses a file. Tools cannot delete artifacts through their context, so retention is your application’s job, through the service’s delete_artifact. And FileArtifactService is local to one machine: two replicas behind a load balancer would each see their own folder, which is the point to move to a shared backend.

Check your understanding

A new session opens the same disk artifact store after a restart. review.md exists but is not listed there. What explains this?

Choose one answer, then check your reasoning.

Read the explanation

Plain filenames are session-scoped. user: files are listed across that user’s sessions; disk persistence does not change scope.

References

Official sources checked October 21, 2026, against google-adk 2.12.0.

Previously on the blog: Google ADK Artifacts: Save & Manage Files in Agents covers the ADK 1.x version of this lesson.

Written by Arjun Prabhulal.

Module 2

Manage context and artifacts