| 213 | # --------------------------------------------------------------------------- |
| 214 | |
| 215 | def _write_call(tool_input: dict[str, Any], context: ToolContext) -> ToolResult: |
| 216 | file_path = tool_input["file_path"] |
| 217 | content = tool_input["content"] |
| 218 | if not isinstance(file_path, str): |
| 219 | raise ToolInputError("file_path must be a string") |
| 220 | if not isinstance(content, str): |
| 221 | raise ToolInputError("content must be a string") |
| 222 | |
| 223 | # call() receives the MODEL-ORIGINAL path (backfill is the |
| 224 | # hooks/permissions audience only); both branches below self-expand. |
| 225 | if _is_auto_memory_write(file_path): |
| 226 | # Memory dir is outside the workspace allowlist; bypass it. The |
| 227 | # auto-memory subsystem owns this path namespace and its own |
| 228 | # safety rails (sanitize_path, NFC, trailing-sep prefix check |
| 229 | # in is_auto_mem_path). |
| 230 | path = Path(_expand_path(file_path)) |
| 231 | else: |
| 232 | path = context.ensure_allowed_path(file_path) |
| 233 | |
| 234 | original_file: str | None = None |
| 235 | if path.exists(): |
| 236 | # validate_input already performed the rich staleness check. |
| 237 | # Double-check here as a safety net in case validate_input was |
| 238 | # bypassed or the file changed between validation and call. |
| 239 | if not context.was_file_read_and_unchanged(path): |
| 240 | raise ToolInputError( |
| 241 | "File has been modified since read, either by the user or by a " |
| 242 | "linter. Read it again before attempting to write it." |
| 243 | ) |
| 244 | original_file = path.read_text(encoding="utf-8", errors="replace") |
| 245 | |
| 246 | path.parent.mkdir(parents=True, exist_ok=True) |
| 247 | path.write_text(content, encoding="utf-8") |
| 248 | context.mark_file_read(path) |
| 249 | before_lines = (original_file or "").splitlines(keepends=True) |
| 250 | after_lines = content.splitlines(keepends=True) |
| 251 | diff_lines = list( |
| 252 | difflib.unified_diff( |
| 253 | before_lines, |
| 254 | after_lines, |
| 255 | fromfile=str(path), |
| 256 | tofile=str(path), |
| 257 | n=3, |
| 258 | lineterm="", |
| 259 | ) |
| 260 | ) |
| 261 | hunks = unified_diff_hunks(diff_lines) |
| 262 | return ToolResult( |
| 263 | name="Write", |
| 264 | output={ |
| 265 | "type": "update" if original_file is not None else "create", |
| 266 | # MODEL-ORIGINAL path, not the resolved one: tool results embed |
| 267 | # input fields verbatim (TS FileWriteTool.ts:377/:400 uses |
| 268 | # file_path for data.filePath; fullFilePath is fs/logging only). |
| 269 | "filePath": file_path, |
| 270 | "content": content, |
| 271 | "structuredPatch": hunks, |
| 272 | "originalFile": original_file, |