@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,187 @@
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
+ import { atomicWriteFile } from '../atomic-write-file.js';
6
+ import { fingerprintContent } from '../content-fingerprint.js';
7
+ import { EditTool } from '../edit.js';
8
+ /**
9
+ * The mutation lock serializes THIS runtime's writers. It cannot see a
10
+ * person editing in an editor, another process, or a second agent run —
11
+ * and an edit computed against a body that has since moved is a lost
12
+ * update whichever of those did the moving.
13
+ *
14
+ * Worse than undetected, it was misreported: an `old_string` that no
15
+ * longer matches came back as "not found in file — make sure the string
16
+ * matches exactly", which tells the agent its input was wrong when the
17
+ * file changed underneath it. The agent then retries the same edit
18
+ * against the same moved file.
19
+ */
20
+ function trackerOver(store) {
21
+ return {
22
+ recordRead: (key, content) => {
23
+ if (content !== undefined)
24
+ store.set(key, fingerprintContent(content));
25
+ },
26
+ hasRead: (key) => store.has(key),
27
+ fingerprint: (key) => store.get(key),
28
+ };
29
+ }
30
+ function contextWith(workingDirectory, tracker) {
31
+ return {
32
+ runId: 'run_stale',
33
+ workingDirectory,
34
+ abortSignal: new AbortController().signal,
35
+ env: {},
36
+ log: () => { },
37
+ ...(tracker ? { fileReadTracker: tracker } : {}),
38
+ };
39
+ }
40
+ describe('an edit against a file that moved is refused, not applied', () => {
41
+ it('refuses when the body changed since the agent read it', async () => {
42
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'));
43
+ const file = join(dir, 'doc.md');
44
+ writeFileSync(file, 'alpha\nbeta\n');
45
+ const store = new Map();
46
+ const tracker = trackerOver(store);
47
+ tracker.recordRead(file, 'alpha\nbeta\n');
48
+ // Somebody else rewrites the very line this edit anchors on.
49
+ writeFileSync(file, 'alpha\nBETA WAS REWRITTEN\n');
50
+ const result = await EditTool.execute({ path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false }, contextWith(dir, tracker));
51
+ expect(result.success).toBe(false);
52
+ expect(result.error).toContain('changed on disk after you read it');
53
+ // The refusal has to be actionable, and "nothing was written" is the
54
+ // part that stops the agent re-running the same edit blind.
55
+ expect(result.error).toContain('Read the file again');
56
+ expect(result.error).toContain('Nothing was written');
57
+ expect(readFileSync(file, 'utf-8')).toBe('alpha\nBETA WAS REWRITTEN\n');
58
+ });
59
+ it('applies normally when the file is untouched', async () => {
60
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'));
61
+ const file = join(dir, 'doc.md');
62
+ writeFileSync(file, 'alpha\nbeta\n');
63
+ const tracker = trackerOver(new Map());
64
+ tracker.recordRead(file, 'alpha\nbeta\n');
65
+ const result = await EditTool.execute({ path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false }, contextWith(dir, tracker));
66
+ expect(result.success).toBe(true);
67
+ expect(readFileSync(file, 'utf-8')).toBe('alpha\ngamma\n');
68
+ });
69
+ it('lets a second edit in the same turn proceed against what the first wrote', async () => {
70
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'));
71
+ const file = join(dir, 'doc.md');
72
+ writeFileSync(file, 'alpha\nbeta\n');
73
+ const tracker = trackerOver(new Map());
74
+ tracker.recordRead(file, 'alpha\nbeta\n');
75
+ const context = contextWith(dir, tracker);
76
+ const first = await EditTool.execute({ path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false }, context);
77
+ // Without re-fingerprinting after a successful write, this second edit
78
+ // would see its own predecessor's output as someone else's drift.
79
+ const second = await EditTool.execute({ path: 'doc.md', old_string: 'alpha', new_string: 'omega', replace_all: false }, context);
80
+ expect(first.success).toBe(true);
81
+ expect(second.success).toBe(true);
82
+ expect(readFileSync(file, 'utf-8')).toBe('omega\ngamma\n');
83
+ });
84
+ it('proceeds when the host tracks nothing, which is the older contract', async () => {
85
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'));
86
+ writeFileSync(join(dir, 'doc.md'), 'alpha\nbeta\n');
87
+ // `fingerprint` is optional. A host that never captured one must not
88
+ // have every edit refused.
89
+ const result = await EditTool.execute({ path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false }, contextWith(dir));
90
+ expect(result.success).toBe(true);
91
+ });
92
+ it('proceeds when the path was never read, so there is nothing to be stale against', async () => {
93
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'));
94
+ writeFileSync(join(dir, 'doc.md'), 'alpha\nbeta\n');
95
+ const result = await EditTool.execute({ path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false }, contextWith(dir, trackerOver(new Map())));
96
+ expect(result.success).toBe(true);
97
+ });
98
+ });
99
+ describe('the fingerprint distinguishes bodies', () => {
100
+ it('is stable for identical content', () => {
101
+ expect(fingerprintContent('alpha\n')).toBe(fingerprintContent('alpha\n'));
102
+ });
103
+ it('differs on a one-character change', () => {
104
+ expect(fingerprintContent('alpha\n')).not.toBe(fingerprintContent('alphb\n'));
105
+ });
106
+ it('differs on trailing whitespace, which a diff would also treat as a change', () => {
107
+ expect(fingerprintContent('alpha')).not.toBe(fingerprintContent('alpha '));
108
+ });
109
+ });
110
+ describe('an atomic commit writes through a symlink rather than over it', () => {
111
+ /** Creating one needs a privilege this platform does not always grant. */
112
+ function symlinkOrSkip(target, link) {
113
+ try {
114
+ symlinkSync(target, link);
115
+ return true;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ it('keeps the link and updates its target', async () => {
122
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-symlink-'));
123
+ const real = join(dir, 'real.md');
124
+ const link = join(dir, 'link.md');
125
+ writeFileSync(real, 'original\n');
126
+ if (!symlinkOrSkip(real, link))
127
+ return;
128
+ await atomicWriteFile(link, 'replaced\n');
129
+ // `rename` onto the link path would have swapped the link for a plain
130
+ // file, silently detaching every other path that pointed through it.
131
+ expect(lstatSync(link).isSymbolicLink()).toBe(true);
132
+ expect(readFileSync(real, 'utf-8')).toBe('replaced\n');
133
+ expect(readFileSync(link, 'utf-8')).toBe('replaced\n');
134
+ });
135
+ it('still creates a file that does not exist yet', async () => {
136
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-symlink-'));
137
+ const fresh = join(dir, 'new.md');
138
+ await atomicWriteFile(fresh, 'created\n');
139
+ expect(readFileSync(fresh, 'utf-8')).toBe('created\n');
140
+ });
141
+ });
142
+ describe('drift that does not touch the anchor is not a conflict', () => {
143
+ it('applies when someone changed an unrelated part of the file', async () => {
144
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'));
145
+ const file = join(dir, 'doc.md');
146
+ writeFileSync(file, 'alpha\nbeta\n');
147
+ const tracker = trackerOver(new Map());
148
+ tracker.recordRead(file, 'alpha\nbeta\n');
149
+ // Someone appends a line. The edit's anchor is untouched, so the edit
150
+ // is still exactly as well defined as when it was computed —
151
+ // refusing here would reject a safe edit every time anybody wrote
152
+ // anywhere in the file.
153
+ writeFileSync(file, 'alpha\nbeta\nan unrelated new line\n');
154
+ const result = await EditTool.execute({ path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false }, contextWith(dir, tracker));
155
+ expect(result.success).toBe(true);
156
+ expect(readFileSync(file, 'utf-8')).toBe('alpha\ngamma\nan unrelated new line\n');
157
+ });
158
+ it('reports a genuinely wrong anchor as wrong, not as drift', async () => {
159
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'));
160
+ const file = join(dir, 'doc.md');
161
+ writeFileSync(file, 'alpha\nbeta\n');
162
+ const tracker = trackerOver(new Map());
163
+ tracker.recordRead(file, 'alpha\nbeta\n');
164
+ // Nothing moved. The anchor is simply not in the file, and telling
165
+ // the agent to re-read would send it to look for a change that never
166
+ // happened.
167
+ const result = await EditTool.execute({ path: 'doc.md', old_string: 'nowhere', new_string: 'x', replace_all: false }, contextWith(dir, tracker));
168
+ expect(result.success).toBe(false);
169
+ expect(result.error).toContain('not found in file');
170
+ expect(result.error).not.toContain('changed on disk');
171
+ });
172
+ it('reports drift when the anchor became ambiguous because of it', async () => {
173
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-stale-'));
174
+ const file = join(dir, 'doc.md');
175
+ writeFileSync(file, 'alpha\nbeta\n');
176
+ const tracker = trackerOver(new Map());
177
+ tracker.recordRead(file, 'alpha\nbeta\n');
178
+ // A second copy of the anchor appears. "not unique — add more
179
+ // context" would send the agent to widen an anchor that was unique
180
+ // when it read the file.
181
+ writeFileSync(file, 'alpha\nbeta\nbeta\n');
182
+ const result = await EditTool.execute({ path: 'doc.md', old_string: 'beta', new_string: 'gamma', replace_all: false }, contextWith(dir, tracker));
183
+ expect(result.success).toBe(false);
184
+ expect(result.error).toContain('changed on disk');
185
+ });
186
+ });
187
+ //# sourceMappingURL=stale-file-refusal.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stale-file-refusal.test.js","sourceRoot":"","sources":["../../../../src/tools/builtins/__tests__/stale-file-refusal.test.ts"],"names":[],"mappings":"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"}
@@ -8,6 +8,6 @@ interface AtomicWriteHooks {
8
8
  * The temp file lives beside the destination so rename stays on one
9
9
  * filesystem. A failed pre-commit write leaves the original untouched.
10
10
  */
11
- export declare function atomicWriteFile(filePath: string, content: string, hooks?: AtomicWriteHooks): Promise<void>;
11
+ export declare function atomicWriteFile(requestedPath: string, content: string, hooks?: AtomicWriteHooks): Promise<void>;
12
12
  export {};
13
13
  //# sourceMappingURL=atomic-write-file.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"atomic-write-file.d.ts","sourceRoot":"","sources":["../../../src/tools/builtins/atomic-write-file.ts"],"names":[],"mappings":"AAIA,UAAU,gBAAgB;IACzB,yEAAyE;IACzE,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAClC;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CACpC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,KAAK,GAAE,gBAAqB,GAC1B,OAAO,CAAC,IAAI,CAAC,CA4Bf"}
1
+ {"version":3,"file":"atomic-write-file.d.ts","sourceRoot":"","sources":["../../../src/tools/builtins/atomic-write-file.ts"],"names":[],"mappings":"AAIA,UAAU,gBAAgB;IACzB,yEAAyE;IACzE,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAClC;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CACpC,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM,EACf,KAAK,GAAE,gBAAqB,GAC1B,OAAO,CAAC,IAAI,CAAC,CAmCf"}
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, open, rename, stat, unlink } from 'node:fs/promises';
2
+ import { mkdir, open, realpath, rename, stat, unlink } from 'node:fs/promises';
3
3
  import { basename, dirname, join } from 'node:path';
4
4
  /**
5
5
  * Commit a complete file body without exposing a truncated destination.
@@ -7,7 +7,14 @@ import { basename, dirname, join } from 'node:path';
7
7
  * The temp file lives beside the destination so rename stays on one
8
8
  * filesystem. A failed pre-commit write leaves the original untouched.
9
9
  */
10
- export async function atomicWriteFile(filePath, content, hooks = {}) {
10
+ export async function atomicWriteFile(requestedPath, content, hooks = {}) {
11
+ // Write THROUGH a symlink, not over it. `rename` replaces whatever sits
12
+ // at the destination, so committing onto a link path swaps the link for
13
+ // a regular file: the link is gone, and every other path that pointed
14
+ // through it now reads stale content. Resolving first keeps the link and
15
+ // updates what it points at, which is what a caller editing a linked
16
+ // file means. A path that does not exist yet resolves to itself.
17
+ const filePath = await realpath(requestedPath).catch(() => requestedPath);
11
18
  const directory = dirname(filePath);
12
19
  await mkdir(directory, { recursive: true });
13
20
  const existingMode = await stat(filePath)
@@ -33,12 +40,34 @@ export async function atomicWriteFile(filePath, content, hooks = {}) {
33
40
  await unlink(tempPath).catch(() => undefined);
34
41
  }
35
42
  }
43
+ /**
44
+ * Best-effort, and only after the rename has already committed.
45
+ *
46
+ * Syncing the directory is what makes the RENAME survive a power loss on a
47
+ * POSIX filesystem. Not every platform permits it — opening a directory and
48
+ * calling fsync raises `EPERM` on Windows — and there the whole builtin was
49
+ * unusable: every `edit` and every `write` failed with `EPERM: operation not
50
+ * permitted, fsync` after correctly writing the file.
51
+ *
52
+ * Swallowing is right here and nowhere else in this file. By this point the
53
+ * destination already holds the complete new body; the only thing still at
54
+ * stake is durability across an OS-level crash, and on a platform that
55
+ * refuses the call there is no second way to ask. Failing the write would
56
+ * trade a durability refinement for a guaranteed error on every write.
57
+ *
58
+ * The sibling in `utils/atomic-write.ts` — the one the disk stores use, and
59
+ * the one that has run on this platform all along — does not sync the
60
+ * directory at all.
61
+ */
36
62
  async function syncDirectory(directory) {
37
63
  let handle;
38
64
  try {
39
65
  handle = await open(directory, 'r');
40
66
  await handle.sync();
41
67
  }
68
+ catch {
69
+ // Unsupported on this platform; the rename already landed.
70
+ }
42
71
  finally {
43
72
  await handle?.close().catch(() => undefined);
44
73
  }
@@ -1 +1 @@
1
- {"version":3,"file":"atomic-write-file.js","sourceRoot":"","sources":["../../../src/tools/builtins/atomic-write-file.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAmB,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AACrF,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAOnD;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACpC,QAAgB,EAChB,OAAe,EACf,QAA0B,EAAE;IAE5B,MAAM,SAAS,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IACnC,MAAM,KAAK,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAE3C,MAAM,YAAY,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC;SACvC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC;SAC3B,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;IACxB,MAAM,QAAQ,GAAG,IAAI,CACpB,SAAS,EACT,IAAI,QAAQ,CAAC,QAAQ,CAAC,UAAU,OAAO,CAAC,GAAG,IAAI,UAAU,EAAE,MAAM,CACjE,CAAA;IACD,IAAI,MAA8B,CAAA;IAClC,IAAI,SAAS,GAAG,KAAK,CAAA;IAErB,IAAI,CAAC;QACJ,MAAM,GAAG,MAAM,IAAI,CAAC,QAAQ,EAAE,IAAI,EAAE,YAAY,IAAI,KAAK,CAAC,CAAA;QAC1D,MAAM,MAAM,CAAC,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;QACxC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAA;QACnB,MAAM,MAAM,CAAC,KAAK,EAAE,CAAA;QACpB,MAAM,GAAG,SAAS,CAAA;QAClB,MAAM,KAAK,CAAC,YAAY,EAAE,EAAE,CAAA;QAC5B,MAAM,MAAM,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAA;QAChC,SAAS,GAAG,IAAI,CAAA;QAChB,MAAM,aAAa,CAAC,SAAS,CAAC,CAAA;IAC/B,CAAC;YAAS,CAAC;QACV,MAAM,MAAM,EAAE,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;QAC5C,IAAI,CAAC,SAAS;YAAE,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;IAC9D,CAAC;AACF,CAAC;AAED,KAAK,UAAU,aAAa,CAAC,SAAiB;IAC7C,IAAI,MAA8B,CAAA;IAClC,IAAI,CAAC;QACJ,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,EAAE,GAAG,CAAC,CAAA;QACnC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAA;IACpB,CAAC;YAAS,CAAC;QACV,MAAM,MAAM,EAAE,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;IAC7C,CAAC;AACF,CAAC"}
1
+ {"version":3,"file":"atomic-write-file.js","sourceRoot":"","sources":["../../../src/tools/builtins/atomic-write-file.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAmB,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAC/F,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAOnD;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACpC,aAAqB,EACrB,OAAe,EACf,QAA0B,EAAE;IAE5B,wEAAwE;IACxE,wEAAwE;IACxE,sEAAsE;IACtE,yEAAyE;IACzE,qEAAqE;IACrE,iEAAiE;IACjE,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,aAAa,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,aAAa,CAAC,CAAA;IACzE,MAAM,SAAS,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IACnC,MAAM,KAAK,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAE3C,MAAM,YAAY,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC;SACvC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC;SAC3B,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;IACxB,MAAM,QAAQ,GAAG,IAAI,CACpB,SAAS,EACT,IAAI,QAAQ,CAAC,QAAQ,CAAC,UAAU,OAAO,CAAC,GAAG,IAAI,UAAU,EAAE,MAAM,CACjE,CAAA;IACD,IAAI,MAA8B,CAAA;IAClC,IAAI,SAAS,GAAG,KAAK,CAAA;IAErB,IAAI,CAAC;QACJ,MAAM,GAAG,MAAM,IAAI,CAAC,QAAQ,EAAE,IAAI,EAAE,YAAY,IAAI,KAAK,CAAC,CAAA;QAC1D,MAAM,MAAM,CAAC,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;QACxC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAA;QACnB,MAAM,MAAM,CAAC,KAAK,EAAE,CAAA;QACpB,MAAM,GAAG,SAAS,CAAA;QAClB,MAAM,KAAK,CAAC,YAAY,EAAE,EAAE,CAAA;QAC5B,MAAM,MAAM,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAA;QAChC,SAAS,GAAG,IAAI,CAAA;QAChB,MAAM,aAAa,CAAC,SAAS,CAAC,CAAA;IAC/B,CAAC;YAAS,CAAC;QACV,MAAM,MAAM,EAAE,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;QAC5C,IAAI,CAAC,SAAS;YAAE,MAAM,MAAM,CAAC,QAAQ,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;IAC9D,CAAC;AACF,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,KAAK,UAAU,aAAa,CAAC,SAAiB;IAC7C,IAAI,MAA8B,CAAA;IAClC,IAAI,CAAC;QACJ,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,EAAE,GAAG,CAAC,CAAA;QACnC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAA;IACpB,CAAC;IAAC,MAAM,CAAC;QACR,2DAA2D;IAC5D,CAAC;YAAS,CAAC;QACV,MAAM,MAAM,EAAE,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;IAC7C,CAAC;AACF,CAAC"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A short, stable fingerprint of a file body.
3
+ *
4
+ * Used to answer one question at mutation time: is the file still what the
5
+ * agent read? An `edit` is computed against a remembered body, and between
6
+ * the read and the write that body can move — a person editing in an
7
+ * editor, another process, a second agent run. The in-process mutation lock
8
+ * serializes this runtime's own writers and is blind to every one of those.
9
+ *
10
+ * Without this the drift is not merely undetected, it is actively
11
+ * misreported: an `old_string` that no longer matches comes back as "not
12
+ * found in file — make sure the string matches exactly", which tells the
13
+ * agent its input was wrong when the file changed underneath it. The agent
14
+ * then retries the same edit against the same moved file.
15
+ *
16
+ * Truncated deliberately. This distinguishes two bodies; it is not a
17
+ * security boundary, and a full digest per read costs bytes in a structure
18
+ * that lives for the whole run.
19
+ */
20
+ export declare function fingerprintContent(content: string): string;
21
+ /** What a mutation tool tells the agent when the file moved under it. */
22
+ export declare function staleFileError(path: string): string;
23
+ //# sourceMappingURL=content-fingerprint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-fingerprint.d.ts","sourceRoot":"","sources":["../../../src/tools/builtins/content-fingerprint.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE1D;AAED,yEAAyE;AACzE,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMnD"}
@@ -0,0 +1,30 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * A short, stable fingerprint of a file body.
4
+ *
5
+ * Used to answer one question at mutation time: is the file still what the
6
+ * agent read? An `edit` is computed against a remembered body, and between
7
+ * the read and the write that body can move — a person editing in an
8
+ * editor, another process, a second agent run. The in-process mutation lock
9
+ * serializes this runtime's own writers and is blind to every one of those.
10
+ *
11
+ * Without this the drift is not merely undetected, it is actively
12
+ * misreported: an `old_string` that no longer matches comes back as "not
13
+ * found in file — make sure the string matches exactly", which tells the
14
+ * agent its input was wrong when the file changed underneath it. The agent
15
+ * then retries the same edit against the same moved file.
16
+ *
17
+ * Truncated deliberately. This distinguishes two bodies; it is not a
18
+ * security boundary, and a full digest per read costs bytes in a structure
19
+ * that lives for the whole run.
20
+ */
21
+ export function fingerprintContent(content) {
22
+ return createHash('sha256').update(content, 'utf-8').digest('hex').slice(0, 16);
23
+ }
24
+ /** What a mutation tool tells the agent when the file moved under it. */
25
+ export function staleFileError(path) {
26
+ return (`${path} changed on disk after you read it, so this edit was computed against a stale copy ` +
27
+ 'and was not applied. Read the file again and redo the edit against its current contents. ' +
28
+ 'Nothing was written.');
29
+ }
30
+ //# sourceMappingURL=content-fingerprint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-fingerprint.js","sourceRoot":"","sources":["../../../src/tools/builtins/content-fingerprint.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAExC;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAAe;IACjD,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;AAChF,CAAC;AAED,yEAAyE;AACzE,MAAM,UAAU,cAAc,CAAC,IAAY;IAC1C,OAAO,CACN,GAAG,IAAI,qFAAqF;QAC5F,2FAA2F;QAC3F,sBAAsB,CACtB,CAAA;AACF,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"edit.d.ts","sourceRoot":"","sources":["../../../src/tools/builtins/edit.ts"],"names":[],"mappings":"AAoEA,eAAO,MAAM,QAAQ;;;;;;;;EA4DnB,CAAA"}
1
+ {"version":3,"file":"edit.d.ts","sourceRoot":"","sources":["../../../src/tools/builtins/edit.ts"],"names":[],"mappings":"AA4HA,eAAO,MAAM,QAAQ;;;;;;;;EAiHnB,CAAA"}
@@ -1,16 +1,37 @@
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';
8
+ /**
9
+ * Two schemas, on purpose.
10
+ *
11
+ * `inputSchema` is what a HOST may send, and it accepts the `oldStr`/`newStr`
12
+ * aliases and `insertLine` because hosts that expose replacement under those
13
+ * names are real. `modelInputSchema` below is what a MODEL is constrained to
14
+ * — one closed canonical shape — because giving a model four spellings of the
15
+ * same field is how it learns to guess between them.
16
+ *
17
+ * `.strict()` is what makes the accepted set closed. Without it zod's default
18
+ * is to STRIP an unknown key, so a hallucinated or misspelled field is
19
+ * silently dropped and the edit proceeds against an input nobody wrote.
20
+ */
5
21
  const inputSchema = z
6
22
  .object({
7
- path: z.string().describe('Path to the file to edit'),
23
+ path: z
24
+ .string()
25
+ .refine((value) => value.trim().length > 0, 'Path must not be empty.')
26
+ .describe('Path to the file to edit. Must not be empty.'),
8
27
  old_string: z
9
28
  .string()
29
+ .min(1)
10
30
  .optional()
11
31
  .describe('The exact string to find and replace. Must be unique in the file.'),
12
32
  oldStr: z
13
33
  .string()
34
+ .min(1)
14
35
  .optional()
15
36
  .describe('Alias for old_string. Used by hosts that expose text replacement as oldStr/newStr.'),
16
37
  new_string: z
@@ -30,23 +51,69 @@ const inputSchema = z
30
51
  .default(false)
31
52
  .describe('Replace all occurrences instead of just the first unique match'),
32
53
  })
54
+ .strict()
33
55
  .refine((value) => typeof value.new_string === 'string' || typeof value.newStr === 'string', {
34
56
  message: 'Either new_string or newStr is required.',
35
57
  })
36
58
  .refine((value) => value.insertLine !== undefined ||
37
59
  typeof value.old_string === 'string' ||
38
60
  typeof value.oldStr === 'string', { message: 'Either old_string/oldStr or insertLine is required.' });
61
+ /**
62
+ * What a capable provider constrains the model to emit: one shape, closed.
63
+ *
64
+ * The aliases above exist for hosts, not for models. A model offered
65
+ * `old_string` and `oldStr` as separate optional fields has to guess which
66
+ * one this deployment wants, and `additionalProperties: false` is what turns
67
+ * an invented field into a generation-time refusal rather than a silent drop.
68
+ */
69
+ const modelInputSchema = {
70
+ type: 'object',
71
+ properties: {
72
+ path: {
73
+ type: 'string',
74
+ description: 'Path to the file to edit. Must not be empty.',
75
+ },
76
+ old_string: {
77
+ type: 'string',
78
+ description: 'Exact unique text from the file, without read-tool line-number prefixes. Must not be empty.',
79
+ },
80
+ new_string: {
81
+ type: 'string',
82
+ description: 'Exact replacement text. May be empty to delete old_string. Keep under 12000 characters.',
83
+ },
84
+ replace_all: {
85
+ type: 'boolean',
86
+ description: 'Replace every occurrence instead of requiring one unique match.',
87
+ },
88
+ },
89
+ required: ['path', 'old_string', 'new_string'],
90
+ additionalProperties: false,
91
+ };
39
92
  export const EditTool = defineTool({
40
93
  name: 'edit',
41
94
  description: '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.',
42
95
  inputSchema,
96
+ modelInputSchema,
97
+ enforceModelInput: true,
98
+ validationErrorHint: 'Required shape: {"path":"file.md","old_string":"exact unique text","new_string":"replacement text"}. Optional: "replace_all": true.',
43
99
  category: 'filesystem',
44
100
  permissions: ['file_write'],
45
101
  readOnly: false,
46
102
  destructive: false,
47
103
  concurrencySafe: false,
48
104
  async execute(input, context) {
49
- const normalized = normalizeEditInput(input);
105
+ // Re-validated here rather than trusted from the registry: `execute` is
106
+ // reachable directly, and the closed contract is only closed if the
107
+ // check runs on the path a caller can actually take.
108
+ const parsed = inputSchema.safeParse(input);
109
+ if (!parsed.success) {
110
+ return {
111
+ success: false,
112
+ output: '',
113
+ error: `Invalid edit input: ${parsed.error.issues.map((issue) => issue.message).join('; ')}`,
114
+ };
115
+ }
116
+ const normalized = normalizeEditInput(parsed.data);
50
117
  if (!normalized.success) {
51
118
  return { success: false, output: '', error: normalized.error };
52
119
  }
@@ -58,33 +125,70 @@ export const EditTool = defineTool({
58
125
  error: 'old_string/oldStr and new_string/newStr are identical — no change needed',
59
126
  };
60
127
  }
61
- // Sandbox-aware: route through sandbox when available
62
- if (context.sandbox) {
63
- const buffer = await context.sandbox.readFile(input.path);
64
- const content = buffer.toString('utf-8');
128
+ const filePath = resolve(context.workingDirectory, parsed.data.path);
129
+ // Read-modify-write is not atomic on its own: two edits to the same
130
+ // path interleave their reads, and the second write lands on content
131
+ // the first had already replaced — so one edit vanishes and the loser
132
+ // reports "old_string not found", blaming the model for a race. The
133
+ // key spans both branches because sandbox and local are distinct
134
+ // files even when the path string matches.
135
+ const lockKey = `${context.sandbox ? 'sandbox' : 'local'}:${filePath}`;
136
+ return withFileMutationLock(lockKey, async () => {
137
+ if (context.sandbox) {
138
+ const buffer = await context.sandbox.readFile(parsed.data.path);
139
+ const result = applyEdit(buffer.toString('utf-8'), normalized.operation);
140
+ if (!result.success) {
141
+ return { success: false, output: '', error: result.error };
142
+ }
143
+ await context.sandbox.writeFile(parsed.data.path, result.content);
144
+ return {
145
+ success: true,
146
+ output: `Edited ${parsed.data.path}: ${result.replacements} replacement(s) [sandboxed]`,
147
+ data: { path: parsed.data.path, replacements: result.replacements, sandboxed: true },
148
+ };
149
+ }
150
+ const content = await readFile(filePath, 'utf-8');
65
151
  const result = applyEdit(content, normalized.operation);
66
152
  if (!result.success) {
153
+ // The anchor did not match what is on disk. Two very different
154
+ // situations produce that, and the difference is the whole
155
+ // value of the fingerprint:
156
+ //
157
+ // The file is as the agent left it, and the anchor is simply
158
+ // wrong — "not found, match the whitespace exactly" is the
159
+ // right thing to say.
160
+ //
161
+ // Or the file moved after the read — a person in an editor,
162
+ // another process, a second agent — and the same message is a
163
+ // lie that sends the agent to re-check text that was never the
164
+ // problem, so it retries the identical edit against the same
165
+ // moved file.
166
+ //
167
+ // Deliberately NOT a gate on the successful path. An anchor
168
+ // that still matches uniquely is well defined however much
169
+ // changed elsewhere in the file, and refusing there would
170
+ // reject safe edits every time anyone touched an unrelated
171
+ // line.
172
+ const seen = context.fileReadTracker?.fingerprint?.(filePath);
173
+ if (seen !== undefined && seen !== fingerprintContent(content)) {
174
+ return { success: false, output: '', error: staleFileError(filePath) };
175
+ }
67
176
  return { success: false, output: '', error: result.error };
68
177
  }
69
- await context.sandbox.writeFile(input.path, result.content);
178
+ // Temp file, fsync, rename — a reader sees the old body or the new
179
+ // one, never a half-written one. A plain `writeFile` that fails
180
+ // partway leaves the user's source truncated.
181
+ await atomicWriteFile(filePath, result.content);
182
+ // This runtime is now the last writer, so the next edit in the same
183
+ // turn compares against what we just wrote rather than the read
184
+ // before it.
185
+ context.fileReadTracker?.recordRead(filePath, result.content);
70
186
  return {
71
187
  success: true,
72
- output: `Edited ${input.path}: ${result.replacements} replacement(s) [sandboxed]`,
73
- data: { path: input.path, replacements: result.replacements, sandboxed: true },
188
+ output: `Edited ${filePath}: ${result.replacements} replacement(s)`,
189
+ data: { path: filePath, replacements: result.replacements },
74
190
  };
75
- }
76
- const filePath = resolve(context.workingDirectory, input.path);
77
- const content = await readFile(filePath, 'utf-8');
78
- const result = applyEdit(content, normalized.operation);
79
- if (!result.success) {
80
- return { success: false, output: '', error: result.error };
81
- }
82
- await writeFile(filePath, result.content, 'utf-8');
83
- return {
84
- success: true,
85
- output: `Edited ${filePath}: ${result.replacements} replacement(s)`,
86
- data: { path: filePath, replacements: result.replacements },
87
- };
191
+ });
88
192
  },
89
193
  });
90
194
  function normalizeEditInput(input) {
@@ -138,24 +242,25 @@ function applyEdit(content, input) {
138
242
  if (input.operation === 'insert') {
139
243
  return applyLineInsert(content, input);
140
244
  }
141
- if (!content.includes(input.oldString)) {
245
+ const replacement = normalizeLineEndings(content, input);
246
+ if (!content.includes(replacement.oldString)) {
142
247
  return {
143
248
  success: false,
144
249
  error: 'old_string/oldStr not found in file. Make sure the string matches exactly, including whitespace and indentation.',
145
250
  };
146
251
  }
147
- if (input.replace_all) {
148
- const parts = content.split(input.oldString);
252
+ if (replacement.replace_all) {
253
+ const parts = content.split(replacement.oldString);
149
254
  const replacements = parts.length - 1;
150
255
  return {
151
256
  success: true,
152
- content: parts.join(input.newString),
257
+ content: parts.join(replacement.newString),
153
258
  replacements,
154
259
  };
155
260
  }
156
261
  // Uniqueness check: old_string/oldStr must appear exactly once
157
- const firstIndex = content.indexOf(input.oldString);
158
- const secondIndex = content.indexOf(input.oldString, firstIndex + 1);
262
+ const firstIndex = content.indexOf(replacement.oldString);
263
+ const secondIndex = content.indexOf(replacement.oldString, firstIndex + 1);
159
264
  if (secondIndex !== -1) {
160
265
  const lineNumber = content.slice(0, firstIndex).split('\n').length;
161
266
  const secondLine = content.slice(0, secondIndex).split('\n').length;
@@ -167,8 +272,8 @@ function applyEdit(content, input) {
167
272
  return {
168
273
  success: true,
169
274
  content: content.slice(0, firstIndex) +
170
- input.newString +
171
- content.slice(firstIndex + input.oldString.length),
275
+ replacement.newString +
276
+ content.slice(firstIndex + replacement.oldString.length),
172
277
  replacements: 1,
173
278
  };
174
279
  }
@@ -190,4 +295,40 @@ function applyLineInsert(content, input) {
190
295
  replacements: 1,
191
296
  };
192
297
  }
298
+ /**
299
+ * Reconcile the caller's line endings with the file's.
300
+ *
301
+ * A model reading a CRLF file and writing back LF (or the reverse) produces
302
+ * an `old_string` that is correct in every visible way and matches nothing.
303
+ * The failure reads as "your text is wrong" when the text is right and only
304
+ * the invisible half of each line break differs.
305
+ *
306
+ * Only applied when the file is CONSISTENT. A mixed-ending file has no
307
+ * single right answer, and rewriting boundaries there would corrupt the
308
+ * half that was already correct.
309
+ */
310
+ function normalizeLineEndings(content, input) {
311
+ const withoutCrlf = content.replaceAll('\r\n', '');
312
+ const usesOnlyCrlf = content.includes('\r\n') && !withoutCrlf.includes('\n');
313
+ if (usesOnlyCrlf) {
314
+ return {
315
+ ...input,
316
+ oldString: content.includes(input.oldString)
317
+ ? input.oldString
318
+ : input.oldString.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n'),
319
+ newString: input.newString.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n'),
320
+ };
321
+ }
322
+ const usesOnlyLf = content.includes('\n') && !content.includes('\r\n');
323
+ if (usesOnlyLf) {
324
+ return {
325
+ ...input,
326
+ oldString: content.includes(input.oldString)
327
+ ? input.oldString
328
+ : input.oldString.replaceAll('\r\n', '\n'),
329
+ newString: input.newString.replaceAll('\r\n', '\n'),
330
+ };
331
+ }
332
+ return input;
333
+ }
193
334
  //# sourceMappingURL=edit.js.map