Repository navigation
Python: fix(core): give each file memory its own description sidecar #8621
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
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 |
|---|---|---|
|
|
@@ -95,15 +95,43 @@ | |
| def _description_file_name(file_name: str) -> str: | ||
| """Return the companion description file name for ``file_name``. | ||
|
|
||
| The suffix replaces the original extension when present (so ``notes.md`` | ||
| becomes ``notes_description.md``); otherwise it is appended. | ||
| The suffix is appended to the full file name, extension included, so | ||
| ``notes.md`` becomes ``notes.md_description.md``. Keeping the extension | ||
| gives every memory file its own sidecar: ``notes.md`` and ``notes.json`` | ||
| must not share one, or writing or deleting either would clobber the | ||
| other's description. | ||
| """ | ||
| return f"{file_name}{_DESCRIPTION_SUFFIX}" | ||
|
|
||
|
|
||
| def _legacy_description_file_name(file_name: str) -> str: | ||
| """Return the sidecar name used before the extension was kept. | ||
|
|
||
| Earlier versions replaced the extension (``notes.md`` -> | ||
| ``notes_description.md``). Sidecars written under that scheme are still | ||
| read so existing memories keep their descriptions; new sidecars always use | ||
| :func:`_description_file_name`. | ||
| """ | ||
| dot_index = file_name.rfind(".") | ||
| if dot_index > 0: | ||
| return f"{file_name[:dot_index]}{_DESCRIPTION_SUFFIX}" | ||
| return f"{file_name}{_DESCRIPTION_SUFFIX}" | ||
|
|
||
|
|
||
| def _legacy_sidecar_is_shared(file_name: str, file_names: list[str]) -> bool: | ||
| """Return whether another memory file maps to the same legacy sidecar as ``file_name``. | ||
|
|
||
| Under the legacy scheme ``notes.md`` and ``notes.json`` both map to | ||
| ``notes_description.md``, so that sidecar may only be removed on behalf of | ||
| ``file_name`` when no other file still claims it. | ||
| """ | ||
| legacy_name = _legacy_description_file_name(file_name) | ||
| return any( | ||
| other != file_name and not _is_internal_file(other) and _legacy_description_file_name(other) == legacy_name | ||
|
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. This ownership check is case-sensitive, but |
||
| for other in file_names | ||
| ) | ||
|
|
||
|
|
||
| def _is_internal_file(file_name: str) -> bool: | ||
| """Return whether ``file_name`` is an internal file hidden from the agent. | ||
|
|
||
|
|
@@ -338,8 +366,9 @@ async def _rebuild_index(self, working_folder: str) -> None: | |
| sorted_files = sorted((name for name in file_names if not _is_internal_file(name)), key=str.lower) | ||
|
|
||
| lines = ["# Memory Index", ""] | ||
| available = set(file_names) | ||
| for file_name in sorted_files[:_MAX_INDEX_ENTRIES]: | ||
| description = await self.store.read(_combine_paths(working_folder, _description_file_name(file_name))) | ||
| description = await self._read_description(working_folder, file_name, available) | ||
| if description and description.strip(): | ||
| lines.append(f"- **{file_name}**: {description.strip()}") | ||
| else: | ||
|
|
@@ -348,6 +377,30 @@ async def _rebuild_index(self, working_folder: str) -> None: | |
| index_path = _combine_paths(working_folder, _MEMORY_INDEX_FILE_NAME) | ||
| await self.store.write(index_path, "\n".join(lines) + "\n") | ||
|
|
||
| async def _read_description(self, working_folder: str, file_name: str, available: set[str]) -> str | None: | ||
| """Return the description stored for ``file_name``, or ``None`` when it has none. | ||
|
|
||
| The current sidecar name is preferred; a sidecar written under the | ||
| legacy extension-replacing scheme is used when no current one exists. | ||
| """ | ||
| for sidecar in (_description_file_name(file_name), _legacy_description_file_name(file_name)): | ||
|
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 new sidecar for |
||
| if sidecar in available: | ||
| return await self.store.read(_combine_paths(working_folder, sidecar)) | ||
| return None | ||
|
|
||
| async def _delete_description(self, working_folder: str, file_name: str) -> None: | ||
| """Remove the description sidecar(s) of ``file_name``. | ||
|
|
||
| The legacy sidecar is only removed when no other memory file still maps | ||
| to it, since the legacy naming is shared between files that differ only | ||
| by extension. | ||
| """ | ||
| await self.store.delete(_combine_paths(working_folder, _description_file_name(file_name))) | ||
|
Member
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. Anish Mehta (@anishmehta24) This still clobbers legacy descriptions when the file being cleared has no extension. A pre-upgrade store with
Contributor
Author
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. You're right, and the extensionless case isn't the only one it opens up. The root of it is the read-time legacy fallback: it keeps two naming schemes live at once, and most of the automated findings fall out of that too — So rather than add more guards around the fallback, I'd rather migrate once and then only ever use the new scheme: on first touch of a working folder, if exactly one visible file maps to a legacy sidecar, rename it to that file's new name and drop the legacy one. After that there's no fallback read and no conditional delete, so those cases stop existing instead of needing a guard each. The part I don't want to guess at is the ambiguous case. When two or more visible files map to the same legacy sidecar ( Either way I'll route the sibling comparison through the store's path identity instead of comparing strings, since the in-memory store is case-insensitive, and bound the derived name so a long filename can't write its content and then fail on the sidecar. |
||
| entries = await self.store.list_children(working_folder) | ||
| file_names = [entry.name for entry in entries if entry.type == FileStoreEntry.FILE] | ||
| if not _legacy_sidecar_is_shared(file_name, file_names): | ||
|
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. When |
||
| await self.store.delete(_combine_paths(working_folder, _legacy_description_file_name(file_name))) | ||
|
|
||
| async def before_run( | ||
| self, | ||
| *, | ||
|
|
@@ -388,7 +441,7 @@ async def file_memory_write(file_name: str, content: str, description: str | Non | |
| if description and description.strip(): | ||
| await self.store.write(desc_path, description) | ||
| else: | ||
| await self.store.delete(desc_path) | ||
| await self._delete_description(working_folder, normalized) | ||
| await self._rebuild_index(working_folder) | ||
| except ValueError as exc: | ||
| return f"Could not write file '{file_name}': {exc}" | ||
|
|
@@ -426,12 +479,12 @@ async def file_memory_delete(file_name: str) -> str: | |
| return f"File '{file_name}' not found." | ||
|
|
||
| path = _combine_paths(working_folder, normalized) | ||
| desc_path = _combine_paths(working_folder, _description_file_name(normalized)) | ||
| async with self._write_lock: | ||
| try: | ||
| deleted = await self.store.delete(path) | ||
| await self.store.delete(desc_path) | ||
| await self._rebuild_index(working_folder) | ||
| if deleted: | ||
| await self._delete_description(working_folder, normalized) | ||
| await self._rebuild_index(working_folder) | ||
| except ValueError as exc: | ||
| return f"Could not delete file '{file_name}': {exc}" | ||
| except OSError as exc: | ||
|
|
@@ -454,10 +507,7 @@ async def file_memory_ls(glob_pattern: str | None = None) -> list[dict[str, Any] | |
| continue | ||
| if not _matches_glob(file_name, glob_pattern): | ||
| continue | ||
| description: str | None = None | ||
| desc_file_name = _description_file_name(file_name) | ||
| if desc_file_name in available: | ||
| description = await self.store.read(_combine_paths(working_folder, desc_file_name)) | ||
| description = await self._read_description(working_folder, file_name, available) | ||
| results.append({"name": file_name, "type": "file", "description": description}) | ||
| return results | ||
|
|
||
|
|
||
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.
Appending this 15-byte suffix can turn a valid filesystem filename into an invalid sidecar component. With the default
FileSystemAgentFileStore, a 250-byte filename is written first, then its 265-byte sidecar fails, so the tool reports failure after persisting content and skips the index rebuild. Please bound or hash the derived name, or validate it before writing the main file, so valid inputs cannot partially commit.