mycode-coding-agent 0.1.0__py3-none-any.whl

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 (121) hide show
  1. mycode/__init__.py +0 -0
  2. mycode/adapters/__init__.py +21 -0
  3. mycode/adapters/jsonl.py +692 -0
  4. mycode/agent/__init__.py +25 -0
  5. mycode/agent/events.py +111 -0
  6. mycode/agent/outcome.py +103 -0
  7. mycode/agent/progress.py +373 -0
  8. mycode/agent/runner.py +1481 -0
  9. mycode/application/__init__.py +38 -0
  10. mycode/application/agent_session.py +367 -0
  11. mycode/application/events.py +59 -0
  12. mycode/application/runtime.py +211 -0
  13. mycode/application/sessions.py +180 -0
  14. mycode/cli.py +840 -0
  15. mycode/config.py +355 -0
  16. mycode/context/__init__.py +1 -0
  17. mycode/context/artifacts.py +672 -0
  18. mycode/context/budget.py +752 -0
  19. mycode/context/builder.py +112 -0
  20. mycode/context/compact.py +795 -0
  21. mycode/context/tool_result_format.py +199 -0
  22. mycode/context/tool_result_retention.py +261 -0
  23. mycode/conversation.py +78 -0
  24. mycode/error_handling.py +481 -0
  25. mycode/event_format.py +147 -0
  26. mycode/instructions.py +285 -0
  27. mycode/llm.py +771 -0
  28. mycode/mcp/__init__.py +41 -0
  29. mycode/mcp/client.py +44 -0
  30. mycode/mcp/config.py +207 -0
  31. mycode/mcp/errors.py +302 -0
  32. mycode/mcp/manager.py +339 -0
  33. mycode/mcp/models.py +20 -0
  34. mycode/mcp/result_adapter.py +58 -0
  35. mycode/mcp/tool_adapter.py +145 -0
  36. mycode/mcp/trust.py +313 -0
  37. mycode/memory.py +570 -0
  38. mycode/memory_context.py +245 -0
  39. mycode/messages.py +63 -0
  40. mycode/observability.py +28 -0
  41. mycode/permissions.py +262 -0
  42. mycode/persistence/__init__.py +1 -0
  43. mycode/persistence/filesystem.py +291 -0
  44. mycode/persistence/project_storage.py +208 -0
  45. mycode/persistence/session_lock.py +138 -0
  46. mycode/persistence/session_store.py +503 -0
  47. mycode/presentation/__init__.py +1 -0
  48. mycode/presentation/cli/__init__.py +14 -0
  49. mycode/presentation/cli/confirmer.py +116 -0
  50. mycode/presentation/cli/mcp_trust.py +61 -0
  51. mycode/presentation/cli/presenter.py +320 -0
  52. mycode/presentation/cli/session_menu.py +146 -0
  53. mycode/presentation/cli/subagent_observer.py +124 -0
  54. mycode/presentation/command_format.py +90 -0
  55. mycode/presentation/commands.py +95 -0
  56. mycode/presentation/tui/__init__.py +6 -0
  57. mycode/presentation/tui/app.py +1351 -0
  58. mycode/presentation/tui/interactions.py +253 -0
  59. mycode/presentation/tui/presenter.py +266 -0
  60. mycode/presentation/tui/screens.py +305 -0
  61. mycode/presentation/tui/widgets.py +214 -0
  62. mycode/project.py +22 -0
  63. mycode/prompts.py +181 -0
  64. mycode/reasoning.py +40 -0
  65. mycode/session.py +86 -0
  66. mycode/skills/__init__.py +27 -0
  67. mycode/skills/builtin/database-recovery/SKILL.md +138 -0
  68. mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
  69. mycode/skills/registry.py +295 -0
  70. mycode/skills/state.py +68 -0
  71. mycode/subagents/__init__.py +1 -0
  72. mycode/subagents/audit.py +212 -0
  73. mycode/subagents/concurrency.py +124 -0
  74. mycode/subagents/contracts.py +421 -0
  75. mycode/subagents/delegate.py +80 -0
  76. mycode/subagents/delegation.py +128 -0
  77. mycode/subagents/lifecycle.py +86 -0
  78. mycode/subagents/limits.py +7 -0
  79. mycode/subagents/observability.py +150 -0
  80. mycode/subagents/persistence.py +152 -0
  81. mycode/subagents/profiles.py +184 -0
  82. mycode/subagents/prompts.py +67 -0
  83. mycode/subagents/results.py +178 -0
  84. mycode/subagents/runtime.py +528 -0
  85. mycode/subagents/snapshots.py +211 -0
  86. mycode/subagents/tool_batch.py +260 -0
  87. mycode/tools/__init__.py +81 -0
  88. mycode/tools/base.py +222 -0
  89. mycode/tools/bounds.py +14 -0
  90. mycode/tools/command_executor.py +167 -0
  91. mycode/tools/command_output.py +166 -0
  92. mycode/tools/command_risk.py +596 -0
  93. mycode/tools/defaults.py +59 -0
  94. mycode/tools/edit_file.py +524 -0
  95. mycode/tools/file_mutation.py +30 -0
  96. mycode/tools/glob.py +247 -0
  97. mycode/tools/grep.py +324 -0
  98. mycode/tools/ignore.py +122 -0
  99. mycode/tools/inspect_changes.py +269 -0
  100. mycode/tools/load_skill.py +92 -0
  101. mycode/tools/memory.py +264 -0
  102. mycode/tools/path_permissions.py +78 -0
  103. mycode/tools/patterns.py +48 -0
  104. mycode/tools/permission_metadata.py +27 -0
  105. mycode/tools/process_tree.py +166 -0
  106. mycode/tools/read_file.py +242 -0
  107. mycode/tools/read_skill_resource.py +93 -0
  108. mycode/tools/registry.py +279 -0
  109. mycode/tools/run_command.py +237 -0
  110. mycode/tools/run_skill_script.py +206 -0
  111. mycode/tools/run_validation.py +107 -0
  112. mycode/tools/submit_result.py +93 -0
  113. mycode/tools/text.py +15 -0
  114. mycode/tools/validation_command.py +377 -0
  115. mycode/tools/workspace.py +33 -0
  116. mycode/tools/write_file.py +169 -0
  117. mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
  118. mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
  119. mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  120. mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  121. mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,377 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import Literal
