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)
- Linguagem predominante
- TypeScript
- Estrelas
- 2.1k
- Forks
- 403
- Merge médio
- 1d 21h
- PRs com merge (30d)
- 8
Descrição
**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.
Guia de contribuição
Direção de pesquisa
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.
Escrita pelo modelo de indexação a partir do texto da issue.
Avaliação
- Stack de tecnologia
- typescript
- Domínio
- tooling
- Tipo de issue
- Bug
- Dificuldade
- 4/5
- Tempo estimado
- 3-5 dias
- Status de atividade
- Ativa
- Clareza
- Claramente especificada
- Facilidade para iniciantes
- 58/100