fix: implement update_message() for guardrail redaction support#388
fix: implement update_message() for guardrail redaction support#388
Conversation
✅ No Breaking Changes DetectedNo public API breaking changes found in this PR. |
If create_message succeeds but delete_event fails, attempt to roll back the newly created event to avoid leaving duplicate messages. Addresses review comment about partial failure handling.
…essage Prevents stale eventId references by updating the tracked latest message with the new eventId after a successful create+delete replacement.
828699f to
a6df5f4
Compare
jariy17
left a comment
There was a problem hiding this comment.
Detailed Review: PR #388 — update_message() for guardrail redaction
Overall this is a well-motivated change that addresses a real gap (guardrail-redacted messages being persisted unredacted). The create-then-delete approach is reasonable given immutable events. However, there are several edge cases and test gaps that should be addressed before merge.
Summary of Findings
P1 — Potential Data Loss (2 issues)
- When
batch_size > 1and a persisted message is updated,create_message()returns{}(buffered). The old event is then deleted immediately. If the process crashes before the buffer flushes, the message is lost with no recovery. Rollback is also impossible sincenew_event_idwould beNone. - If
create_message()returnsNone(e.g. converter produces empty payload from redacted content), the code proceeds to delete the old event with no replacement created.
P2 — Race Condition (1 issue)
3. _update_buffered_message holds _message_lock while replacing the entry, but _flush_messages_only copies the buffer before clearing it. A flush concurrent with an update could send the old (unredacted) content, then the updated buffer entry is cleared — silently losing the redaction.
P2 — Fragile Buffer Matching (1 issue)
4. _update_buffered_message matches by role only. Multiple buffered messages with the same role, or two blob messages (None == None), could cause the wrong message to be replaced.
P3 — Code Quality (3 issues)
5. read_message docstring says it's "primarily used internally by update_message" but update_message never calls read_message.
6. getattr(self, "_latest_agent_message", None) is unnecessary — attribute is always initialized in parent __init__.
7. PR description says "same pattern as update_agent()" but update_agent does NOT delete the old event.
Test Gaps (see inline comments on test file)
- No test for
_latest_agent_messagebeing updated after successful update - No test for rollback success path (only double-failure is accidentally tested)
test_update_buffered_messagechecks buffer count but never verifies content actually changedtest_update_message_create_failsdoesn't assertdelete_eventwas NOT called- No test for
PersistenceMode.NONEorbatch_size > 1with persisted messages - No test for buffer role mismatch or multi-message-same-role scenarios
| created_at=session_message.created_at, | ||
| ) | ||
| new_event = self.create_message(session_id, agent_id, updated_message) | ||
| except Exception as e: |
There was a problem hiding this comment.
P1 — Potential data loss when create_message returns None or {}
Two scenarios:
-
batch_size > 1:create_message()returns{}(buffered, no eventId). The code then proceeds todelete_event()on the old message. The new content is sitting in an unflushed buffer. If the process crashes before flush, the message is permanently lost. Rollback is also impossible sincenew_event.get("eventId")returnsNone. -
Empty converter payload: If
create_message()returnsNone(converter produces empty payload), the code proceeds to delete the old event with no replacement.
Suggestion: Guard against both cases:
new_event = self.create_message(session_id, agent_id, updated_message)
if not new_event or not new_event.get("eventId"):
logger.warning("create_message did not return an eventId — skipping delete of old event %s", old_message_id)
return|
|
||
| Args: | ||
| session_message (SessionMessage): The message with updated content. | ||
|
|
There was a problem hiding this comment.
P2 — Race condition between buffer update and flush
_flush_messages_only copies the buffer (list(self._message_buffer)) then later clears it. If _update_buffered_message runs between the copy and the clear:
- The flush sends the old (unredacted) content from the copy
- The buffer now contains the new (redacted) content
- After flush succeeds, the buffer is cleared — redaction silently lost
The fix would be to hold _message_lock around the entire flush-copy-and-clear operation, or to check if the flush path also needs to coordinate with updates.
|
|
||
| # Verify new event was created | ||
| mock_memory_client.create_event.assert_called_once() | ||
|
|
There was a problem hiding this comment.
Test gap — content of created event not verified
mock_memory_client.create_event.assert_called_once() verifies the call happened but never inspects what content was passed. The replacement event could contain garbage and this test would still pass.
Suggest adding:
create_kwargs = mock_memory_client.create_event.call_args.kwargs
# or inspect the payload to verify it contains "redacted"|
|
||
| def test_update_message_wrong_session(self, session_manager): | ||
| """Test updating a message with wrong session ID.""" | ||
| message = SessionMessage(message={"role": "user", "content": [{"text": "Hello"}]}, message_id=1) |
There was a problem hiding this comment.
Test gap — _latest_agent_message update not tested
The source code explicitly updates self._latest_agent_message[agent_id] when the old message_id matches. This is critical for guardrail correctness — subsequent turns must reference the new eventId.
Suggest adding a test that:
- Pre-populates
session_manager._latest_agent_message["test-agent-123"]with aSessionMessageusingmessage_id="old_event_123" - Calls
update_message - Asserts
session_manager._latest_agent_message["test-agent-123"].message_id == "new_event_456"
| """Test update_message raises SessionException when delete fails.""" | ||
| mock_memory_client.create_event.return_value = {"eventId": "new_event_456"} | ||
| mock_memory_client.gmdp_client.delete_event.side_effect = Exception("Delete failed") | ||
|
|
There was a problem hiding this comment.
Test gap — rollback not verified
Setting delete_event.side_effect = Exception(...) makes ALL calls fail — including the rollback attempt. This accidentally tests only the double-failure path.
Missing tests:
- Rollback succeeds: Use
side_effect=[Exception("Delete failed"), None]so the first call (delete old) fails but the second (rollback new) succeeds. Assertdelete_eventcalled twice. - Current test: Should assert
delete_eventwas called at least twice (once for old, once for rollback) and that the rollback targetednew_event_456.
| message={"role": "user", "content": [{"text": "redacted"}]}, | ||
| message_id="old_event_123", | ||
| created_at="2024-01-01T12:00:00Z", | ||
| ) |
There was a problem hiding this comment.
Test gap — create_fails should verify no delete attempted
When create_event raises, the code should bail out before calling delete_event. Add:
mock_memory_client.gmdp_client.delete_event.assert_not_called()
Summary
update_message()inAgentCoreMemorySessionManagerso that Strands' built-in guardrail redaction (redact_latest_message()) works out of the boxupdate_agent())batch_size > 1case by replacing messages in the send buffer before they are flushedContext
When using Bedrock Guardrails with AgentCore Memory as the Strands session store,
update_message()was a no-op. This meant guardrail-blocked user messages were persisted unredacted, creating a permanent dead-end conversation — on subsequent turns or reconnect, the guardrail would block again on the persisted offending message.Test plan
test_update_message— verifies new event created + old event deleted for persisted messagestest_update_message_wrong_session— session ID mismatch raisesSessionExceptiontest_update_message_no_message_id— graceful skip when message has no event IDtest_update_message_create_fails— raisesSessionExceptionon create failuretest_update_message_delete_fails— raisesSessionExceptionon delete failuretest_update_buffered_message— in-buffer replacement whenbatch_size > 1