4
+
5
+
6
+ PYTHON_COMMANDS = {"python", "python3", "py"}
7
+ TEST_COMMANDS = {"pytest", "unittest"}
8
+ LINT_COMMANDS = {"mypy", "pyright"}
9
+ COMPILE_MODULES = {"compileall", "py_compile"}
10
+ RUFF_MUTATING_OPTIONS = {"--fix", "--fix-only", "--unsafe-fixes"}
11
+ INFORMATIONAL_OPTIONS = {"-h", "--help", "--version"}
12
+ PYTEST_NON_EXECUTION_OPTIONS = {
13
+ "--collect-only",
14
+ "--collectonly",
15
+ "--co",
16
+ "--fixtures",
17
+ "--fixtures-per-test",
18
+ "--markers",
19
+ "--setup-plan",
20
+ "--trace-config",
21
+ }
22
+ TOX_NON_EXECUTION_OPTIONS = {
23
+ "-a",
24
+ "-l",
25
+ "--listenvs",
26
+ "--listenvs-all",
27
+ "--notest",
28
+ "--showconfig",
29
+ }
30
+ NOX_NON_EXECUTION_OPTIONS = {"-l", "--list", "--list-sessions"}
31
+ MAKE_NON_EXECUTION_OPTIONS = {
32
+ "-n",
33
+ "--dry-run",
34
+ "--just-print",
35
+ "--question",
36
+ "--recon",
37
+ }
38
+ UV_RUN_FLAG_OPTIONS = {
39
+ "--frozen",
40
+ "--isolated",
41
+ "--locked",
42
+ "--no-project",
43
+ "--no-sync",
44
+ "--offline",
45
+ }
46
+ UV_RUN_VALUE_OPTIONS = {
47
+ "-p",
48
+ "--directory",
49
+ "--project",
50
+ "--python",
51
+ }
52
+ ORDINARY_COMMANDS = {
53
+ "cat",
54
+ "dir",
55
+ "echo",
56
+ "find",
57
+ "findstr",
58
+ "get-childitem",
59
+ "grep",
60
+ "head",
61
+ "ls",
62
+ "pwd",
63
+ "rg",
64
+ "sed",
65
+ "tail",
66
+ "type",
67
+ "where",
68
+ "which",
69
+ }
70
+
71
+ ValidationClassification = Literal["validation", "non_validation", "unknown"]
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class ValidationCommandAnalysis:
76
+ allowed: bool
77
+ classification: ValidationClassification
78
+ category: str
79
+ reason: str
80
+
81
+
82
+ def analyze_validation_command(command: list[str]) -> ValidationCommandAnalysis:
83
+ if not command:
84
+ return _unknown("Validation command must not be empty.")
85
+
86
+ normalized = [part.casefold() for part in command]
87
+ executable = _normalized_executable(normalized[0])
88
+
89
+ if executable == "uv":
90
+ if len(normalized) < 3 or normalized[1] != "run":
91
+ return _non_validation("Command is not a uv validation run.")
92
+ unwrapped = _unwrap_uv_run(normalized[2:])
93
+ if isinstance(unwrapped, ValidationCommandAnalysis):
94
+ return unwrapped
95
+ return _analyze_direct(unwrapped)
96
+
97
+ return _analyze_direct(normalized)
98
+
99
+
100
+ def _analyze_direct(parts: list[str]) -> ValidationCommandAnalysis:
101
+ executable = _normalized_executable(parts[0])
102
+
103
+ if executable == "pytest":
104
+ if _has_option(parts[1:], INFORMATIONAL_OPTIONS | PYTEST_NON_EXECUTION_OPTIONS):
105
+ return _non_validation(
106
+ "pytest command only reports information or collects tests."
107
+ )
108
+ return _allowed("test", "Command runs pytest validation.")
109
+
110
+ if executable == "unittest":
111
+ if _has_option(parts[1:], INFORMATIONAL_OPTIONS):
112
+ return _non_validation("unittest command only reports information.")
113
+ return _allowed("test", "Command runs unittest validation.")
114
+
115
+ if executable == "tox":
116
+ if _has_option(parts[1:], INFORMATIONAL_OPTIONS | TOX_NON_EXECUTION_OPTIONS):
117
+ return _non_validation("tox command does not execute validation.")
118
+ return _allowed("test", "Command runs tox validation.")
119
+
120
+ if executable == "nox":
121
+ if _has_option(parts[1:], INFORMATIONAL_OPTIONS | NOX_NON_EXECUTION_OPTIONS):
122
+ return _non_validation("nox command does not execute validation.")
123
+ return _allowed("test", "Command runs nox validation.")
124
+
125
+ if executable == "ctest":
126
+ if _has_option(parts[1:], INFORMATIONAL_OPTIONS | {"-n", "--show-only"}):
127
+ return _non_validation("ctest command does not execute tests.")
128
+ return _allowed("test", "Command runs ctest validation.")
129
+
130
+ if executable == "go":
131
+ return _analyze_go(parts)
132
+
133
+ if executable == "cargo":
134
+ return _analyze_subcommand_validator(parts, command="cargo", subcommand="test")
135
+
136
+ if executable in {"npm", "yarn", "pnpm"}:
137
+ return _analyze_package_test(parts, executable=executable)
138
+
139
+ if executable == "make":
140
+ if _has_option(parts[1:], INFORMATIONAL_OPTIONS | MAKE_NON_EXECUTION_OPTIONS):
141
+ return _non_validation("make command does not execute the test target.")
142
+ if len(parts) >= 2 and parts[1] == "test":
143
+ return _allowed("test", "Command runs the make test target.")
144
+ return _unknown("Make command is not the explicit test target.")
145
+
146
+ if executable in LINT_COMMANDS:
147
+ if _has_option(parts[1:], INFORMATIONAL_OPTIONS):
148
+ return _non_validation(f"{executable} command only reports information.")
149
+ return _allowed("lint", f"Command runs {executable} static validation.")
150
+
151
+ if executable == "ruff":
152
+ return _analyze_ruff(parts)
153
+
154
+ if executable in PYTHON_COMMANDS:
155
+ return _analyze_python(parts)
156
+
157
+ if executable in {"pip", "pip3"}:
158
+ if len(parts) >= 2 and parts[1] == "install":
159
+ return _non_validation("Package installation is not validation.")
160
+ return _unknown("pip command is not a recognized validator.")
161
+
162
+ if executable == "git":
163
+ if parts[1:] == ["diff", "--check"]:
164
+ return _allowed("lint", "Command checks the Git diff for whitespace errors.")
165
+ return _non_validation("Git command is not a recognized validation command.")
166
+
167
+ if executable in ORDINARY_COMMANDS:
168
+ return _non_validation("Command is a recognized non-validation utility.")
169
+
170
+ return _unknown(
171
+ "Command is not recognized confidently as validation or non-validation."
172
+ )
173
+
174
+
175
+ def _analyze_python(parts: list[str]) -> ValidationCommandAnalysis:
176
+ if len(parts) < 2:
177
+ return _non_validation("Python interpreter was invoked without a validator.")
178
+ if parts[1] != "-m":
179
+ if _is_setup_script(parts[1]):
180
+ return _analyze_setup_build(parts[2:])
181
+ if _is_known_python_test_script(parts[1:]):
182
+ if _has_option(parts[2:], INFORMATIONAL_OPTIONS):
183
+ return _non_validation("Python test script only reports information.")
184
+ return _allowed("test", "Command runs a recognized Python test script.")
185
+ if parts[1] in {"-c", "-"}:
186
+ return _unknown("Inline Python may or may not perform validation.")
187
+ return _unknown("Python script is not a recognized validator.")
188
+ if len(parts) < 3:
189
+ return _unknown("Python -m did not name a module.")
190
+
191
+ module = parts[2]
192
+ module_parts = [module, *parts[3:]]
193
+ if module in TEST_COMMANDS:
194
+ return _analyze_direct(module_parts)
195
+ if module in COMPILE_MODULES:
196
+ if _has_option(parts[3:], INFORMATIONAL_OPTIONS):
197
+ return _non_validation(f"python -m {module} only reports information.")
198
+ return _allowed("compile", f"Command runs python -m {module} validation.")
199
+ if module == "build":
200
+ if _has_option(parts[3:], INFORMATIONAL_OPTIONS):
201
+ return _non_validation("python -m build only reports information.")
202
+ return _allowed("build", "Command runs python -m build validation.")
203
+ if module in LINT_COMMANDS or module == "ruff":
204
+ return _analyze_direct(module_parts)
205
+
206
+ if module in {"pip", "venv"}:
207
+ return _non_validation(f"Python module is not validation: {module}")
208
+ return _unknown(f"Python module is not a recognized validator: {module}")
209
+
210
+
211
+ def _analyze_go(parts: list[str]) -> ValidationCommandAnalysis:
212
+ if len(parts) < 2:
213
+ return _unknown("Go command did not name a validation subcommand.")
214
+ if parts[1] != "test":
215
+ if parts[1] in {"env", "help", "version"}:
216
+ return _non_validation("Go command only reports information.")
217
+ return _unknown("Go command is not the test subcommand.")
218
+ if _has_option(parts[2:], INFORMATIONAL_OPTIONS | {"-list"}):
219
+ return _non_validation("go test command only lists tests or reports information.")
220
+ return _allowed("test", "Command runs go test validation.")
221
+
222
+
223
+ def _analyze_subcommand_validator(
224
+ parts: list[str], *, command: str, subcommand: str
225
+ ) -> ValidationCommandAnalysis:
226
+ if len(parts) >= 2 and parts[1] == subcommand:
227
+ if _has_option(parts[2:], INFORMATIONAL_OPTIONS):
228
+ return _non_validation(f"{command} {subcommand} only reports information.")
229
+ return _allowed("test", f"Command runs {command} {subcommand} validation.")
230
+ if _has_option(parts[1:], INFORMATIONAL_OPTIONS):
231
+ return _non_validation(f"{command} command only reports information.")
232
+ return _unknown(f"{command} command is not the {subcommand} subcommand.")
233
+
234
+
235
+ def _analyze_package_test(
236
+ parts: list[str], *, executable: str
237
+ ) -> ValidationCommandAnalysis:
238
+ arguments = parts[1:]
239
+ if _has_option(arguments, INFORMATIONAL_OPTIONS):
240
+ return _non_validation(f"{executable} command only reports information.")
241
+ is_test = bool(arguments) and (
242
+ arguments[0] == "test"
243
+ or (len(arguments) >= 2 and arguments[:2] == ["run", "test"])
244
+ )
245
+ if not is_test:
246
+ return _unknown(f"{executable} command is not the explicit test script.")
247
+ if _has_option(arguments, {"--dry-run", "--ignore-scripts"}):
248
+ return _non_validation(f"{executable} test command does not execute tests.")
249
+ if _has_option(arguments, {"--if-present"}):
250
+ return _unknown(f"{executable} test script may not exist.")
251
+ return _allowed("test", f"Command runs {executable} test validation.")
252
+
253
+
254
+ def _analyze_ruff(parts: list[str]) -> ValidationCommandAnalysis:
255
+ if len(parts) < 2:
256
+ return _unknown("ruff requires an explicit non-mutating subcommand.")
257
+ if _has_option(parts[1:], INFORMATIONAL_OPTIONS):
258
+ return _non_validation("ruff command only reports information.")
259
+ if any(_is_mutating_ruff_option(part) for part in parts[1:]):
260
+ return _non_validation("Mutating ruff fix options are not validation.")
261
+ if parts[1] == "check":
262
+ return _allowed("lint", "Command runs non-fixing ruff checks.")
263
+ if parts[1] == "format" and "--check" in parts[2:]:
264
+ return _allowed("lint", "Command checks ruff formatting without writing files.")
265
+ return _unknown("Ruff command is not a recognized non-mutating validation.")
266
+
267
+
268
+ def _is_known_python_test_script(parts: list[str]) -> bool:
269
+ if not parts:
270
+ return False
271
+ script = Path(parts[0].replace("\\", "/"))
272
+ name = script.name.casefold()
273
+ if name == "manage.py":
274
+ return len(parts) >= 2 and parts[1] == "test"
275
+ if name in {"runtest.py", "runtests.py"}:
276
+ return True
277
+ return name.endswith(".py") and (
278
+ name.startswith("test") or name.endswith("_test.py")
279
+ )
280
+
281
+
282
+ def _is_setup_script(value: str) -> bool:
283
+ return Path(value.replace("\\", "/")).name.casefold() == "setup.py"
284
+
285
+
286
+ def _analyze_setup_build(parts: list[str]) -> ValidationCommandAnalysis:
287
+ if not parts or parts[0] not in {"build", "build_ext"}:
288
+ return _unknown("setup.py command is not a recognized build validator.")
289
+ if _has_option(parts[1:], INFORMATIONAL_OPTIONS):
290
+ return _non_validation("setup.py build command only reports information.")
291
+ return _allowed("build", f"Command runs setup.py {parts[0]} validation.")
292
+
293
+
294
+ def _normalized_executable(value: str) -> str:
295
+ executable = Path(value).name.casefold()
296
+ for suffix in (".exe", ".cmd", ".bat"):
297
+ if executable.endswith(suffix):
298
+ return executable[: -len(suffix)]
299
+ return executable
300
+
301
+
302
+ def _is_mutating_ruff_option(value: str) -> bool:
303
+ return value in RUFF_MUTATING_OPTIONS or any(
304
+ value.startswith(f"{option}=") for option in RUFF_MUTATING_OPTIONS
305
+ )
306
+
307
+
308
+ def _allowed(category: str, reason: str) -> ValidationCommandAnalysis:
309
+ return ValidationCommandAnalysis(
310
+ allowed=True,
311
+ classification="validation",
312
+ category=category,
313
+ reason=reason,
314
+ )
315
+
316
+
317
+ def _has_option(parts: list[str], options: set[str]) -> bool:
318
+ return any(
319
+ part in options or any(part.startswith(f"{option}=") for option in options)
320
+ for part in parts
321
+ )
322
+
323
+
324
+ def _unwrap_uv_run(
325
+ parts: list[str],
326
+ ) -> list[str] | ValidationCommandAnalysis:
327
+ index = 0
328
+ python_module = False
329
+ while index < len(parts):
330
+ part = parts[index]
331
+ if part == "--":
332
+ index += 1
333
+ break
334
+ if part in INFORMATIONAL_OPTIONS:
335
+ return _non_validation("uv run command only reports information.")
336
+ if part in UV_RUN_FLAG_OPTIONS:
337
+ index += 1
338
+ continue
339
+ if part in {"-m", "--module"}:
340
+ python_module = True
341
+ index += 1
342
+ continue
343
+ if part in UV_RUN_VALUE_OPTIONS:
344
+ if index + 1 >= len(parts):
345
+ return _unknown(f"uv run option requires a value: {part}")
346
+ index += 2
347
+ continue
348
+ if any(part.startswith(f"{option}=") for option in UV_RUN_VALUE_OPTIONS):
349
+ index += 1
350
+ continue
351
+ if part.startswith("-"):
352
+ return _unknown(f"Cannot identify validator after uv run option: {part}")
353
+ break
354
+
355
+ if index >= len(parts):
356
+ return _unknown("uv run did not name a validator.")
357
+ if python_module:
358
+ return ["python", "-m", *parts[index:]]
359
+ return parts[index:]
360
+
361
+
362
+ def _non_validation(reason: str) -> ValidationCommandAnalysis:
363
+ return ValidationCommandAnalysis(
364
+ allowed=False,
365
+ classification="non_validation",
366
+ category="unsupported",
367
+ reason=reason,
368
+ )
369
+
370
+
371
+ def _unknown(reason: str) -> ValidationCommandAnalysis:
372
+ return ValidationCommandAnalysis(
373
+ allowed=False,
374
+ classification="unknown",
375
+ category="unsupported",
376
+ reason=reason,
377
+ )
@@ -0,0 +1,33 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+
5
+ class WorkspacePathError(ValueError):
6
+ pass
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Workspace:
11
+ root: Path
12
+
13
+ def __post_init__(self) -> None:
14
+ resolved_root = self.root.resolve(strict=False)
15
+ if not resolved_root.exists():
16
+ raise WorkspacePathError(f"Workspace root does not exist: {self.root}")
17
+ if not resolved_root.is_dir():
18
+ raise WorkspacePathError(f"Workspace root is not a directory: {self.root}")
19
+
20
+ object.__setattr__(self, "root", resolved_root)
21
+
22
+ def resolve_path(self, path: str | Path) -> Path:
23
+ requested_path = Path(path)
24
+
25
+ if requested_path.is_absolute():
26
+ resolved_path = requested_path.resolve(strict=False)
27
+ else:
28
+ resolved_path = (self.root / requested_path).resolve(strict=False)
29
+
30
+ if not resolved_path.is_relative_to(self.root):
31
+ raise WorkspacePathError(f"Path is outside workspace: {path}")
32
+
33
+ return resolved_path
@@ -0,0 +1,169 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import Field
4
+
5
+ from mycode.permissions import PermissionChecker, PermissionDecision, PermissionRequest
6
+ from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
7
+ from mycode.tools.file_mutation import (
8
+ display_path,
9
+ failure_metadata,
10
+ resolved_path_from_decision,
11
+ with_permission_metadata,
12
+ )
13
+ from mycode.tools.path_permissions import PathPermissionPolicy
14
+ from mycode.tools.workspace import Workspace
15
+
16
+
17
+ class WriteFileArgs(ToolArgs):
18
+ path: str = Field(
19
+ min_length=1,
20
+ description="目标文件路径,必须位于 workspace 内,父目录需要已存在。",
21
+ )
22
+ content: str = Field(
23
+ description=(
24
+ "要写入文件的完整 UTF-8 文本内容;目标文件已存在时会覆盖原内容。"
25
+ ),
26
+ )
27
+
28
+
29
+ class WriteFileTool(PydanticTool[WriteFileArgs]):
30
+ name = "write_file"
31
+ description = "Write complete UTF-8 text content to a file."
32
+ args_model = WriteFileArgs
33
+ capability = "write"
34
+ risk = "medium"
35
+
36
+ def __init__(self, workspace: Workspace) -> None:
37
+ self.workspace = workspace
38
+
39
+ def build_permission_request(self, args: WriteFileArgs) -> PermissionRequest:
40
+ return PermissionRequest(
41
+ tool_name=self.name,
42
+ capability=self.capability,
43
+ action=self.name,
44
+ target=args.path,
45
+ arguments=args.model_dump(),
46
+ description="Write complete file content.",
47
+ )
48
+
49
+ def check_permission(
50
+ self,
51
+ args: WriteFileArgs,
52
+ permission_checker: PermissionChecker,
53
+ ) -> tuple[PermissionRequest, PermissionDecision]:
54
+ request = self.build_permission_request(args)
55
+ path_decision = PathPermissionPolicy(self.workspace).check_path(
56
+ request,
57
+ args.path,
58
+ )
59
+ metadata = _write_metadata(
60
+ path=resolved_path_from_decision(path_decision),
61
+ content=args.content,
62
+ path_decision=path_decision,
63
+ )
64
+
65
+ if metadata["target_exists"] and not metadata["target_is_file"]:
66
+ return request, PermissionDecision.deny(
67
+ reason="unsupported_operation",
68
+ message=f"Path is not a file: {args.path}",
69
+ metadata=metadata,
70
+ )
71
+
72
+ if path_decision.status != "allow":
73
+ return request, PermissionDecision.ask(
74
+ reason=path_decision.reason,
75
+ message=path_decision.message,
76
+ metadata=metadata,
77
+ )
78
+
79
+ decision = permission_checker.check(
80
+ request,
81
+ self.get_permission_profile(),
82
+ )
83
+
84
+ return request, with_permission_metadata(decision, metadata)
85
+
86
+ def run_authorized(
87
+ self,
88
+ args: WriteFileArgs,
89
+ decision: PermissionDecision,
90
+ ) -> ToolResult:
91
+ try:
92
+ return self._write_path(
93
+ args,
94
+ resolved_path_from_decision(decision),
95
+ decision,
96
+ )
97
+ except Exception as error:
98
+ return ToolResult.failure(
99
+ error=f"Tool execution failed: {error}",
100
+ metadata=failure_metadata(
101
+ decision,
102
+ {"exception_type": type(error).__name__},
103
+ ),
104
+ )
105
+
106
+ def _run(self, args: WriteFileArgs) -> ToolResult:
107
+ return ToolResult.failure(
108
+ error="write_file must be run through ToolRegistry.run_tool().",
109
+ metadata={"path": args.path, "reason": "permission_required"},
110
+ )
111
+
112
+ def _write_path(
113
+ self,
114
+ args: WriteFileArgs,
115
+ path: Path,
116
+ decision: PermissionDecision,
117
+ ) -> ToolResult:
118
+ if path.exists() and not path.is_file():
119
+ return ToolResult.failure(
120
+ error=f"Path is not a file: {args.path}",
121
+ metadata=failure_metadata(
122
+ decision,
123
+ {"path": args.path, "target_is_file": False},
124
+ ),
125
+ )
126
+
127
+ if not path.parent.exists():
128
+ return ToolResult.failure(
129
+ error=f"Parent directory does not exist: {args.path}",
130
+ metadata=failure_metadata(
131
+ decision,
132
+ {"path": args.path, "parent": path.parent.as_posix()},
133
+ ),
134
+ )
135
+
136
+ existed_before = path.exists()
137
+ path.write_text(args.content, encoding="utf-8")
138
+
139
+ operation = "overwrite" if existed_before else "create"
140
+ return ToolResult.success(
141
+ content=f"Wrote file: {display_path(path, self.workspace.root)}",
142
+ metadata={
143
+ **decision.metadata,
144
+ "path": display_path(path, self.workspace.root),
145
+ "operation": operation,
146
+ "encoding": "utf-8",
147
+ "content_chars": len(args.content),
148
+ "content_bytes": len(args.content.encode("utf-8")),
149
+ "target_existed_before": existed_before,
150
+ "permission_status": decision.status,
151
+ },
152
+ )
153
+
154
+
155
+ def _write_metadata(
156
+ *,
157
+ path: Path,
158
+ content: str,
159
+ path_decision: PermissionDecision,
160
+ ) -> dict[str, object]:
161
+ target_exists = path.exists()
162
+ return {
163
+ **path_decision.metadata,
164
+ "operation": "overwrite" if target_exists else "create",
165
+ "target_exists": target_exists,
166
+ "target_is_file": path.is_file() if target_exists else None,
167
+ "content_chars": len(content),
168
+ "content_bytes": len(content.encode("utf-8")),
169
+ }