anthropics / anthropics/anthropic-sdk-typescript

Memory tool: two edits to one file in the same turn silently lose one (BetaToolRunner runs tool calls concurrently; BetaLocalFilesystemMemoryTool has no serialization)

Open
#1,181 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
2.1k
Forks
403
Avg merge
1d 21h
Merged PRs (30d)
8

Description

**SDK version:** `@anthropic-ai/sdk` 0.124.0; `src/lib/tools/BetaToolRunner.ts` and `src/tools/memory/node.ts` on `main` as of 2026-09-09 behave the same.
**Runtime:** Node v24.9.0, macOS 15.

`BetaToolRunner` executes every `tool_use` block of an assistant turn concurrently:

```ts
const toolResults = await Promise.all(
toolUseBlocks.map(async (toolUse) => {
...
const result = await tool.run(input, { toolUse, toolUseBlock: toolUse, signal: requestOptions?.signal });
...
}),
);
```

`BetaLocalFilesystemMemoryTool.str_replace` and `insert` are read-modify-write with no lock: read the file, compute the new content, `atomicWriteFile`. When two of them target the same memory file in one turn, both read the original content, both write, and the last write wins. Both return the success snippet, so neither the model nor the application learns that an edit was dropped.

Claude does issue several memory writes in a single turn (in one live session on `claude-opus-5` a single turn carried five, across different files). Two `str_replace` calls on the same file are the natural way for it to make two corrections to one note at once, and nothing in the tool description discourages that.

### Repro

The `Promise.all` below is the interleaving the runner produces.

```ts
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { BetaLocalFilesystemMemoryTool } from '@anthropic-ai/sdk/tools/memory/node';

const base = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-repro-'));
const memory = await BetaLocalFilesystemMemoryTool.init(base);
await memory.create({ command: 'create', path: '/memories/race.md', file_text: 'alpha\nbeta\n' });

console.log(await Promise.all([
memory.str_replace({ command: 'str_replace', path: '/memories/race.md', old_str: 'alpha', new_str: 'ALPHA' }),
memory.str_replace({ command: 'str_replace', path: '/memories/race.md', old_str: 'beta', new_str: 'BETA' }),
]));
console.log(JSON.stringify(await fs.readFile(path.join(base, 'memories/race.md'), 'utf8')));
```

Output:

```
[
"The memory file has been edited. Here is the snippet showing the change (with line numbers):\n 1\tALPHA\n 2\tbeta\n 3\t",
"The memory file has been edited. Here is the snippet showing the change (with line numbers):\n 1\talpha\n 2\tBETA\n 3\t"
]
"ALPHA\nbeta\n"
```

Two successes reported, one edit on disk.

### Expected

Both edits applied: `"ALPHA\nBETA\n"`.

### Suggested fix

Either change closes it, and both together are cheap:

- In `BetaLocalFilesystemMemoryTool`, run commands through a per-instance promise queue so they execute one at a time in call order. The operations are local filesystem calls, so serialising them costs nothing noticeable.
- In `BetaToolRunner`, execute the `tool_use` blocks of a turn sequentially, or give `BetaRunnableTool` an opt-in for ordered execution, so any stateful client tool is safe. The Python SDK's runner already executes them in a `for` loop.

Contributor guide

Open the contributing guide

Research direction

Start with src/lib/tools/BetaToolRunner.ts and src/tools/memory/node.ts, then run the supplied Promise.all reproduction against the current main behavior. Verify that two edits to the same memory file both persist as "ALPHA\nBETA\n" and that the runner/tool path no longer reports success after dropping an edit.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.