Repository navigation
SqliteSessionService turns state integers outside 64 bits into floats聽#7576
Description
馃敶 Required Information
Describe the Bug:
SqliteSessionService stores an integer outside the signed 64-bit range in session state as a float. A 64-bit hash like 0xFEDCBA9876543210 comes back as 1.8364758544493064e+19 and no longer equals the original.
The state merge (_MERGE_STATE_SQL) rebuilds every key of the row inside SQLite, and SQLite parses such an integer as REAL. So a big int stored earlier also turns into a float when a later turn writes an unrelated key. This is the store adk web and adk api_server use by default (.adk/session.db). The in-memory and database services keep the value exact.
#7426 fixed float rounding in the same SQL and left this out on purpose: "Integers beyond 64 bits are still parsed as REAL by SQLite".
Steps to Reproduce:
- Checkout of main
- Save the script below as
repro.py - Run
PYTHONPATH=src python repro.py. No network: the model is a fake that calls the tool named in the user message.
Expected Behavior:
state['fingerprint'] stays 18364758544493064720 (int) on every turn, and check_fingerprint sees a match.
Observed Behavior:
expected 18364758544493064720 (int)
after remember_fingerprint state['fingerprint'] = 1.8364758544493064e+19 (float)
after count_visit state['fingerprint'] = 1.8364758544493064e+19 (float)
after check_fingerprint state['fingerprint'] = 1.8364758544493064e+19 (float)
check_fingerprint tool saw: {'matches': False, 'value': '1.8364758544493064e+19'}
Environment Details:
- ADK Library Version (pip show google-adk): 2.12.0, main at f78149a
- Desktop OS: macOS 26.6.2
- Python Version (python -V): Python 3.12.13, SQLite 3.53.4
Model Information:
- Are you using LiteLLM: No
- Which model is being used: a fake
BaseLlm, no real model call
馃煛 Optional Information
Root cause:
In _MERGE_STATE_SQL (src/google/adk/sessions/sqlite_session_service.py:59), json_each reports such a value with type = 'integer' but typeof(value) = 'real', and json_group_object writes the REAL. Values nested inside an object or list keep their JSON text, so only top-level keys are hit.
Minimal Reproduction Code:
import asyncio
import os
import tempfile
from typing import AsyncGenerator
from google.adk.agents import LlmAgent
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import Runner
from google.adk.sessions.sqlite_session_service import SqliteSessionService
from google.adk.tools.tool_context import ToolContext
from google.genai import types
FINGERPRINT = 0xFEDCBA9876543210 # an unsigned 64-bit hash, > 2**63 - 1
class FakeLlm(BaseLlm):
"""Calls the tool named in the user message, then answers 'done'."""
model: str = "fake"
async def generate_content_async(
self, llm_request: LlmRequest, stream: bool = False
) -> AsyncGenerator[LlmResponse, None]:
last = llm_request.contents[-1]
if any(p.function_response for p in last.parts):
yield LlmResponse(content=types.ModelContent("done"))
return
tool = last.parts[0].text
yield LlmResponse(
content=types.ModelContent(
[types.Part.from_function_call(name=tool, args={})]
)
)
def remember_fingerprint(tool_context: ToolContext) -> dict:
"""Stores a 64-bit document fingerprint."""
tool_context.state["fingerprint"] = FINGERPRINT
return {"stored": True}
def count_visit(tool_context: ToolContext) -> dict:
"""Unrelated state write."""
tool_context.state["visits"] = tool_context.state.get("visits", 0) + 1
return {"ok": True}
def check_fingerprint(tool_context: ToolContext) -> dict:
"""Compares the stored fingerprint."""
value = tool_context.state["fingerprint"]
return {"matches": value == FINGERPRINT, "value": repr(value)}
async def main():
db = os.path.join(tempfile.mkdtemp(), "session.db")
agent = LlmAgent(
name="a",
model=FakeLlm(),
tools=[remember_fingerprint, count_visit, check_fingerprint],
)
runner = Runner(
app_name="app", agent=agent, session_service=SqliteSessionService(db)
)
s = await runner.session_service.create_session(app_name="app", user_id="u")
async def turn(tool_name):
async for _ in runner.run_async(
user_id="u", session_id=s.id, new_message=types.UserContent(tool_name)
):
pass
got = await runner.session_service.get_session(
app_name="app", user_id="u", session_id=s.id
)
v = got.state.get("fingerprint")
print(f"after {tool_name:<22} state['fingerprint'] = {v!r} ({type(v).__name__})")
print(f"expected {FINGERPRINT!r} (int)")
await turn("remember_fingerprint")
await turn("count_visit")
await turn("check_fingerprint")
got = await runner.session_service.get_session(
app_name="app", user_id="u", session_id=s.id
)
resp = [
p.function_response.response
for e in got.events
for p in (e.content.parts if e.content else [])
if p.function_response and p.function_response.name == "check_fingerprint"
]
print("check_fingerprint tool saw:", resp[-1])
asyncio.run(main())
Activity
Somay-kousis commentedon Oct 17, 2026
For triage: #7577 (opened 2026-10-16) fixes this, and its f094042 adds the guard BichengWang asked for so merging ordinary state stays linear, with a scaling test. #7586 covers the same fix.
fix: keep integers outside 64 bits exact in SqliteSessionService state