@namzu/sdk 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/dist/advisory/__tests__/consultation-context.test.d.ts +2 -0
  3. package/dist/advisory/__tests__/consultation-context.test.d.ts.map +1 -0
  4. package/dist/advisory/__tests__/consultation-context.test.js +124 -0
  5. package/dist/advisory/__tests__/consultation-context.test.js.map +1 -0
  6. package/dist/advisory/context.d.ts +25 -0
  7. package/dist/advisory/context.d.ts.map +1 -1
  8. package/dist/advisory/context.js +18 -0
  9. package/dist/advisory/context.js.map +1 -1
  10. package/dist/advisory/executor.d.ts.map +1 -1
  11. package/dist/advisory/executor.js +25 -3
  12. package/dist/advisory/executor.js.map +1 -1
  13. package/dist/compaction/__tests__/context-reducer.test.d.ts +2 -0
  14. package/dist/compaction/__tests__/context-reducer.test.d.ts.map +1 -0
  15. package/dist/compaction/__tests__/context-reducer.test.js +197 -0
  16. package/dist/compaction/__tests__/context-reducer.test.js.map +1 -0
  17. package/dist/compaction/factory.d.ts +7 -0
  18. package/dist/compaction/factory.d.ts.map +1 -1
  19. package/dist/compaction/factory.js +7 -0
  20. package/dist/compaction/factory.js.map +1 -1
  21. package/dist/compaction/index.d.ts +2 -0
  22. package/dist/compaction/index.d.ts.map +1 -1
  23. package/dist/compaction/index.js +1 -0
  24. package/dist/compaction/index.js.map +1 -1
  25. package/dist/compaction/interface.d.ts +13 -0
  26. package/dist/compaction/interface.d.ts.map +1 -1
  27. package/dist/compaction/managers/null.d.ts +3 -0
  28. package/dist/compaction/managers/null.d.ts.map +1 -1
  29. package/dist/compaction/managers/null.js +3 -0
  30. package/dist/compaction/managers/null.js.map +1 -1
  31. package/dist/compaction/managers/slidingWindow.d.ts +6 -0
  32. package/dist/compaction/managers/slidingWindow.d.ts.map +1 -1
  33. package/dist/compaction/managers/slidingWindow.js +6 -0
  34. package/dist/compaction/managers/slidingWindow.js.map +1 -1
  35. package/dist/compaction/managers/structured.d.ts +10 -0
  36. package/dist/compaction/managers/structured.d.ts.map +1 -1
  37. package/dist/compaction/managers/structured.js +10 -0
  38. package/dist/compaction/managers/structured.js.map +1 -1
  39. package/dist/compaction/reducer.d.ts +86 -0
  40. package/dist/compaction/reducer.d.ts.map +1 -0
  41. package/dist/compaction/reducer.js +77 -0
  42. package/dist/compaction/reducer.js.map +1 -0
  43. package/dist/connector/builtins/__tests__/oauth2-auth.test.d.ts +2 -0
  44. package/dist/connector/builtins/__tests__/oauth2-auth.test.d.ts.map +1 -0
  45. package/dist/connector/builtins/__tests__/oauth2-auth.test.js +54 -0
  46. package/dist/connector/builtins/__tests__/oauth2-auth.test.js.map +1 -0
  47. package/dist/connector/builtins/http.d.ts.map +1 -1
  48. package/dist/connector/builtins/http.js +24 -2
  49. package/dist/connector/builtins/http.js.map +1 -1
  50. package/dist/connector/builtins/http.test.js +18 -2
  51. package/dist/connector/builtins/http.test.js.map +1 -1
  52. package/dist/connector/index.d.ts +2 -2
  53. package/dist/connector/index.d.ts.map +1 -1
  54. package/dist/connector/index.js +1 -1
  55. package/dist/connector/index.js.map +1 -1
  56. package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.d.ts +2 -0
  57. package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.d.ts.map +1 -0
  58. package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.js +214 -0
  59. package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.js.map +1 -0
  60. package/dist/connector/mcp/client.d.ts +52 -1
  61. package/dist/connector/mcp/client.d.ts.map +1 -1
  62. package/dist/connector/mcp/client.js +86 -0
  63. package/dist/connector/mcp/client.js.map +1 -1
  64. package/dist/connector/mcp/discovery.d.ts +12 -1
  65. package/dist/connector/mcp/discovery.d.ts.map +1 -1
  66. package/dist/connector/mcp/discovery.js +19 -4
  67. package/dist/connector/mcp/discovery.js.map +1 -1
  68. package/dist/connector/mcp/index.d.ts +2 -2
  69. package/dist/connector/mcp/index.d.ts.map +1 -1
  70. package/dist/connector/mcp/index.js +1 -1
  71. package/dist/connector/mcp/index.js.map +1 -1
  72. package/dist/connector/mcp/server.d.ts +42 -1
  73. package/dist/connector/mcp/server.d.ts.map +1 -1
  74. package/dist/connector/mcp/server.js +77 -4
  75. package/dist/connector/mcp/server.js.map +1 -1
  76. package/dist/manager/agent/__tests__/depth-limit-authority.test.d.ts +2 -0
  77. package/dist/manager/agent/__tests__/depth-limit-authority.test.d.ts.map +1 -0
  78. package/dist/manager/agent/__tests__/depth-limit-authority.test.js +58 -0
  79. package/dist/manager/agent/__tests__/depth-limit-authority.test.js.map +1 -0
  80. package/dist/plugin/__tests__/discovery-scopes.test.d.ts +2 -0
  81. package/dist/plugin/__tests__/discovery-scopes.test.d.ts.map +1 -0
  82. package/dist/plugin/__tests__/discovery-scopes.test.js +97 -0
  83. package/dist/plugin/__tests__/discovery-scopes.test.js.map +1 -0
  84. package/dist/plugin/__tests__/enable-contributions.test.js +5 -1
  85. package/dist/plugin/__tests__/enable-contributions.test.js.map +1 -1
  86. package/dist/plugin/__tests__/mcp-admission.test.d.ts +2 -0
  87. package/dist/plugin/__tests__/mcp-admission.test.d.ts.map +1 -0
  88. package/dist/plugin/__tests__/mcp-admission.test.js +192 -0
  89. package/dist/plugin/__tests__/mcp-admission.test.js.map +1 -0
  90. package/dist/plugin/lifecycle.d.ts +41 -0
  91. package/dist/plugin/lifecycle.d.ts.map +1 -1
  92. package/dist/plugin/lifecycle.js +29 -1
  93. package/dist/plugin/lifecycle.js.map +1 -1
  94. package/dist/plugin/loader.d.ts +39 -3
  95. package/dist/plugin/loader.d.ts.map +1 -1
  96. package/dist/plugin/loader.js +37 -4
  97. package/dist/plugin/loader.js.map +1 -1
  98. package/dist/public-runtime.d.ts +5 -3
  99. package/dist/public-runtime.d.ts.map +1 -1
  100. package/dist/public-runtime.js +11 -3
  101. package/dist/public-runtime.js.map +1 -1
  102. package/dist/public-types.d.ts +2 -2
  103. package/dist/public-types.d.ts.map +1 -1
  104. package/dist/rag/__tests__/namespace-isolation.test.d.ts +2 -0
  105. package/dist/rag/__tests__/namespace-isolation.test.d.ts.map +1 -0
  106. package/dist/rag/__tests__/namespace-isolation.test.js +80 -0
  107. package/dist/rag/__tests__/namespace-isolation.test.js.map +1 -0
  108. package/dist/rag/ingestion.d.ts.map +1 -1
  109. package/dist/rag/ingestion.js +1 -0
  110. package/dist/rag/ingestion.js.map +1 -1
  111. package/dist/rag/retriever.d.ts.map +1 -1
  112. package/dist/rag/retriever.js +2 -0
  113. package/dist/rag/retriever.js.map +1 -1
  114. package/dist/rag/vector-store.d.ts.map +1 -1
  115. package/dist/rag/vector-store.js +6 -0
  116. package/dist/rag/vector-store.js.map +1 -1
  117. package/dist/registry/tool/execute.d.ts.map +1 -1
  118. package/dist/registry/tool/execute.js +113 -109
  119. package/dist/registry/tool/execute.js.map +1 -1
  120. package/dist/runtime/query/__tests__/capability-negotiation.test.js +59 -0
  121. package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -1
  122. package/dist/runtime/query/__tests__/per-step-skills.test.d.ts +10 -0
  123. package/dist/runtime/query/__tests__/per-step-skills.test.d.ts.map +1 -0
  124. package/dist/runtime/query/__tests__/per-step-skills.test.js +122 -0
  125. package/dist/runtime/query/__tests__/per-step-skills.test.js.map +1 -0
  126. package/dist/runtime/query/__tests__/per-step-tool-choice.test.d.ts +2 -0
  127. package/dist/runtime/query/__tests__/per-step-tool-choice.test.d.ts.map +1 -0
  128. package/dist/runtime/query/__tests__/per-step-tool-choice.test.js +153 -0
  129. package/dist/runtime/query/__tests__/per-step-tool-choice.test.js.map +1 -0
  130. package/dist/runtime/query/__tests__/resume-run.test.d.ts +2 -0
  131. package/dist/runtime/query/__tests__/resume-run.test.d.ts.map +1 -0
  132. package/dist/runtime/query/__tests__/resume-run.test.js +211 -0
  133. package/dist/runtime/query/__tests__/resume-run.test.js.map +1 -0
  134. package/dist/runtime/query/executor.d.ts +1 -0
  135. package/dist/runtime/query/executor.d.ts.map +1 -1
  136. package/dist/runtime/query/executor.js +10 -1
  137. package/dist/runtime/query/executor.js.map +1 -1
  138. package/dist/runtime/query/index.d.ts +10 -0
  139. package/dist/runtime/query/index.d.ts.map +1 -1
  140. package/dist/runtime/query/index.js +26 -0
  141. package/dist/runtime/query/index.js.map +1 -1
  142. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  143. package/dist/runtime/query/iteration/index.js +118 -34
  144. package/dist/runtime/query/iteration/index.js.map +1 -1
  145. package/dist/runtime/query/iteration/phases/compaction-model-routing.test.d.ts +2 -0
  146. package/dist/runtime/query/iteration/phases/compaction-model-routing.test.d.ts.map +1 -0
  147. package/dist/runtime/query/iteration/phases/compaction-model-routing.test.js +96 -0
  148. package/dist/runtime/query/iteration/phases/compaction-model-routing.test.js.map +1 -0
  149. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  150. package/dist/runtime/query/iteration/phases/compaction.js +95 -5
  151. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  152. package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.d.ts +2 -0
  153. package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.d.ts.map +1 -0
  154. package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.js +180 -0
  155. package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.js.map +1 -0
  156. package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
  157. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  158. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  159. package/dist/runtime/query/resume-run.d.ts +70 -0
  160. package/dist/runtime/query/resume-run.d.ts.map +1 -0
  161. package/dist/runtime/query/resume-run.js +46 -0
  162. package/dist/runtime/query/resume-run.js.map +1 -0
  163. package/dist/telemetry/__tests__/model-call-span.test.d.ts +2 -0
  164. package/dist/telemetry/__tests__/model-call-span.test.d.ts.map +1 -0
  165. package/dist/telemetry/__tests__/model-call-span.test.js +147 -0
  166. package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -0
  167. package/dist/telemetry/__tests__/span-closure.test.d.ts +2 -0
  168. package/dist/telemetry/__tests__/span-closure.test.d.ts.map +1 -0
  169. package/dist/telemetry/__tests__/span-closure.test.js +124 -0
  170. package/dist/telemetry/__tests__/span-closure.test.js.map +1 -0
  171. package/dist/tools/advisory/index.js +1 -1
  172. package/dist/tools/advisory/index.js.map +1 -1
  173. package/dist/tools/builtins/__tests__/edit-commits-atomically.test.d.ts +2 -0
  174. package/dist/tools/builtins/__tests__/edit-commits-atomically.test.d.ts.map +1 -0
  175. package/dist/tools/builtins/__tests__/edit-commits-atomically.test.js +78 -0
  176. package/dist/tools/builtins/__tests__/edit-commits-atomically.test.js.map +1 -0
  177. package/dist/tools/builtins/__tests__/edit.test.js +242 -1
  178. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  179. package/dist/tools/builtins/__tests__/stale-file-refusal.test.d.ts +2 -0
  180. package/dist/tools/builtins/__tests__/stale-file-refusal.test.d.ts.map +1 -0
  181. package/dist/tools/builtins/__tests__/stale-file-refusal.test.js +187 -0
  182. package/dist/tools/builtins/__tests__/stale-file-refusal.test.js.map +1 -0
  183. package/dist/tools/builtins/atomic-write-file.d.ts +1 -1
  184. package/dist/tools/builtins/atomic-write-file.d.ts.map +1 -1
  185. package/dist/tools/builtins/atomic-write-file.js +31 -2
  186. package/dist/tools/builtins/atomic-write-file.js.map +1 -1
  187. package/dist/tools/builtins/content-fingerprint.d.ts +23 -0
  188. package/dist/tools/builtins/content-fingerprint.d.ts.map +1 -0
  189. package/dist/tools/builtins/content-fingerprint.js +30 -0
  190. package/dist/tools/builtins/content-fingerprint.js.map +1 -0
  191. package/dist/tools/builtins/edit.d.ts.map +1 -1
  192. package/dist/tools/builtins/edit.js +172 -31
  193. package/dist/tools/builtins/edit.js.map +1 -1
  194. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  195. package/dist/tools/builtins/read-file.js +5 -2
  196. package/dist/tools/builtins/read-file.js.map +1 -1
  197. package/dist/tools/builtins/write-file.d.ts.map +1 -1
  198. package/dist/tools/builtins/write-file.js +75 -27
  199. package/dist/tools/builtins/write-file.js.map +1 -1
  200. package/dist/tools/coordinator/__tests__/ask-user-question.test.js +55 -0
  201. package/dist/tools/coordinator/__tests__/ask-user-question.test.js.map +1 -1
  202. package/dist/tools/coordinator/__tests__/plan-dependencies.test.d.ts +2 -0
  203. package/dist/tools/coordinator/__tests__/plan-dependencies.test.d.ts.map +1 -0
  204. package/dist/tools/coordinator/__tests__/plan-dependencies.test.js +126 -0
  205. package/dist/tools/coordinator/__tests__/plan-dependencies.test.js.map +1 -0
  206. package/dist/tools/coordinator/index.d.ts.map +1 -1
  207. package/dist/tools/coordinator/index.js +75 -5
  208. package/dist/tools/coordinator/index.js.map +1 -1
  209. package/dist/tools/coordinator/plan-dependencies.d.ts +43 -0
  210. package/dist/tools/coordinator/plan-dependencies.d.ts.map +1 -0
  211. package/dist/tools/coordinator/plan-dependencies.js +148 -0
  212. package/dist/tools/coordinator/plan-dependencies.js.map +1 -0
  213. package/dist/types/agent/supervisor.d.ts +15 -0
  214. package/dist/types/agent/supervisor.d.ts.map +1 -1
  215. package/dist/types/connector/core.d.ts +34 -0
  216. package/dist/types/connector/core.d.ts.map +1 -1
  217. package/dist/types/connector/definition.d.ts +10 -0
  218. package/dist/types/connector/definition.d.ts.map +1 -1
  219. package/dist/types/connector/mcp.d.ts +13 -0
  220. package/dist/types/connector/mcp.d.ts.map +1 -1
  221. package/dist/types/rag/retrieval.d.ts +16 -0
  222. package/dist/types/rag/retrieval.d.ts.map +1 -1
  223. package/dist/types/rag/storage.d.ts +9 -0
  224. package/dist/types/rag/storage.d.ts.map +1 -1
  225. package/dist/types/rag/vector.d.ts +11 -0
  226. package/dist/types/rag/vector.d.ts.map +1 -1
  227. package/dist/types/router/task-router.d.ts +19 -0
  228. package/dist/types/router/task-router.d.ts.map +1 -1
  229. package/dist/types/run/prepare-step.d.ts +56 -3
  230. package/dist/types/run/prepare-step.d.ts.map +1 -1
  231. package/dist/types/tool/index.d.ts +17 -1
  232. package/dist/types/tool/index.d.ts.map +1 -1
  233. package/dist/types/tool/index.js.map +1 -1
  234. package/dist/types/toolset/index.d.ts +22 -0
  235. package/dist/types/toolset/index.d.ts.map +1 -1
  236. package/package.json +1 -1
  237. package/src/advisory/__tests__/consultation-context.test.ts +191 -0
  238. package/src/advisory/context.ts +32 -0
  239. package/src/advisory/executor.ts +30 -3
  240. package/src/compaction/__tests__/context-reducer.test.ts +239 -0
  241. package/src/compaction/factory.ts +7 -0
  242. package/src/compaction/index.ts +8 -0
  243. package/src/compaction/interface.ts +13 -0
  244. package/src/compaction/managers/null.ts +3 -0
  245. package/src/compaction/managers/slidingWindow.ts +6 -0
  246. package/src/compaction/managers/structured.ts +10 -0
  247. package/src/compaction/reducer.ts +154 -0
  248. package/src/connector/builtins/__tests__/oauth2-auth.test.ts +73 -0
  249. package/src/connector/builtins/http.test.ts +28 -2
  250. package/src/connector/builtins/http.ts +26 -2
  251. package/src/connector/index.ts +6 -2
  252. package/src/connector/mcp/__tests__/prompts-and-lifecycle.test.ts +286 -0
  253. package/src/connector/mcp/client.ts +95 -0
  254. package/src/connector/mcp/discovery.ts +19 -4
  255. package/src/connector/mcp/index.ts +6 -2
  256. package/src/connector/mcp/server.ts +101 -3
  257. package/src/manager/agent/__tests__/depth-limit-authority.test.ts +74 -0
  258. package/src/plugin/__tests__/discovery-scopes.test.ts +133 -0
  259. package/src/plugin/__tests__/enable-contributions.test.ts +5 -1
  260. package/src/plugin/__tests__/mcp-admission.test.ts +242 -0
  261. package/src/plugin/lifecycle.ts +57 -1
  262. package/src/plugin/loader.ts +57 -3
  263. package/src/public-runtime.ts +16 -1
  264. package/src/public-types.ts +5 -0
  265. package/src/rag/__tests__/namespace-isolation.test.ts +109 -0
  266. package/src/rag/ingestion.ts +1 -0
  267. package/src/rag/retriever.ts +2 -0
  268. package/src/rag/vector-store.ts +5 -0
  269. package/src/registry/tool/execute.ts +123 -119
  270. package/src/runtime/query/__tests__/capability-negotiation.test.ts +69 -0
  271. package/src/runtime/query/__tests__/per-step-skills.test.ts +154 -0
  272. package/src/runtime/query/__tests__/per-step-tool-choice.test.ts +180 -0
  273. package/src/runtime/query/__tests__/resume-run.test.ts +262 -0
  274. package/src/runtime/query/executor.ts +9 -1
  275. package/src/runtime/query/index.ts +39 -0
  276. package/src/runtime/query/iteration/index.ts +130 -34
  277. package/src/runtime/query/iteration/phases/compaction-model-routing.test.ts +125 -0
  278. package/src/runtime/query/iteration/phases/compaction.ts +106 -5
  279. package/src/runtime/query/iteration/phases/context-reducer-dispatch.test.ts +238 -0
  280. package/src/runtime/query/iteration/phases/context.ts +11 -0
  281. package/src/runtime/query/resume-run.ts +93 -0
  282. package/src/telemetry/__tests__/model-call-span.test.ts +189 -0
  283. package/src/telemetry/__tests__/span-closure.test.ts +153 -0
  284. package/src/tools/advisory/index.ts +1 -1
  285. package/src/tools/builtins/__tests__/edit-commits-atomically.test.ts +103 -0
  286. package/src/tools/builtins/__tests__/edit.test.ts +324 -1
  287. package/src/tools/builtins/__tests__/stale-file-refusal.test.ts +258 -0
  288. package/src/tools/builtins/atomic-write-file.ts +30 -2
  289. package/src/tools/builtins/content-fingerprint.ts +33 -0
  290. package/src/tools/builtins/edit.ts +188 -36
  291. package/src/tools/builtins/read-file.ts +5 -2
  292. package/src/tools/builtins/write-file.ts +79 -28
  293. package/src/tools/coordinator/__tests__/ask-user-question.test.ts +68 -0
  294. package/src/tools/coordinator/__tests__/plan-dependencies.test.ts +186 -0
  295. package/src/tools/coordinator/index.ts +116 -42
  296. package/src/tools/coordinator/plan-dependencies.ts +175 -0
  297. package/src/types/agent/supervisor.ts +15 -0
  298. package/src/types/connector/core.ts +34 -0
  299. package/src/types/connector/definition.ts +10 -0
  300. package/src/types/connector/mcp.ts +14 -0
  301. package/src/types/rag/retrieval.ts +16 -0
  302. package/src/types/rag/storage.ts +9 -0
  303. package/src/types/rag/vector.ts +11 -0
  304. package/src/types/router/task-router.ts +19 -0
  305. package/src/types/run/prepare-step.ts +58 -3
  306. package/src/types/tool/index.ts +17 -1
  307. package/src/types/toolset/index.ts +22 -0
