Repository navigation
Python: fix(core): prevent path collisions between files and directories in InMemoryAgentFileStore - #9608
Conversation
…ies in InMemoryAgentFileStore Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com>
…AgentFileStore Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com>
The upstream microsoft#8507 assertion expected list_children("") to stay empty, which held while create_directory was a no-op. This PR makes explicit directories listable, so the session-1 directory created during before_run now appears at the root. The invariant the test protects, that a rejected nested write leaves nothing behind, is preserved: the session directory exists and stays empty, with no notes child.
Explicitly created directories had no deletion path, so session-scoped runs kept accumulating entries in the shared map. delete() now removes an explicit directory once nothing is stored under it and no other explicit directory is nested below, giving session directories a lifecycle. Non-empty directories stay put, matching the filesystem store's file-only deletes. Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com>
Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com>
|
The The job crashes inside griffe while loading the base (main) snapshot, before it compares the PR changes — the traceback points at the base copy of the package: Example run: https://github.com/microsoft/agent-framework/actions/runs/39810016173/job/119933441343 None of the changes in this PR touch |
Bicheng (Kenneth) (BichengWang)
left a comment
There was a problem hiding this comment.
Reviewed 4f2d38c43136bcbc03de6544f51642f7cb8951e4, including the feedback on #8687.
P2: Bound directory-prefix materialization (python/packages/core/agent_framework/_harness/_file_access.py:1096-1103). Slicing key[:idx] still copies every full ancestor prefix, so the helper takes quadratic space in path depth despite its linear-time docstring. write() then retains those strings in _dir_counts. The path normalizer has no length/depth bound. This leaves the deep-path concern from #8687 unresolved, with persistent memory growth from one small write.
Offline reproduction on macOS / CPython 3.13.16:
store = InMemoryAgentFileStore()
path = "/".join(["a"] * 8000 + ["f"])
await store.write(path, "x")
print(len(path), sum(len(k) for k in store._dir_counts))
# 16001 64000000Depths 2,000 / 4,000 / 8,000 retain 4 / 16 / 64 million prefix characters; the final case retains 64,328,000 bytes of key strings, excluding the dictionary. Please bound accepted path complexity before allocating prefixes, or use an index that avoids storing every full prefix, and add regression coverage for that bound.
The two affected test files pass locally: 176 passed, 2 skipped. Six extracted collision/directory regressions fail on current main 4982e4c61ec81cc4f9ba0cbdb5594ed530dbbcfe and pass on this head. Ruff 0.17.1 lint and formatting pass for all three changed files. Validation was offline; the complete core suite and Windows behavior were not tested.
…fixes The in-memory store's directory index retains the full key of every ancestor directory, so an unbounded path let a single write hold quadratic space in the path depth (an 8000 deep path retained roughly 64 million characters of prefix strings). The shared normalizer now refuses paths beyond 255 segments or 4096 characters with ENAMETOOLONG, the same errno the filesystem backend's OS layer returns, so the bound is checked before any prefix is allocated and is identical across backends. Deep but realistic paths keep working, and regression tests cover the rejection, the empty index afterward, and backend parity on both sides of the bound. Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com>
|
Verified your reproduction first: on the previous head the 8000 deep write retained exactly 64,000,000 characters of ancestor keys in _dir_counts, matching your numbers. dacf006 takes your first option and bounds the accepted complexity before any prefix is allocated. The shared normalizer now refuses a path beyond 255 segments or 4096 total characters with OSError ENAMETOOLONG, the same errno the OS layer returns for the filesystem backend on the same input, so both stores reject identically and nothing is materialized before the check runs. Your exact script now raises that error and leaves _files and _dir_counts empty, with zero characters retained. Tests cover the rejection and the empty index afterward, a shallow path past the length bound, a 200 deep path that still works with its retention staying linear in the stored path, and a side by side parity case running both backends on both sides of the bound. The rejection and parity tests fail on the previous head. The two affected suites pass at 162 tests, ruff check and format are clean, and mypy passes on the changed file. |
| async def create_directory(self, path: str) -> None: | ||
| """Ensure ``path`` exists as a directory, creating it if necessary.""" | ||
|
|
||
| async def delete_directory(self, path: str) -> bool: |
There was a problem hiding this comment.
this introduces a new public API, I would like to see a issue for that, since we want this to be consistent with dotnet. CC westey (@westey-m)
There was a problem hiding this comment.
Filed as #9666, covering the motivation (session scoped cleanup of explicit directories), the chosen shape (base default returning False so third party stores are unaffected until they opt in), the alternative rejected in the earlier review (overloading delete(), which makes backend substitution observable), and the open .NET consistency question for the store contract. westey (@westey-m) looped in there for that decision. The PR body now references it under Related Issue as the tracking issue for the new surface.
Motivation & Context
In
InMemoryAgentFileStore, writing to a path whose ancestor is already an existing file succeeded, as did writing to a path that already has descendant files in the store. This allowedReportsandReports/q1.txtto coexist simultaneously, causinglist_childrento surface both a directory and a file under the exact same name. Real filesystems reject these operations withNotADirectoryErrorandIsADirectoryError.This change aligns
InMemoryAgentFileStorewithFileSystemAgentFileStoreby enforcing directory and file path invariants duringwriteandcreate_directory.This PR replaces #8687, which was closed while it still had outstanding review feedback about preserving the file-only delete contract. Per the close reason, it is being resubmitted here with that feedback addressed on top of the original fix.
Description & Review Guide
write, verified that no ancestor path of the target is an existing file inself._files, raisingNotADirectoryErrorif encountered.write, checked whether the target path is already an existing directory with descendants, raisingIsADirectoryErrorif so.create_directory, ensured that neither the target path nor any ancestor is an existing file, raisingFileExistsErrorif a collision is detected._normalize_relative_path, which both backends already call: a normalized path is refused withOSError(ENAMETOOLONG)when it exceeds 255 segments or 4096 characters. That is the same error the OS raises for the filesystem backend on such paths, and it closes the unbounded ancestor prefix materialization flagged in review: an 8000 deep write previously retained 64 million characters of ancestor keys and now stores nothing.test_harness_file_access.pycovering both collision orders and confirming existing file contents remain intact.Changes addressing the review feedback from #8687:
delete()is now strictly file-only: a path that names a directory — explicitly created, holding stored descendants, or holding a nested explicit directory — returnsFalse, exactly matchingFileSystemAgentFileStore.delete. Backend substitution is therefore no longer observable throughdelete().AgentFileStore.delete_directory(path)method that releases an empty explicitly created directory. The in-memory store removes the explicit-dir entry; the filesystem store usesrmdirand returnsFalse(instead of raising) when the path is missing, is a file, or still holds entries. The base class ships a non-breaking default implementation returningFalse, so third-party stores are unaffected until they opt in.test_in_memory_store_delete_is_file_onlyasserts theFalsereturns;test_in_memory_store_delete_directory_releases_empty_explicit_directorycovers the lifecycle;test_filesystem_delete_is_file_only_and_delete_directory_matches_in_memoryruns both backends side by side for parity ondelete()anddelete_directory().The full harness suites (259 tests), Ruff check/format, and all five typing checkers (pyrefly, ty, zuban, mypy, pyright) pass locally.
Related Issue
Fixes #8567
The new
AgentFileStore.delete_directorysurface is tracked in #9666, which also carries the Python and .NET consistency discussion for the store contract.