Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions src/agents/extensions/memory/advanced_sqlite_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,129 @@ def _get_items_sync():

return await asyncio.to_thread(_get_items_sync)

async def pop_item(self) -> TResponseInputItem | None:
"""Remove and return the most recent item from the current branch.

Overrides the base implementation so the popped message's
`message_structure` row is removed in the same transaction and only the
current branch is affected. The underlying message row is deleted only
when no other branch still references it, mirroring `delete_branch`. When
popping empties a turn on the current branch, its `turn_usage` row is
removed as well so usage analytics do not report a turn that no longer
exists.
"""

# Snapshot the current branch at call time so a concurrent
# switch_to_branch() cannot redirect this pop to a different branch once
# it has been dispatched to the worker thread.
branch_id = self._current_branch_id

def _pop_item_sync():
with self._locked_connection() as conn:
while True:
with closing(conn.cursor()) as cursor:
# Find the most recent item on the snapshotted branch.
cursor.execute(
"""
SELECT id, message_id, user_turn_number FROM message_structure
WHERE session_id = ? AND branch_id = ?
ORDER BY sequence_number DESC
LIMIT 1
""",
(self.session_id, branch_id),
)
row = cursor.fetchone()
if row is None:
return None

structure_id, message_id, user_turn_number = row

# Read the message payload before removing anything.
cursor.execute(
f"SELECT message_data FROM {self.messages_table} WHERE id = ?",
(message_id,),
)
message_row = cursor.fetchone()

# Remove the structure row for this branch, then drop the
# underlying message only if no other branch references it.
cursor.execute(
"DELETE FROM message_structure WHERE id = ?",
(structure_id,),
)
self._cleanup_orphaned_messages_sync(conn)
Comment on lines +301 to +305

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove stale usage when popping a whole turn

When callers have stored usage for the latest branch turn and then pop_item() removes the last message_structure row for that turn, this path only deletes the structure row and orphaned message rows. The matching turn_usage row remains, so get_turn_usage() and get_session_usage() continue to report usage for a turn that no longer exists in the current branch; clear_session() and delete_branch() already clean this table, so this pop path should delete the turn's usage once no structure rows reference it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — fixed in 2c97250. pop_item now deletes the turn's turn_usage row once no message_structure rows reference that (branch, user_turn_number) anymore, so a fully-popped turn no longer shows up in get_turn_usage/get_session_usage, while a partially-popped turn keeps its usage. This matches the cleanup delete_branch and clear_session already do. Added a regression test covering both the partial and full-pop cases.


# If this was the last item of the turn on this branch,
# drop the now-stale turn_usage row for that turn.
if user_turn_number is not None:
cursor.execute(
"""
SELECT COUNT(*) FROM message_structure
WHERE session_id = ? AND branch_id = ? AND user_turn_number = ?
""",
(self.session_id, branch_id, user_turn_number),
)
if cursor.fetchone()[0] == 0:
cursor.execute(
"""
DELETE FROM turn_usage
WHERE session_id = ? AND branch_id = ? AND user_turn_number = ?
""",
(self.session_id, branch_id, user_turn_number),
)

conn.commit()

if message_row is None:
# Structure row pointed at a missing message; keep looking.
continue

try:
return json.loads(message_row[0])
except (json.JSONDecodeError, TypeError):
# Drop corrupted JSON entries and keep looking for a valid item.
continue

return await asyncio.to_thread(_pop_item_sync)

async def clear_session(self) -> None:
"""Clear all items for this session.

Overrides the base implementation so the `message_structure` and
`turn_usage` metadata tables are cleared in the same transaction. Those
rows declare an `ON DELETE CASCADE` foreign key, but SQLite does not
enforce foreign keys unless `PRAGMA foreign_keys=ON` is set, so they must
be deleted explicitly to avoid leaking stale structure and usage data.
"""

def _clear_session_sync():
with self._locked_connection() as conn:
conn.execute(
f"DELETE FROM {self.messages_table} WHERE session_id = ?",
(self.session_id,),
)
conn.execute(
f"DELETE FROM {self.sessions_table} WHERE session_id = ?",
(self.session_id,),
)
conn.execute(
"DELETE FROM message_structure WHERE session_id = ?",
(self.session_id,),
)
conn.execute(
"DELETE FROM turn_usage WHERE session_id = ?",
(self.session_id,),
)
conn.commit()
# All branches were removed, so reset the in-memory pointer to
# 'main' while still holding the lock. Doing this inside the
# locked operation keeps the reset atomic with the clear, so no
# other locked operation observes the session as cleared while
# the pointer still references a deleted branch.
self._current_branch_id = "main"

await asyncio.to_thread(_clear_session_sync)

async def store_run_usage(self, result: RunResult) -> None:
"""Store usage data for the current conversation turn.

Expand Down
Loading