Repository navigation
fix(memory): isolate ingested events and returned search content #7566
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -103,7 +103,7 @@ async def add_session_to_memory(self, session: Session) -> None: | |||||
| deleted_ids = self._deleted_memory_ids.get(user_key, set()) | ||||||
| self._session_events[user_key] = self._session_events.get(user_key, {}) | ||||||
| self._session_events[user_key][session.id] = [ | ||||||
| event | ||||||
| event.model_copy(deep=True) | ||||||
| for event in session.events | ||||||
| if event.content | ||||||
| and event.content.parts | ||||||
|
|
@@ -136,7 +136,7 @@ async def add_events_to_memory( | |||||
| existing_ids = {event.id for event in existing_events} | ||||||
| for event in events_to_add: | ||||||
| if event.id not in existing_ids and event.id not in deleted_ids: | ||||||
| existing_events.append(event) | ||||||
| existing_events.append(event.model_copy(deep=True)) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similarly to
Suggested change
|
||||||
| existing_ids.add(event.id) | ||||||
| self._session_events[user_key][scoped_session_id] = existing_events | ||||||
|
|
||||||
|
|
@@ -182,7 +182,7 @@ async def search_memory( | |||||
| matched_words, | ||||||
| MemoryEntry( | ||||||
| id=event.id, | ||||||
| content=event.content, | ||||||
| content=event.content.model_copy(deep=True), | ||||||
| author=event.author, | ||||||
| timestamp=_utils.format_timestamp(event.timestamp), | ||||||
| ), | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -941,3 +941,73 @@ def reader(): | |||||
| assert ( | ||||||
| not errors | ||||||
| ), f'search_memory raced with concurrent deletes: {errors[0]!r}' | ||||||
|
|
||||||
|
|
||||||
| @pytest.mark.parametrize('ingest_session', [True, False]) | ||||||
| async def test_ingested_events_are_detached_from_the_caller( | ||||||
| ingest_session: bool, | ||||||
| ) -> None: | ||||||
| """Changing an ingested event must not rewrite stored memory.""" | ||||||
| service = InMemoryMemoryService() | ||||||
| session = MOCK_SESSION_1.model_copy(deep=True) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||
| event = session.events[0] | ||||||
| original_content = event.content.model_copy(deep=True) | ||||||
| if ingest_session: | ||||||
| await service.add_session_to_memory(session) | ||||||
| else: | ||||||
| await service.add_events_to_memory( | ||||||
| app_name=session.app_name, | ||||||
| user_id=session.user_id, | ||||||
| session_id=session.id, | ||||||
| events=[event], | ||||||
| ) | ||||||
|
|
||||||
| event.id = 'changed-id' | ||||||
| event.content.parts[0].text = 'replaced by the caller' | ||||||
| result = await service.search_memory( | ||||||
| app_name=session.app_name, user_id=session.user_id, query='toolkit' | ||||||
| ) | ||||||
|
|
||||||
| assert len(result.memories) == 1 | ||||||
| assert result.memories[0].id == 'event-1a' | ||||||
| assert result.memories[0].content == original_content | ||||||
|
|
||||||
|
|
||||||
| @pytest.mark.parametrize('ingest_session', [True, False]) | ||||||
| async def test_search_results_are_detached_from_stored_memory( | ||||||
| ingest_session: bool, | ||||||
| ) -> None: | ||||||
| """Editing returned content must not affect memory or the source session.""" | ||||||
| service = InMemoryMemoryService() | ||||||
| session = MOCK_SESSION_1.model_copy(deep=True) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
| event = session.events[0] | ||||||
| event.content.parts.append( | ||||||
| types.Part.from_function_call( | ||||||
| name='lookup', args={'options': {'color': 'blue'}} | ||||||
| ) | ||||||
| ) | ||||||
| original_content = event.content.model_copy(deep=True) | ||||||
| if ingest_session: | ||||||
| await service.add_session_to_memory(session) | ||||||
| else: | ||||||
| await service.add_events_to_memory( | ||||||
| app_name=session.app_name, | ||||||
| user_id=session.user_id, | ||||||
| session_id=session.id, | ||||||
| events=[event], | ||||||
| ) | ||||||
| result = await service.search_memory( | ||||||
| app_name=session.app_name, user_id=session.user_id, query='toolkit' | ||||||
| ) | ||||||
|
|
||||||
| returned_content = result.memories[0].content | ||||||
| returned_content.parts[0].text = 'replaced by the caller' | ||||||
| returned_content.parts[1].function_call.args['options']['color'] = 'red' | ||||||
| returned_content.parts.append(types.Part(text='extra content')) | ||||||
| repeated = await service.search_memory( | ||||||
| app_name=session.app_name, user_id=session.user_id, query='toolkit' | ||||||
| ) | ||||||
|
|
||||||
| assert len(repeated.memories) == 1 | ||||||
| assert repeated.memories[0].content == original_content | ||||||
| assert event.content == original_content | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
Eventclass explicitly overrides__deepcopy__with a custom implementation (_model_copy.deep_copy_model) to handle its copying logic correctly. Usingevent.model_copy(deep=True)bypasses this custom__deepcopy__because Pydantic'smodel_copydeep-copies the underlying dictionary directly rather than callingcopy.deepcopyon the model instance.\n\nTo ensure the custom deep copy logic is executed, usecopy.deepcopy(event)instead.\n\nNote: You will need toimport copyat the top of the file.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verified against the actual Pydantic implementation:
BaseModel.model_copy(deep=True)withoutupdatecallsself.__deepcopy__(), so these calls do invoke Event/Session's custom copier. ADK'sutils/_model_copy.py::deep_copy_modelalso explicitly documents support for bothcopy.deepcopyandmodel_copy(deep=True).I ran an additional probe using that production ADK helper as a custom model copier:
model_copy(deep=True)invoked the override once and editing nested copied content left the source unchanged. The existing four public-service regressions and full memory-service tests already pass (53 tests). Keeping the current calls avoids a behavior-neutral rewrite; the bypass described in these comments does not occur for these calls.