Skip to content

Python: [Bug]: FileAccessProvider replace/replace_lines lose updates when providers share a store #8909

Closed

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.

Activity

  1. added
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Sep 30, 2026
  2. added
    reproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow
    on Sep 30, 2026
  3. github-actions commented on Sep 30, 2026

    @github-actions
    Contributor

    🤖 Automated triage reproduction notes (agent-authored — trust but verify)

    Agent analysis

    python/packages/core/agent_framework/_harness/_file_access.py::FileAccessProvider.__init__/before_run initializes a per-instance _write_lock at line 1886, while file_access_replace (lines 2124-2145) and file_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-read InMemoryAgentFileStore containing A=0\nB=0\n, invoke their replace or replace-lines tools concurrently with asyncio.gather, and assert the final content is A=1\nB=1\n.

    • Failing test: python/packages/core/tests/core/test_harness_file_access.py
    • Files examined: python/packages/core/agent_framework/_harness/_file_access.py, python/packages/core/tests/core/test_harness_file_access.py, python/packages/core/pyproject.toml
    • Tests run: existing replace/replace_lines tests (2 passed), shared-store concurrent replace/replace_lines tests (2 failed)
    • Reported version: 1.19.0
    • Current version: 1.19.0
  4. added
    harness[Issues, PRs], Target: harness-level items
    and removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Sep 30, 2026
  5. nefayran commented on Oct 2, 2026

    @nefayran
    Contributor

    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 FileHistoryProvider already keeps its per-path locks at class level in _sessions.py. That covers FileAccessProvider's replace, replace_lines, write and delete paths, and FileMemoryProvider, which has the same per-instance lock. Tests: the two-provider repro from this issue, plus one for FileMemoryProvider. Cross-process and remote stores would stay with #8912. Does that work for you?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

harness[Issues, PRs], Target: harness-level itemspythonUsage: [Issues, PRs], Target: PythonreproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions