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)

未關閉
#1,181 0 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視
主要語言
TypeScript
星號
2.1k
分支
403
平均合併
1 天 21 小時
30 天內合併 PR
8

描述

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

貢獻指南

開啟貢獻指南

研究方向

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.

由索引模型根據 Issue 內容生成。

評估

技術堆疊
typescript
領域
tooling
Issue 類型
缺陷
難度
4/5
預估耗時
3-5 天
活躍度
活躍
描述清晰度
描述清楚
新手友好度
58/100

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。