Repository navigation
Description
Description
FileAccessProvider guards its read-modify-write tools (file_access_replace, file_access_replace_lines, and the write/delete paths) with self._write_lock, which is per provider instance. The provider docs say the store is intentionally shared across sessions and agents, and FileMemoryProvider uses the same AgentFileStore abstraction. Two providers on the same store can therefore interleave read, modify and write, and one edit is lost.
Expected: concurrent edits to different parts of the same file through providers that share a store are both applied.
Actual: the last writer wins, and the other edit is silently dropped. The tool still reports Replaced 1 occurrence(s).
The repro uses a store with a small read delay to simulate real I/O latency, such as disk or remote stores. With a single provider the lock serializes correctly.
Suggested fix: move the serialization into the store (a per-path lock in AgentFileStore), or add an optimistic-concurrency precondition, e.g. write(path, content, expected_content=...) / an ETag, that fails and retries on conflict.
Code Sample
import asyncio
from agent_framework import AgentSession
from agent_framework._sessions import SessionContext
from agent_framework._harness._file_access import FileAccessProvider, InMemoryAgentFileStore
class SlowStore(InMemoryAgentFileStore): # simulates I/O latency
async def read(self, *a, **k):
r = await super().read(*a, **k); await asyncio.sleep(0.01); return r
async def replace_tool(p):
ctx = SessionContext(input_messages=[])
await p.before_run(agent=None, session=AgentSession(), context=ctx, state={})
return next(t for t in ctx.tools if t.name == FileAccessProvider.REPLACE_TOOL_NAME)
async def main():
store = SlowStore()
await store.write("notes.txt", "A=0\nB=0\n")
t1 = await replace_tool(FileAccessProvider(store))
t2 = await replace_tool(FileAccessProvider(store))
await asyncio.gather(
t1.invoke(arguments={"file_name": "notes.txt", "old_string": "A=0", "new_string": "A=1"}),
t2.invoke(arguments={"file_name": "notes.txt", "old_string": "B=0", "new_string": "B=1"}))
print(repr(await store.read("notes.txt"))) # 'A=0\nB=1\n' (expected 'A=1\nB=1\n')
asyncio.run(main())Error Messages / Stack Traces
Package Versions
agent-framework-core 1.19.0 (main @ 853c456)
Python Version
Python 3.11
Additional Context
Found during a code review of main @ 853c456 (2026-09-30); the repro above was run against that commit.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
github-actions commentedon Sep 30, 2026
🤖 Automated triage reproduction notes (agent-authored — trust but verify)
Agent analysis
python/packages/core/agent_framework/_harness/_file_access.py::FileAccessProvider.__init__/before_runinitializes a per-instance_write_lockat line 1886, whilefile_access_replace(lines 2124-2145) andfile_access_replace_lines(lines 2147-2167) perform whole-file read-modify-write sequences under that lock. The trigger is two provider instances sharing one store and concurrently editing different parts of the same path; both calls report success while the later whole-file write drops the earlier update. Minimal repro: build two providers over a delayed-readInMemoryAgentFileStorecontainingA=0\nB=0\n, invoke their replace or replace-lines tools concurrently withasyncio.gather, and assert the final content isA=1\nB=1\n.python/packages/core/tests/core/test_harness_file_access.py1.19.01.19.0nefayran commentedon Oct 2, 2026
I'd like to fix this. To stay out of the way of #8912, I'd keep the fix internal, with no new public API: providers that use the same store instance share one write lock per path, the way
FileHistoryProvideralready keeps its per-path locks at class level in_sessions.py. That coversFileAccessProvider's replace, replace_lines, write and delete paths, andFileMemoryProvider, which has the same per-instance lock. Tests: the two-provider repro from this issue, plus one forFileMemoryProvider. Cross-process and remote stores would stay with #8912. Does that work for you?