@@ -0,0 +1,258 @@
1
+ import { lstatSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ import type { FileReadTracker, ToolContext } from '../../../types/tool/index.js'
7
+ import { atomicWriteFile } from '../atomic-write-file.js'
8
+ import { fingerprintContent } from '../content-fingerprint.js'
9
+ import { EditTool } from '../edit.js'
10
+
11
+ /**
12
+ * The mutation lock serializes THIS runtime's writers. It cannot see a
13
+ * person editing in an editor, another process, or a second agent run —
14
+ * and an edit computed against a body that has since moved is a lost
15
+ * update whichever of those did the moving.
16
+ *
17
+ * Worse than undetected, it was misreported: an `old_string` that no
18
+ * longer matches came back as "not found in file — make sure the string
19
+ * matches exactly", which tells the agent its input was wrong when the
20
+ * file changed underneath it. The agent then retries the same edit
21
+ * against the same moved file.
22
+ */
23
+
24
+ function trackerOver(store: Map<string, string>): FileReadTracker {
25
+ return {
26
+ recordRead: (key, content) => {
27
+ if (content !== undefined) store.set(key, fingerprintContent(content))
28
+ },
29
+ hasRead: (key) => store.has(key),
30
+ fingerprint: (key) => store.get(key),
31
+ }
32
+ }
33
+
34
+ function contextWith(workingDirectory: string, tracker?: FileReadTracker): ToolContext {
35
+ return {
36
+ runId: 'run_stale' as ToolContext['runId'],
37
+ workingDirectory,
38
+ abortSignal: new AbortController().signal,
39
+ env: {},
40
+ log: () => {},
41
+ ...(tracker ? { fileReadTracker: tracker } : {}),
42
+ }
43
+ }
44
+
45
+ describe('an edit against a file that moved is refused, not applied', () => {
46
+ it('refuses when the body changed since the agent read it', async () => {
47
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'))
48
+ const file = join(dir, 'doc.md')
49
+ writeFileSync(file, 'alpha\nbeta\n')
50
+
51
+ const store = new Map<string, string>()
52
+ const tracker = trackerOver(store)
53
+ tracker.recordRead(file, 'alpha\nbeta\n')
54
+
55
+ // Somebody else rewrites the very line this edit anchors on.
56
+ writeFileSync(file, 'alpha\nBETA WAS REWRITTEN\n')
57
+
58
+ const result = await EditTool.execute(
59
+ { path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false },
60
+ contextWith(dir, tracker),
61
+ )
62
+
63
+ expect(result.success).toBe(false)
64
+ expect(result.error).toContain('changed on disk after you read it')
65
+ // The refusal has to be actionable, and "nothing was written" is the
66
+ // part that stops the agent re-running the same edit blind.
67
+ expect(result.error).toContain('Read the file again')
68
+ expect(result.error).toContain('Nothing was written')
69
+ expect(readFileSync(file, 'utf-8')).toBe('alpha\nBETA WAS REWRITTEN\n')
70
+ })
71
+
72
+ it('applies normally when the file is untouched', async () => {
73
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'))
74
+ const file = join(dir, 'doc.md')
75
+ writeFileSync(file, 'alpha\nbeta\n')
76
+
77
+ const tracker = trackerOver(new Map())
78
+ tracker.recordRead(file, 'alpha\nbeta\n')
79
+
80
+ const result = await EditTool.execute(
81
+ { path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false },
82
+ contextWith(dir, tracker),
83
+ )
84
+
85
+ expect(result.success).toBe(true)
86
+ expect(readFileSync(file, 'utf-8')).toBe('alpha\ngamma\n')
87
+ })
88
+
89
+ it('lets a second edit in the same turn proceed against what the first wrote', async () => {
90
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'))
91
+ const file = join(dir, 'doc.md')
92
+ writeFileSync(file, 'alpha\nbeta\n')
93
+
94
+ const tracker = trackerOver(new Map())
95
+ tracker.recordRead(file, 'alpha\nbeta\n')
96
+ const context = contextWith(dir, tracker)
97
+
98
+ const first = await EditTool.execute(
99
+ { path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false },
100
+ context,
101
+ )
102
+ // Without re-fingerprinting after a successful write, this second edit
103
+ // would see its own predecessor's output as someone else's drift.
104
+ const second = await EditTool.execute(
105
+ { path: 'doc.md', old_string: 'alpha', new_string: 'omega', replace_all: false },
106
+ context,
107
+ )
108
+
109
+ expect(first.success).toBe(true)
110
+ expect(second.success).toBe(true)
111
+ expect(readFileSync(file, 'utf-8')).toBe('omega\ngamma\n')
112
+ })
113
+
114
+ it('proceeds when the host tracks nothing, which is the older contract', async () => {
115
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'))
116
+ writeFileSync(join(dir, 'doc.md'), 'alpha\nbeta\n')
117
+
118
+ // `fingerprint` is optional. A host that never captured one must not
119
+ // have every edit refused.
120
+ const result = await EditTool.execute(
121
+ { path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false },
122
+ contextWith(dir),
123
+ )
124
+
125
+ expect(result.success).toBe(true)
126
+ })
127
+
128
+ it('proceeds when the path was never read, so there is nothing to be stale against', async () => {
129
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'))
130
+ writeFileSync(join(dir, 'doc.md'), 'alpha\nbeta\n')
131
+
132
+ const result = await EditTool.execute(
133
+ { path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false },
134
+ contextWith(dir, trackerOver(new Map())),
135
+ )
136
+
137
+ expect(result.success).toBe(true)
138
+ })
139
+ })
140
+
141
+ describe('the fingerprint distinguishes bodies', () => {
142
+ it('is stable for identical content', () => {
143
+ expect(fingerprintContent('alpha\n')).toBe(fingerprintContent('alpha\n'))
144
+ })
145
+
146
+ it('differs on a one-character change', () => {
147
+ expect(fingerprintContent('alpha\n')).not.toBe(fingerprintContent('alphb\n'))
148
+ })
149
+
150
+ it('differs on trailing whitespace, which a diff would also treat as a change', () => {
151
+ expect(fingerprintContent('alpha')).not.toBe(fingerprintContent('alpha '))
152
+ })
153
+ })
154
+
155
+ describe('an atomic commit writes through a symlink rather than over it', () => {
156
+ /** Creating one needs a privilege this platform does not always grant. */
157
+ function symlinkOrSkip(target: string, link: string): boolean {
158
+ try {
159
+ symlinkSync(target, link)
160
+ return true
161
+ } catch {
162
+ return false
163
+ }
164
+ }
165
+
166
+ it('keeps the link and updates its target', async () => {
167
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-symlink-'))
168
+ const real = join(dir, 'real.md')
169
+ const link = join(dir, 'link.md')
170
+ writeFileSync(real, 'original\n')
171
+ if (!symlinkOrSkip(real, link)) return
172
+
173
+ await atomicWriteFile(link, 'replaced\n')
174
+
175
+ // `rename` onto the link path would have swapped the link for a plain
176
+ // file, silently detaching every other path that pointed through it.
177
+ expect(lstatSync(link).isSymbolicLink()).toBe(true)
178
+ expect(readFileSync(real, 'utf-8')).toBe('replaced\n')
179
+ expect(readFileSync(link, 'utf-8')).toBe('replaced\n')
180
+ })
181
+
182
+ it('still creates a file that does not exist yet', async () => {
183
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-symlink-'))
184
+ const fresh = join(dir, 'new.md')
185
+
186
+ await atomicWriteFile(fresh, 'created\n')
187
+
188
+ expect(readFileSync(fresh, 'utf-8')).toBe('created\n')
189
+ })
190
+ })
191
+
192
+ describe('drift that does not touch the anchor is not a conflict', () => {
193
+ it('applies when someone changed an unrelated part of the file', async () => {
194
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'))
195
+ const file = join(dir, 'doc.md')
196
+ writeFileSync(file, 'alpha\nbeta\n')
197
+
198
+ const tracker = trackerOver(new Map())
199
+ tracker.recordRead(file, 'alpha\nbeta\n')
200
+
201
+ // Someone appends a line. The edit's anchor is untouched, so the edit
202
+ // is still exactly as well defined as when it was computed —
203
+ // refusing here would reject a safe edit every time anybody wrote
204
+ // anywhere in the file.
205
+ writeFileSync(file, 'alpha\nbeta\nan unrelated new line\n')
206
+
207
+ const result = await EditTool.execute(
208
+ { path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false },
209
+ contextWith(dir, tracker),
210
+ )
211
+
212
+ expect(result.success).toBe(true)
213
+ expect(readFileSync(file, 'utf-8')).toBe('alpha\ngamma\nan unrelated new line\n')
214
+ })
215
+
216
+ it('reports a genuinely wrong anchor as wrong, not as drift', async () => {
217
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'))
218
+ const file = join(dir, 'doc.md')
219
+ writeFileSync(file, 'alpha\nbeta\n')
220
+
221
+ const tracker = trackerOver(new Map())
222
+ tracker.recordRead(file, 'alpha\nbeta\n')
223
+
224
+ // Nothing moved. The anchor is simply not in the file, and telling
225
+ // the agent to re-read would send it to look for a change that never
226
+ // happened.
227
+ const result = await EditTool.execute(
228
+ { path: 'doc.md', old_string: 'nowhere', new_string: 'x', replace_all: false },
229
+ contextWith(dir, tracker),
230
+ )
231
+
232
+ expect(result.success).toBe(false)
233
+ expect(result.error).toContain('not found in file')
234
+ expect(result.error).not.toContain('changed on disk')
235
+ })
236
+
237
+ it('reports drift when the anchor became ambiguous because of it', async () => {
238
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'))
239
+ const file = join(dir, 'doc.md')
240
+ writeFileSync(file, 'alpha\nbeta\n')
241
+
242
+ const tracker = trackerOver(new Map())
243
+ tracker.recordRead(file, 'alpha\nbeta\n')
244
+
245
+ // A second copy of the anchor appears. "not unique — add more
246
+ // context" would send the agent to widen an anchor that was unique
247
+ // when it read the file.
248
+ writeFileSync(file, 'alpha\nbeta\nbeta\n')
249
+
250
+ const result = await EditTool.execute(
251
+ { path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false },
252
+ contextWith(dir, tracker),
253
+ )
254
+
255
+ expect(result.success).toBe(false)
256
+ expect(result.error).toContain('changed on disk')
257
+ })
258
+ })
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { type FileHandle, mkdir, open, rename, stat, unlink } from 'node:fs/promises'
2
+ import { type FileHandle, mkdir, open, realpath, rename, stat, unlink } from 'node:fs/promises'
3
3
  import { basename, dirname, join } from 'node:path'
4
4
 
5
5
  interface AtomicWriteHooks {
@@ -14,10 +14,17 @@ interface AtomicWriteHooks {
14
14
  * filesystem. A failed pre-commit write leaves the original untouched.
15
15
  */
16
16
  export async function atomicWriteFile(
17
- filePath: string,
17
+ requestedPath: string,
18
18
  content: string,
19
19
  hooks: AtomicWriteHooks = {},
20
20
  ): Promise<void> {
21
+ // Write THROUGH a symlink, not over it. `rename` replaces whatever sits
22
+ // at the destination, so committing onto a link path swaps the link for
23
+ // a regular file: the link is gone, and every other path that pointed
24
+ // through it now reads stale content. Resolving first keeps the link and
25
+ // updates what it points at, which is what a caller editing a linked
26
+ // file means. A path that does not exist yet resolves to itself.
27
+ const filePath = await realpath(requestedPath).catch(() => requestedPath)
21
28
  const directory = dirname(filePath)
22
29
  await mkdir(directory, { recursive: true })
23
30
 
@@ -47,11 +54,32 @@ export async function atomicWriteFile(
47
54
  }
48
55
  }
49
56
 
57
+ /**
58
+ * Best-effort, and only after the rename has already committed.
59
+ *
60
+ * Syncing the directory is what makes the RENAME survive a power loss on a
61
+ * POSIX filesystem. Not every platform permits it — opening a directory and
62
+ * calling fsync raises `EPERM` on Windows — and there the whole builtin was
63
+ * unusable: every `edit` and every `write` failed with `EPERM: operation not
64
+ * permitted, fsync` after correctly writing the file.
65
+ *
66
+ * Swallowing is right here and nowhere else in this file. By this point the
67
+ * destination already holds the complete new body; the only thing still at
68
+ * stake is durability across an OS-level crash, and on a platform that
69
+ * refuses the call there is no second way to ask. Failing the write would
70
+ * trade a durability refinement for a guaranteed error on every write.
71
+ *
72
+ * The sibling in `utils/atomic-write.ts` — the one the disk stores use, and
73
+ * the one that has run on this platform all along — does not sync the
74
+ * directory at all.
75
+ */
50
76
  async function syncDirectory(directory: string): Promise<void> {
51
77
  let handle: FileHandle | undefined
52
78
  try {
53
79
  handle = await open(directory, 'r')
54
80
  await handle.sync()
81
+ } catch {
82
+ // Unsupported on this platform; the rename already landed.
55
83
  } finally {
56
84
  await handle?.close().catch(() => undefined)
57
85
  }
@@ -0,0 +1,33 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /**
4
+ * A short, stable fingerprint of a file body.
5
+ *
6
+ * Used to answer one question at mutation time: is the file still what the
7
+ * agent read? An `edit` is computed against a remembered body, and between
8
+ * the read and the write that body can move — a person editing in an
9
+ * editor, another process, a second agent run. The in-process mutation lock
10
+ * serializes this runtime's own writers and is blind to every one of those.
11
+ *
12
+ * Without this the drift is not merely undetected, it is actively
13
+ * misreported: an `old_string` that no longer matches comes back as "not
14
+ * found in file — make sure the string matches exactly", which tells the
15
+ * agent its input was wrong when the file changed underneath it. The agent
16
+ * then retries the same edit against the same moved file.
17
+ *
18
+ * Truncated deliberately. This distinguishes two bodies; it is not a
19
+ * security boundary, and a full digest per read costs bytes in a structure
20
+ * that lives for the whole run.
21
+ */
22
+ export function fingerprintContent(content: string): string {
23
+ return createHash('sha256').update(content, 'utf-8').digest('hex').slice(0, 16)
24
+ }
25
+
26
+ /** What a mutation tool tells the agent when the file moved under it. */
27
+ export function staleFileError(path: string): string {
28
+ return (
29
+ `${path} changed on disk after you read it, so this edit was computed against a stale copy ` +
30
+ 'and was not applied. Read the file again and redo the edit against its current contents. ' +
31
+ 'Nothing was written.'
32
+ )
33
+ }
@@ -1,17 +1,38 @@
1
- import { readFile, writeFile } from 'node:fs/promises'
1
+ import { readFile } from 'node:fs/promises'
2
2
  import { resolve } from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import { defineTool } from '../defineTool.js'
5
+ import { atomicWriteFile } from './atomic-write-file.js'
6
+ import { fingerprintContent, staleFileError } from './content-fingerprint.js'
7
+ import { withFileMutationLock } from './file-mutation-lock.js'
5
8
 
9
+ /**
10
+ * Two schemas, on purpose.
11
+ *
12
+ * `inputSchema` is what a HOST may send, and it accepts the `oldStr`/`newStr`
13
+ * aliases and `insertLine` because hosts that expose replacement under those
14
+ * names are real. `modelInputSchema` below is what a MODEL is constrained to
15
+ * — one closed canonical shape — because giving a model four spellings of the
16
+ * same field is how it learns to guess between them.
17
+ *
18
+ * `.strict()` is what makes the accepted set closed. Without it zod's default
19
+ * is to STRIP an unknown key, so a hallucinated or misspelled field is
20
+ * silently dropped and the edit proceeds against an input nobody wrote.
21
+ */
6
22
  const inputSchema = z
7
23
  .object({
8
- path: z.string().describe('Path to the file to edit'),
24
+ path: z
25
+ .string()
26
+ .refine((value) => value.trim().length > 0, 'Path must not be empty.')
27
+ .describe('Path to the file to edit. Must not be empty.'),
9
28
  old_string: z
10
29
  .string()
30
+ .min(1)
11
31
  .optional()
12
32
  .describe('The exact string to find and replace. Must be unique in the file.'),
13
33
  oldStr: z
14
34
  .string()
35
+ .min(1)
15
36
  .optional()
16
37
  .describe(
17
38
  'Alias for old_string. Used by hosts that expose text replacement as oldStr/newStr.',
@@ -39,6 +60,7 @@ const inputSchema = z
39
60
  .default(false)
40
61
  .describe('Replace all occurrences instead of just the first unique match'),
41
62
  })
63
+ .strict()
42
64
  .refine((value) => typeof value.new_string === 'string' || typeof value.newStr === 'string', {
43
65
  message: 'Either new_string or newStr is required.',
44
66
  })
@@ -52,6 +74,40 @@ const inputSchema = z
52
74
 
53
75
  type EditInput = z.infer<typeof inputSchema>
54
76
 
77
+ /**
78
+ * What a capable provider constrains the model to emit: one shape, closed.
79
+ *
80
+ * The aliases above exist for hosts, not for models. A model offered
81
+ * `old_string` and `oldStr` as separate optional fields has to guess which
82
+ * one this deployment wants, and `additionalProperties: false` is what turns
83
+ * an invented field into a generation-time refusal rather than a silent drop.
84
+ */
85
+ const modelInputSchema: Record<string, unknown> = {
86
+ type: 'object',
87
+ properties: {
88
+ path: {
89
+ type: 'string',
90
+ description: 'Path to the file to edit. Must not be empty.',
91
+ },
92
+ old_string: {
93
+ type: 'string',
94
+ description:
95
+ 'Exact unique text from the file, without read-tool line-number prefixes. Must not be empty.',
96
+ },
97
+ new_string: {
98
+ type: 'string',
99
+ description:
100
+ 'Exact replacement text. May be empty to delete old_string. Keep under 12000 characters.',
101
+ },
102
+ replace_all: {
103
+ type: 'boolean',
104
+ description: 'Replace every occurrence instead of requiring one unique match.',
105
+ },
106
+ },
107
+ required: ['path', 'old_string', 'new_string'],
108
+ additionalProperties: false,
109
+ }
110
+
55
111
  type NormalizedEditInput =
56
112
  | {
57
113
  operation: 'replace'
@@ -71,6 +127,10 @@ export const EditTool = defineTool({
71
127
  description:
72
128
  'Makes targeted edits to a file using exact string find-and-replace or line insertion. THIS IS THE PREFERRED WAY TO MODIFY AN EXISTING FILE — never reach for `write` to change a file that already exists, because `write` overwrites the whole body and discards earlier work on partial failure. `edit` keeps the rest of the file byte-for-byte intact and is recoverable: if a single edit fails (old_string/oldStr ambiguous, broader restructuring needed), follow up with another `edit` instead of re-emitting the entire file via `write`. The old_string/oldStr must be unique in the file unless replace_all is true. For insertions, pass insertLine plus new_string/newStr; use insertLine: "end" to extend a file at the end. Self-budget new_string/newStr under 12000 characters before emitting the tool call; use repeated bounded edits for long sections. Preserves file formatting and indentation.',
73
129
  inputSchema,
130
+ modelInputSchema,
131
+ enforceModelInput: true,
132
+ validationErrorHint:
133
+ 'Required shape: {"path":"file.md","old_string":"exact unique text","new_string":"replacement text"}. Optional: "replace_all": true.',
74
134
  category: 'filesystem',
75
135
  permissions: ['file_write'],
76
136
  readOnly: false,
@@ -78,7 +138,19 @@ export const EditTool = defineTool({
78
138
  concurrencySafe: false,
79
139
 
80
140
  async execute(input: EditInput, context) {
81
- const normalized = normalizeEditInput(input)
141
+ // Re-validated here rather than trusted from the registry: `execute` is
142
+ // reachable directly, and the closed contract is only closed if the
143
+ // check runs on the path a caller can actually take.
144
+ const parsed = inputSchema.safeParse(input)
145
+ if (!parsed.success) {
146
+ return {
147
+ success: false,
148
+ output: '',
149
+ error: `Invalid edit input: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`,
150
+ }
151
+ }
152
+
153
+ const normalized = normalizeEditInput(parsed.data)
82
154
  if (!normalized.success) {
83
155
  return { success: false, output: '', error: normalized.error }
84
156
  }
@@ -93,38 +165,75 @@ export const EditTool = defineTool({
93
165
  }
94
166
  }
95
167
 
96
- // Sandbox-aware: route through sandbox when available
97
- if (context.sandbox) {
98
- const buffer = await context.sandbox.readFile(input.path)
99
- const content = buffer.toString('utf-8')
168
+ const filePath = resolve(context.workingDirectory, parsed.data.path)
169
+ // Read-modify-write is not atomic on its own: two edits to the same
170
+ // path interleave their reads, and the second write lands on content
171
+ // the first had already replaced — so one edit vanishes and the loser
172
+ // reports "old_string not found", blaming the model for a race. The
173
+ // key spans both branches because sandbox and local are distinct
174
+ // files even when the path string matches.
175
+ const lockKey = `${context.sandbox ? 'sandbox' : 'local'}:${filePath}`
176
+
177
+ return withFileMutationLock(lockKey, async () => {
178
+ if (context.sandbox) {
179
+ const buffer = await context.sandbox.readFile(parsed.data.path)
180
+ const result = applyEdit(buffer.toString('utf-8'), normalized.operation)
181
+ if (!result.success) {
182
+ return { success: false as const, output: '', error: result.error }
183
+ }
184
+
185
+ await context.sandbox.writeFile(parsed.data.path, result.content)
186
+ return {
187
+ success: true as const,
188
+ output: `Edited ${parsed.data.path}: ${result.replacements} replacement(s) [sandboxed]`,
189
+ data: { path: parsed.data.path, replacements: result.replacements, sandboxed: true },
190
+ }
191
+ }
192
+
193
+ const content = await readFile(filePath, 'utf-8')
100
194
 
101
195
  const result = applyEdit(content, normalized.operation)
102
196
  if (!result.success) {
103
- return { success: false, output: '', error: result.error }
197
+ // The anchor did not match what is on disk. Two very different
198
+ // situations produce that, and the difference is the whole
199
+ // value of the fingerprint:
200
+ //
201
+ // The file is as the agent left it, and the anchor is simply
202
+ // wrong — "not found, match the whitespace exactly" is the
203
+ // right thing to say.
204
+ //
205
+ // Or the file moved after the read — a person in an editor,
206
+ // another process, a second agent — and the same message is a
207
+ // lie that sends the agent to re-check text that was never the
208
+ // problem, so it retries the identical edit against the same
209
+ // moved file.
210
+ //
211
+ // Deliberately NOT a gate on the successful path. An anchor
212
+ // that still matches uniquely is well defined however much
213
+ // changed elsewhere in the file, and refusing there would
214
+ // reject safe edits every time anyone touched an unrelated
215
+ // line.
216
+ const seen = context.fileReadTracker?.fingerprint?.(filePath)
217
+ if (seen !== undefined && seen !== fingerprintContent(content)) {
218
+ return { success: false as const, output: '', error: staleFileError(filePath) }
219
+ }
220
+ return { success: false as const, output: '', error: result.error }
104
221
  }
105
222
 
106
- await context.sandbox.writeFile(input.path, result.content)
223
+ // Temp file, fsync, rename — a reader sees the old body or the new
224
+ // one, never a half-written one. A plain `writeFile` that fails
225
+ // partway leaves the user's source truncated.
226
+ await atomicWriteFile(filePath, result.content)
227
+ // This runtime is now the last writer, so the next edit in the same
228
+ // turn compares against what we just wrote rather than the read
229
+ // before it.
230
+ context.fileReadTracker?.recordRead(filePath, result.content)
107
231
  return {
108
- success: true,
109
- output: `Edited ${input.path}: ${result.replacements} replacement(s) [sandboxed]`,
110
- data: { path: input.path, replacements: result.replacements, sandboxed: true },
232
+ success: true as const,
233
+ output: `Edited ${filePath}: ${result.replacements} replacement(s)`,
234
+ data: { path: filePath, replacements: result.replacements },
111
235
  }
112
- }
113
-
114
- const filePath = resolve(context.workingDirectory, input.path)
115
- const content = await readFile(filePath, 'utf-8')
116
-
117
- const result = applyEdit(content, normalized.operation)
118
- if (!result.success) {
119
- return { success: false, output: '', error: result.error }
120
- }
121
-
122
- await writeFile(filePath, result.content, 'utf-8')
123
- return {
124
- success: true,
125
- output: `Edited ${filePath}: ${result.replacements} replacement(s)`,
126
- data: { path: filePath, replacements: result.replacements },
127
- }
236
+ })
128
237
  },
129
238
  })
130
239
 
@@ -188,7 +297,9 @@ function applyEdit(
188
297
  return applyLineInsert(content, input)
189
298
  }
190
299
 
191
- if (!content.includes(input.oldString)) {
300
+ const replacement = normalizeLineEndings(content, input)
301
+
302
+ if (!content.includes(replacement.oldString)) {
192
303
  return {
193
304
  success: false,
194
305
  error:
@@ -196,19 +307,19 @@ function applyEdit(
196
307
  }
197
308
  }
198
309
 
199
- if (input.replace_all) {
200
- const parts = content.split(input.oldString)
310
+ if (replacement.replace_all) {
311
+ const parts = content.split(replacement.oldString)
201
312
  const replacements = parts.length - 1
202
313
  return {
203
314
  success: true,
204
- content: parts.join(input.newString),
315
+ content: parts.join(replacement.newString),
205
316
  replacements,
206
317
  }
207
318
  }
208
319
 
209
320
  // Uniqueness check: old_string/oldStr must appear exactly once
210
- const firstIndex = content.indexOf(input.oldString)
211
- const secondIndex = content.indexOf(input.oldString, firstIndex + 1)
321
+ const firstIndex = content.indexOf(replacement.oldString)
322
+ const secondIndex = content.indexOf(replacement.oldString, firstIndex + 1)
212
323
 
213
324
  if (secondIndex !== -1) {
214
325
  const lineNumber = content.slice(0, firstIndex).split('\n').length
@@ -223,8 +334,8 @@ function applyEdit(
223
334
  success: true,
224
335
  content:
225
336
  content.slice(0, firstIndex) +
226
- input.newString +
227
- content.slice(firstIndex + input.oldString.length),
337
+ replacement.newString +
338
+ content.slice(firstIndex + replacement.oldString.length),
228
339
  replacements: 1,
229
340
  }
230
341
  }
@@ -251,3 +362,44 @@ function applyLineInsert(
251
362
  replacements: 1,
252
363
  }
253
364
  }
365
+
366
+ /**
367
+ * Reconcile the caller's line endings with the file's.
368
+ *
369
+ * A model reading a CRLF file and writing back LF (or the reverse) produces
370
+ * an `old_string` that is correct in every visible way and matches nothing.
371
+ * The failure reads as "your text is wrong" when the text is right and only
372
+ * the invisible half of each line break differs.
373
+ *
374
+ * Only applied when the file is CONSISTENT. A mixed-ending file has no
375
+ * single right answer, and rewriting boundaries there would corrupt the
376
+ * half that was already correct.
377
+ */
378
+ function normalizeLineEndings(
379
+ content: string,
380
+ input: Extract<NormalizedEditInput, { operation: 'replace' }>,
381
+ ): Extract<NormalizedEditInput, { operation: 'replace' }> {
382
+ const withoutCrlf = content.replaceAll('\r\n', '')
383
+ const usesOnlyCrlf = content.includes('\r\n') && !withoutCrlf.includes('\n')
384
+ if (usesOnlyCrlf) {
385
+ return {
386
+ ...input,
387
+ oldString: content.includes(input.oldString)
388
+ ? input.oldString
389
+ : input.oldString.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n'),
390
+ newString: input.newString.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n'),
391
+ }
392
+ }
393
+
394
+ const usesOnlyLf = content.includes('\n') && !content.includes('\r\n')
395
+ if (usesOnlyLf) {
396
+ return {
397
+ ...input,
398
+ oldString: content.includes(input.oldString)
399
+ ? input.oldString
400
+ : input.oldString.replaceAll('\r\n', '\n'),
401
+ newString: input.newString.replaceAll('\r\n', '\n'),
402
+ }
403
+ }
404
+ return input
405
+ }
@@ -55,7 +55,10 @@ export const ReadFileTool = defineTool({
55
55
 
56
56
  const numberedLines = selectedLines.map((line, i) => `${start + i + 1}\t${line}`).join('\n')
57
57
 
58
- context.fileReadTracker?.recordRead(input.path)
58
+ // The WHOLE body, not the selected window: a later edit is checked
59
+ // against the file, and a partial read must not fingerprint a
60
+ // fragment as if it were the file.
61
+ context.fileReadTracker?.recordRead(input.path, content)
59
62
 
60
63
  const partial = selectedLines.length < lines.length
61
64
  return {
@@ -92,7 +95,7 @@ export const ReadFileTool = defineTool({
92
95
 
93
96
  const numberedLines = selectedLines.map((line, i) => `${start + i + 1}\t${line}`).join('\n')
94
97
 
95
- context.fileReadTracker?.recordRead(filePath)
98
+ context.fileReadTracker?.recordRead(filePath, content)
96
99
 
97
100
  const isPartial = selectedLines.length < lines.length
98
101