Coverage for core/langgraph/tools/files_write.py: 97%
64 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-28 00:19 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-28 00:19 +0000
1"""Per-tool module for the `files-write` deterministic builtin.
3Owns the tool's model-facing definition, its instruction surface (none), and
4its atomic workspace write of `{path, content}` entries (ADR-0012/ADR-0024).
5The definition and the execution are the single source; the legacy builtin
6site delegates here.
7"""
8from __future__ import annotations
10import json
11from dataclasses import dataclass
12from pathlib import Path
13from typing import Any
15from .contract import ToolContext, ToolResult
17try:
18 from _files_output import (
19 FilesOutputError,
20 resolve_workspace_root,
21 validate_and_write_files,
22 )
23except ImportError: # pragma: no cover — main.py run (qualified imports)
24 from langgraph._files_output import (
25 FilesOutputError,
26 resolve_workspace_root,
27 validate_and_write_files,
28 )
30FILES_WRITE_TOOL_NAME = "files-write"
32FILES_WRITE_DEFINITION: dict[str, Any] = {
33 "type": "function",
34 "function": {
35 "name": FILES_WRITE_TOOL_NAME,
36 "description": (
37 "Write one or more {path, content} entries into the workspace. "
38 "Accepts a single {path, content} object or a list of them; paths "
39 "are workspace-relative. Validation is atomic: one invalid entry "
40 "(traversal, .git target, per-file/step cap, non-string fields) "
41 "rejects the whole call and nothing is written (ADR-0012)."
42 ),
43 "parameters": {
44 "type": "object",
45 "properties": {
46 "entries": {
47 "description": (
48 "A single {path, content} object or a list of them."
49 ),
50 "anyOf": [
51 {
52 "type": "object",
53 "properties": {
54 "path": {
55 "type": "string",
56 "description": (
57 "Workspace-relative target path."
58 ),
59 },
60 "content": {
61 "type": "string",
62 "description": (
63 "UTF-8 file content to write."
64 ),
65 },
66 },
67 "required": ["path", "content"],
68 "additionalProperties": False,
69 },
70 {
71 "type": "array",
72 "items": {
73 "type": "object",
74 "properties": {
75 "path": {
76 "type": "string",
77 "description": (
78 "Workspace-relative target path."
79 ),
80 },
81 "content": {
82 "type": "string",
83 "description": (
84 "UTF-8 file content to write."
85 ),
86 },
87 },
88 "required": ["path", "content"],
89 "additionalProperties": False,
90 },
91 },
92 ],
93 },
94 },
95 "required": ["entries"],
96 "additionalProperties": False,
97 },
98 },
99}
102@dataclass
103class FilesWriteResult:
104 written: list[str]
105 count: int
106 error: str | None
107 details: dict[str, Any] | None
109 def as_tool_message(self) -> str:
110 payload: dict[str, Any] = {
111 "name": FILES_WRITE_TOOL_NAME,
112 "ok": self.error is None,
113 "written": self.written,
114 "count": self.count,
115 }
116 if self.error is not None:
117 payload["error"] = self.error
118 return json.dumps(payload, ensure_ascii=False)
121def _files_write_error(step_id: str, message: str) -> FilesWriteResult:
122 return FilesWriteResult(
123 [], 0, f"files-write step '{step_id}': {message}", None
124 )
127def _normalize_entries(
128 step_id: str, entries: Any
129) -> tuple[list | None, FilesWriteResult | None]:
130 if entries is None or entries == []:
131 return None, _files_write_error(
132 step_id,
133 "'params.entries' is required — single "
134 "{path, content} object or a list",
135 )
136 if isinstance(entries, dict):
137 return [entries], None
138 if not isinstance(entries, list):
139 return None, _files_write_error(
140 step_id,
141 f"'params.entries' must be a single "
142 f"{{path, content}} object or a list, got {type(entries).__name__}",
143 )
144 return entries, None
147def _write_validated_entries(
148 step_id: str, entries: list, workspace_root: Path
149) -> FilesWriteResult | None:
150 try:
151 validate_and_write_files(entries, workspace_root)
152 except FilesOutputError as exc:
153 return _files_write_error(step_id, str(exc))
154 return None
157def files_write(
158 entries: Any, workspace_root: Path, step_id: str
159) -> FilesWriteResult:
160 """Atomically write `{path, content}` entries into the workspace
161 (ADR-0012/ADR-0024). Every rejected write returns an explicit error
162 carrying its `details`; it never raises.
163 """
164 normalized, failure = _normalize_entries(step_id, entries)
165 if failure is not None:
166 return failure
167 failure = _write_validated_entries(step_id, normalized, workspace_root)
168 if failure is not None:
169 return failure
170 written = [entry["path"] for entry in normalized]
171 return FilesWriteResult(written, len(written), None, None)
174class _FilesWriteTool:
175 name = FILES_WRITE_TOOL_NAME
177 def definition(self) -> dict[str, Any]:
178 return FILES_WRITE_DEFINITION
180 def instruction(self) -> str | None:
181 return None
183 def params(self) -> list[dict[str, Any]] | None:
184 return None
186 def execute(self, context: ToolContext) -> ToolResult:
187 step = context.step or {}
188 step_id = step["id"]
189 entries = (context.args or {}).get("entries")
190 workspace_root = resolve_workspace_root(context.state or {})
191 res = files_write(entries, workspace_root, step_id)
192 if res.error is not None:
193 return ToolResult(ok=False, content=res.as_tool_message(), error=res.error)
194 return ToolResult(
195 ok=True,
196 content=res.as_tool_message(),
197 state_patch={step_id: {"written": res.written, "count": res.count}},
198 )
201TOOL = _FilesWriteTool()