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)

Offen
#1,181 0 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen
Vorherrschende Sprache
TypeScript
Sterne
2.1k
Forks
403
Ø Merge
1 T. 21 Std.
Gemergte PRs (30 T.)
8

Beschreibung

**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.

Beitragsleitfaden

Beitragsleitfaden öffnen

Rechercherichtung

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.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Bewertung

Tech-Stack
typescript
Bereich
tooling
Issue-Typ
Bug
Schwierigkeit
4/5
Geschätzter Aufwand
3-5 Tage
Aktivitätsstatus
Aktiv
Klarheit
Klar beschrieben
Anfängerfreundlichkeit
58/100

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.