From 9787c108ff0070e168b6b892755da1cfa40c28be Mon Sep 17 00:00:00 2001 From: Bicheng Wang Date: Thu, 8 Oct 2026 12:54:34 -0700 Subject: [PATCH] feat(cli): seed adk web sessions from initial_state.json When an agent directory contains initial_state.json, the dev server merges it into the state of each session created through the session endpoints. State sent by the client takes precedence. The file is read per request, and a missing or malformed file leaves behavior unchanged. ApiServer is not affected. --- src/google/adk/cli/dev_server.py | 50 +++++++++++++++++ tests/unittests/cli/test_fast_api.py | 83 ++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/src/google/adk/cli/dev_server.py b/src/google/adk/cli/dev_server.py index e3b5f0f59c5..2da0037a6ef 100644 --- a/src/google/adk/cli/dev_server.py +++ b/src/google/adk/cli/dev_server.py @@ -103,6 +103,7 @@ logger = logging.getLogger("google_adk." + __name__) _EVAL_SET_FILE_EXTENSION = ".evalset.json" +_INITIAL_STATE_FILENAME = "initial_state.json" _PROCESS_TERMINATION_GRACE_SECONDS = 0.5 _PROCESS_TERMINATOR_TIMEOUT_SECONDS = 1.0 _TEST_OUTPUT_CHUNK_BYTES = 64 * 1024 @@ -529,6 +530,55 @@ def _get_agent_dir(self, app_name: str) -> str: return str(resolved_path) + def _load_initial_state(self, app_name: str) -> dict[str, Any]: + """Reads the optional ``initial_state.json`` next to an agent. + + The file is read on every call, so edits apply to the next session without + restarting the server. A missing, unreadable or malformed file yields an + empty state rather than failing session creation. + """ + try: + path = Path(self._get_agent_dir(app_name)) / _INITIAL_STATE_FILENAME + except HTTPException: + return {} + if not path.is_file(): + return {} + try: + with open(path, "r", encoding="utf-8") as f: + initial_state = json.load(f) + except (OSError, ValueError) as e: + logger.warning("Ignoring %s: %s", path, e) + return {} + if not isinstance(initial_state, dict): + logger.warning("Ignoring %s: expected a JSON object", path) + return {} + return initial_state + + async def _create_session( + self, + *, + app_name: str, + user_id: str, + session_id: Optional[str] = None, + state: Optional[dict[str, Any]] = None, + ) -> Session: + """Creates a session seeded from the agent's ``initial_state.json``. + + State supplied by the caller takes precedence over the file's values, key by + key. Only explicit session creation is seeded; a session that the runner + creates itself (``--auto_create_session``) is not. ``app:`` and ``user:`` + keys in the file follow the usual state scoping. + """ + initial_state = self._load_initial_state(app_name) + if initial_state: + state = {**initial_state, **(state or {})} + return await super()._create_session( + app_name=app_name, + user_id=user_id, + session_id=session_id, + state=state, + ) + def _get_test_file_path(self, *, app_name: str, test_name: str) -> str: """Resolves a test file to a path inside the app's own tests directory. diff --git a/tests/unittests/cli/test_fast_api.py b/tests/unittests/cli/test_fast_api.py index 3358a994d3f..e153c788f8a 100755 --- a/tests/unittests/cli/test_fast_api.py +++ b/tests/unittests/cli/test_fast_api.py @@ -7209,6 +7209,89 @@ def test_dev_endpoint_rejects_app_name_that_is_not_an_identifier( assert "must be valid" in rejected.json()["detail"] +def _create_builder_session(client, payload=None): + response = client.post( + "/apps/test_app/users/test_user/sessions", json=payload or {} + ) + assert response.status_code == 200, response.text + return response.json() + + +def test_dev_create_session_seeds_state_from_initial_state_file( + builder_test_client, tmp_path +): + (tmp_path / "test_app").mkdir() + (tmp_path / "test_app" / "initial_state.json").write_text( + json.dumps({"city": "New York", "time": "10:30"}), encoding="utf-8" + ) + + session = _create_builder_session(builder_test_client) + + assert session["state"] == {"city": "New York", "time": "10:30"} + + +def test_dev_create_session_client_state_overrides_initial_state_file( + builder_test_client, tmp_path +): + (tmp_path / "test_app").mkdir() + (tmp_path / "test_app" / "initial_state.json").write_text( + json.dumps({"city": "New York", "time": "10:30"}), encoding="utf-8" + ) + + session = _create_builder_session( + builder_test_client, {"state": {"city": "Paris", "mood": "calm"}} + ) + + assert session["state"] == { + "city": "Paris", + "time": "10:30", + "mood": "calm", + } + + +def test_dev_create_session_without_initial_state_file_is_unchanged( + builder_test_client, tmp_path +): + (tmp_path / "test_app").mkdir() + + session = _create_builder_session(builder_test_client, {"state": {"a": 1}}) + + assert session["state"] == {"a": 1} + + +@pytest.mark.parametrize("content", ["{not json", "[1, 2]", '"text"']) +def test_dev_create_session_ignores_invalid_initial_state_file( + builder_test_client, tmp_path, content +): + (tmp_path / "test_app").mkdir() + (tmp_path / "test_app" / "initial_state.json").write_text( + content, encoding="utf-8" + ) + + session = _create_builder_session(builder_test_client, {"state": {"a": 1}}) + + assert session["state"] == {"a": 1} + + +def test_api_server_create_session_ignores_initial_state_file( + test_app, test_session_info, tmp_path +): + """Only the dev server reads initial_state.json.""" + (tmp_path / test_session_info["app_name"]).mkdir() + (tmp_path / test_session_info["app_name"] / "initial_state.json").write_text( + json.dumps({"city": "New York"}), encoding="utf-8" + ) + + response = test_app.post( + f"/apps/{test_session_info['app_name']}/users/" + f"{test_session_info['user_id']}/sessions", + json={}, + ) + + assert response.status_code == 200 + assert "city" not in response.json().get("state", {}) + + ################################################# # Eval endpoint plumbing #################################################