rlmness-cli 0.2.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 (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,210 @@
1
+ """Shared tool helpers: output truncation, per-path async locks, arg parsing, error builders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Mapping
7
+ from contextvars import ContextVar
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from rlmagent.harness.contracts.tooling import (
12
+ CancelToken,
13
+ InputShaper,
14
+ InvocationPrinter,
15
+ OutcomePresenter,
16
+ Parallelism,
17
+ ProgressNotifier,
18
+ RunHandler,
19
+ ToolOutcome,
20
+ ToolSpec,
21
+ )
22
+ from rlmagent.harness.contracts.transcript import CallBlock, TextSegment
23
+ from rlmagent.harness.contracts.values import JValue
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Constants
27
+ # ---------------------------------------------------------------------------
28
+
29
+ MAX_OUTPUT_LINES: int = 2000
30
+ TRUNCATION_NOTICE = "\n... (output truncated — {shown}/{total} lines shown)"
31
+
32
+ IMAGE_EXTENSIONS: frozenset[str] = frozenset({
33
+ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg", ".ico", ".tiff",
34
+ })
35
+
36
+ BINARY_EXTENSIONS: frozenset[str] = frozenset({
37
+ ".pdf", ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
38
+ ".exe", ".dll", ".so", ".dylib",
39
+ ".pyc", ".pyo", ".class",
40
+ ".whl", ".egg",
41
+ ".sqlite", ".db",
42
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
43
+ })
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Per-path async locks
47
+ # ---------------------------------------------------------------------------
48
+
49
+ _path_locks: dict[Path, asyncio.Lock] = {}
50
+
51
+
52
+ def path_lock(path: Path) -> asyncio.Lock:
53
+ """Return a per-path async lock (created on first access)."""
54
+ resolved = path.resolve()
55
+ if resolved not in _path_locks:
56
+ _path_locks[resolved] = asyncio.Lock()
57
+ return _path_locks[resolved]
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Output truncation
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ def truncate_output(text: str, *, max_lines: int = MAX_OUTPUT_LINES) -> str:
66
+ """Truncate text to *max_lines*, appending a notice if clipped."""
67
+ lines = text.split("\n")
68
+ if len(lines) <= max_lines:
69
+ return text
70
+ kept = "\n".join(lines[:max_lines])
71
+ return kept + TRUNCATION_NOTICE.format(shown=max_lines, total=len(lines))
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Line-numbered formatting (cat -n style)
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def format_with_line_numbers(
80
+ text: str, *, start: int = 1
81
+ ) -> str:
82
+ """Format text with right-aligned line numbers and a tab separator."""
83
+ lines = text.split("\n")
84
+ width = len(str(start + len(lines) - 1))
85
+ numbered = []
86
+ for i, line in enumerate(lines):
87
+ num = start + i
88
+ numbered.append(f"{num:>{width}}\t{line}")
89
+ return "\n".join(numbered)
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Argument extraction helpers
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def require_str(args: Mapping[str, JValue], key: str) -> str:
98
+ """Extract a required string argument, raising on missing/wrong type."""
99
+ val = args.get(key)
100
+ if not isinstance(val, str):
101
+ raise ValueError(f"Missing or invalid required argument: {key}")
102
+ return val
103
+
104
+
105
+ def optional_str(args: Mapping[str, JValue], key: str) -> str | None:
106
+ """Extract an optional string argument."""
107
+ val = args.get(key)
108
+ return val if isinstance(val, str) else None
109
+
110
+
111
+ def optional_int(args: Mapping[str, JValue], key: str) -> int | None:
112
+ """Extract an optional integer argument."""
113
+ val = args.get(key)
114
+ if val is None:
115
+ return None
116
+ if isinstance(val, int) and not isinstance(val, bool):
117
+ return val
118
+ if isinstance(val, float) and val == int(val):
119
+ return int(val)
120
+ return None
121
+
122
+
123
+ def optional_bool(args: Mapping[str, JValue], key: str) -> bool | None:
124
+ """Extract an optional boolean argument."""
125
+ val = args.get(key)
126
+ return val if isinstance(val, bool) else None
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Error / fault outcome builders
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ def fault(message: str, **extra: Any) -> ToolOutcome:
135
+ """Build a ToolOutcome representing a tool-level error."""
136
+ return ToolOutcome(
137
+ content=[TextSegment(text=message)],
138
+ details=extra if extra else None,
139
+ )
140
+
141
+
142
+ def text_outcome(text: str) -> ToolOutcome:
143
+ """Build a ToolOutcome containing a single text block."""
144
+ return ToolOutcome(content=[TextSegment(text=text)])
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Path validation
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ _workspace: Path | None = None
153
+ # One process can serve several sessions in different folders; each task sees its own.
154
+ _task_workspace: ContextVar[Path | None] = ContextVar("workspace", default=None)
155
+
156
+
157
+ def set_workspace(root: str | Path | None) -> None:
158
+ """Confine the file tools to `root` (None: no confinement)."""
159
+ global _workspace
160
+ _workspace = Path(root).resolve() if root is not None else None
161
+ _task_workspace.set(_workspace)
162
+
163
+
164
+ def validate_file_path(raw: str) -> Path:
165
+ """Validate and resolve a file path. Raises ValueError on issues."""
166
+ if not raw.strip():
167
+ raise ValueError("File path must not be empty.")
168
+ path = Path(raw)
169
+ if not path.is_absolute():
170
+ raise ValueError(
171
+ f"File path must be absolute, got relative path: {raw}"
172
+ )
173
+ workspace = _task_workspace.get() or _workspace
174
+ if workspace is not None:
175
+ real = path.resolve() # follows symlinks, so a link out of the workspace is caught
176
+ if real != workspace and workspace not in real.parents:
177
+ raise ValueError(f"{raw} is outside the workspace {workspace}")
178
+ return path
179
+
180
+
181
+ __all__ = [
182
+ # re-exports from contracts
183
+ "CallBlock",
184
+ "CancelToken",
185
+ "InputShaper",
186
+ "InvocationPrinter",
187
+ "JValue",
188
+ "OutcomePresenter",
189
+ "Parallelism",
190
+ "ProgressNotifier",
191
+ "RunHandler",
192
+ "TextSegment",
193
+ "ToolOutcome",
194
+ "ToolSpec",
195
+ # shared helpers
196
+ "BINARY_EXTENSIONS",
197
+ "IMAGE_EXTENSIONS",
198
+ "MAX_OUTPUT_LINES",
199
+ "fault",
200
+ "format_with_line_numbers",
201
+ "optional_bool",
202
+ "optional_int",
203
+ "optional_str",
204
+ "path_lock",
205
+ "require_str",
206
+ "text_outcome",
207
+ "truncate_output",
208
+ "set_workspace",
209
+ "validate_file_path",
210
+ ]
@@ -0,0 +1,462 @@
1
+ """File tools: Read, Write, and Edit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import mimetypes
7
+ from collections.abc import Mapping
8
+ from pathlib import Path
9
+
10
+ from rlmagent.tools._shared import (
11
+ BINARY_EXTENSIONS,
12
+ IMAGE_EXTENSIONS,
13
+ MAX_OUTPUT_LINES,
14
+ CancelToken,
15
+ JValue,
16
+ ProgressNotifier,
17
+ ToolOutcome,
18
+ ToolSpec,
19
+ fault,
20
+ format_with_line_numbers,
21
+ optional_bool,
22
+ optional_int,
23
+ path_lock,
24
+ require_str,
25
+ text_outcome,
26
+ validate_file_path,
27
+ )
28
+ from rlmagent.harness.contracts.transcript import ImageSegment
29
+
30
+ # ═══════════════════════════════════════════════════════════════════════════
31
+ # Read tool
32
+ # ═══════════════════════════════════════════════════════════════════════════
33
+
34
+ _READ_PARAMS: Mapping[str, JValue] = {
35
+ "type": "object",
36
+ "properties": {
37
+ "file_path": {
38
+ "type": "string",
39
+ "description": "Absolute path to the file to read.",
40
+ },
41
+ "offset": {
42
+ "type": "integer",
43
+ "description": "1-based line number to start reading from.",
44
+ },
45
+ "limit": {
46
+ "type": "integer",
47
+ "description": "Maximum number of lines to read.",
48
+ },
49
+ },
50
+ "required": ["file_path"],
51
+ "additionalProperties": False,
52
+ }
53
+
54
+
55
+ def _is_image(path: Path) -> bool:
56
+ return path.suffix.lower() in IMAGE_EXTENSIONS
57
+
58
+
59
+ def _is_binary(path: Path) -> bool:
60
+ return path.suffix.lower() in BINARY_EXTENSIONS
61
+
62
+
63
+ async def _run_read(
64
+ tool_call_id: str,
65
+ arguments: Mapping[str, JValue],
66
+ signal: CancelToken | None = None,
67
+ on_update: ProgressNotifier | None = None,
68
+ ) -> ToolOutcome:
69
+ """Read a file and return its contents with line numbers, or base64 for images."""
70
+ try:
71
+ raw_path = require_str(arguments, "file_path")
72
+ path = validate_file_path(raw_path)
73
+ except ValueError as exc:
74
+ return fault(str(exc))
75
+
76
+ if not path.exists():
77
+ return fault(f"File not found: {path}")
78
+
79
+ if not path.is_file():
80
+ return fault(
81
+ f"Not a regular file: {path}. "
82
+ "Use a shell command to list directory contents."
83
+ )
84
+
85
+ # Image files → base64 image segment
86
+ if _is_image(path):
87
+ return _read_image(path)
88
+
89
+ # Binary files → reject with helpful message
90
+ if _is_binary(path):
91
+ return fault(
92
+ f"Cannot read binary file: {path} "
93
+ f"(extension: {path.suffix})"
94
+ )
95
+
96
+ if _looks_binary(path):
97
+ return fault(f"Cannot read binary file: {path} (it contains NUL bytes)")
98
+
99
+ # Text files → line-numbered output
100
+ return _read_text(path, arguments)
101
+
102
+
103
+ _MAX_IMAGE_BYTES = 5_000_000
104
+
105
+
106
+ def _read_image(path: Path) -> ToolOutcome:
107
+ """Read an image file and return as a base64 ImageSegment."""
108
+ try:
109
+ size = path.stat().st_size
110
+ if size > _MAX_IMAGE_BYTES:
111
+ # Providers reject oversized images, and an attached image is resent
112
+ # on every later turn, so one bad attachment would break the session.
113
+ return fault(
114
+ f"Image is {size / 1_000_000:.1f} MB; the limit is "
115
+ f"{_MAX_IMAGE_BYTES // 1_000_000} MB. Resize or crop it first."
116
+ )
117
+ raw = path.read_bytes()
118
+ except OSError as exc:
119
+ return fault(f"Failed to read image: {exc}")
120
+
121
+ mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
122
+ encoded = base64.b64encode(raw).decode("ascii")
123
+ return ToolOutcome(content=[ImageSegment(mime_type=mime, data=encoded)])
124
+
125
+
126
+ def _looks_binary(path: Path) -> bool:
127
+ """A NUL in the first 8 KB: binary, whatever the extension claims."""
128
+ try:
129
+ with path.open("rb") as f:
130
+ return b"\0" in f.read(8192)
131
+ except OSError:
132
+ return False # the text read reports this properly
133
+
134
+
135
+ def _read_text(path: Path, arguments: Mapping[str, JValue]) -> ToolOutcome:
136
+ """Read a text file with optional offset/limit, returning line-numbered output."""
137
+ offset = optional_int(arguments, "offset")
138
+ limit = optional_int(arguments, "limit")
139
+
140
+ try:
141
+ text = path.read_text(encoding="utf-8", errors="replace")
142
+ except OSError as exc:
143
+ return fault(f"Failed to read file: {exc}")
144
+
145
+ if not text:
146
+ return text_outcome(f"(empty file: {path})")
147
+
148
+ lines = text.split("\n")
149
+ total = len(lines)
150
+
151
+ # Apply offset (1-based)
152
+ start_line = 1
153
+ if offset is not None and offset > 1:
154
+ start_line = min(offset, total + 1)
155
+ lines = lines[start_line - 1:]
156
+
157
+ # Apply limit
158
+ effective_limit = limit if limit is not None else MAX_OUTPUT_LINES
159
+ truncated = len(lines) > effective_limit
160
+ if truncated:
161
+ lines = lines[:effective_limit]
162
+
163
+ body = format_with_line_numbers(
164
+ "\n".join(lines), start=start_line,
165
+ )
166
+
167
+ if truncated:
168
+ body += (
169
+ f"\n\n... ({effective_limit} of {total} lines shown. "
170
+ f"Use offset/limit to read more.)"
171
+ )
172
+
173
+ return text_outcome(body)
174
+
175
+
176
+ def _format_read_call(arguments: Mapping[str, JValue]) -> str | None:
177
+ path = arguments.get("file_path", "?")
178
+ parts = [str(path)]
179
+ offset = arguments.get("offset")
180
+ limit = arguments.get("limit")
181
+ if offset:
182
+ parts.append(f"from line {offset}")
183
+ if limit:
184
+ parts.append(f"({limit} lines)")
185
+ return " ".join(parts)
186
+
187
+
188
+ def make_read_tool() -> ToolSpec:
189
+ """Create the Read file tool."""
190
+ return ToolSpec(
191
+ name="Read",
192
+ label="Read File",
193
+ description=(
194
+ "Read a file from the filesystem. Returns text with line numbers, "
195
+ "or the image content for image files. Use offset/limit for large files."
196
+ ),
197
+ parameters=_READ_PARAMS,
198
+ run=_run_read,
199
+ parallelism="parallel",
200
+ format_call=_format_read_call,
201
+ )
202
+
203
+
204
+ # ═══════════════════════════════════════════════════════════════════════════
205
+ # Write tool
206
+ # ═══════════════════════════════════════════════════════════════════════════
207
+
208
+ _WRITE_PARAMS: Mapping[str, JValue] = {
209
+ "type": "object",
210
+ "properties": {
211
+ "file_path": {
212
+ "type": "string",
213
+ "description": "Absolute path to the file to write.",
214
+ },
215
+ "content": {
216
+ "type": "string",
217
+ "description": "The full content to write to the file.",
218
+ },
219
+ },
220
+ "required": ["file_path", "content"],
221
+ "additionalProperties": False,
222
+ }
223
+
224
+
225
+ async def _run_write(
226
+ tool_call_id: str,
227
+ arguments: Mapping[str, JValue],
228
+ signal: CancelToken | None = None,
229
+ on_update: ProgressNotifier | None = None,
230
+ ) -> ToolOutcome:
231
+ """Write content to a file, creating parent directories as needed."""
232
+ try:
233
+ raw_path = require_str(arguments, "file_path")
234
+ content = require_str(arguments, "content")
235
+ path = validate_file_path(raw_path)
236
+ except ValueError as exc:
237
+ return fault(str(exc))
238
+
239
+ async with path_lock(path):
240
+ return _write_file(path, content)
241
+
242
+
243
+ def _write_file(path: Path, content: str) -> ToolOutcome:
244
+ """Perform the actual file write."""
245
+ try:
246
+ path.parent.mkdir(parents=True, exist_ok=True)
247
+ except OSError as exc:
248
+ return fault(f"Failed to create directories: {exc}")
249
+
250
+ is_new = not path.exists()
251
+
252
+ try:
253
+ path.write_text(content, encoding="utf-8")
254
+ except OSError as exc:
255
+ return fault(f"Failed to write file: {exc}")
256
+
257
+ line_count = content.count("\n") + (1 if content else 0)
258
+ verb = "Created" if is_new else "Updated"
259
+ return text_outcome(f"{verb} {path} ({line_count} lines)")
260
+
261
+
262
+ def _format_write_call(arguments: Mapping[str, JValue]) -> str | None:
263
+ path = arguments.get("file_path", "?")
264
+ content = arguments.get("content", "")
265
+ lines = str(content).count("\n") + 1 if content else 0
266
+ return f"{path} ({lines} lines)"
267
+
268
+
269
+ def make_write_tool() -> ToolSpec:
270
+ """Create the Write file tool."""
271
+ return ToolSpec(
272
+ name="Write",
273
+ label="Write File",
274
+ description=(
275
+ "Write content to a file. Creates parent directories automatically. "
276
+ "Overwrites existing files. Use the Edit tool for partial modifications."
277
+ ),
278
+ parameters=_WRITE_PARAMS,
279
+ run=_run_write,
280
+ parallelism="sequential",
281
+ format_call=_format_write_call,
282
+ )
283
+
284
+
285
+ # ═══════════════════════════════════════════════════════════════════════════
286
+ # Edit tool
287
+ # ═══════════════════════════════════════════════════════════════════════════
288
+
289
+ _EDIT_PARAMS: Mapping[str, JValue] = {
290
+ "type": "object",
291
+ "properties": {
292
+ "file_path": {
293
+ "type": "string",
294
+ "description": "Absolute path to the file to edit.",
295
+ },
296
+ "old_string": {
297
+ "type": "string",
298
+ "description": "The exact text to find and replace.",
299
+ },
300
+ "new_string": {
301
+ "type": "string",
302
+ "description": "The replacement text.",
303
+ },
304
+ "replace_all": {
305
+ "type": "boolean",
306
+ "description": (
307
+ "If true, replace all occurrences. "
308
+ "If false (default), old_string must appear exactly once."
309
+ ),
310
+ "default": False,
311
+ },
312
+ },
313
+ "required": ["file_path", "old_string", "new_string"],
314
+ "additionalProperties": False,
315
+ }
316
+
317
+
318
+ async def _run_edit(
319
+ tool_call_id: str,
320
+ arguments: Mapping[str, JValue],
321
+ signal: CancelToken | None = None,
322
+ on_update: ProgressNotifier | None = None,
323
+ ) -> ToolOutcome:
324
+ """Replace exact text in a file with match-uniqueness validation."""
325
+ try:
326
+ raw_path = require_str(arguments, "file_path")
327
+ old_string = require_str(arguments, "old_string")
328
+ new_string = require_str(arguments, "new_string")
329
+ except ValueError as exc:
330
+ return fault(str(exc))
331
+
332
+ replace_all = optional_bool(arguments, "replace_all") or False
333
+
334
+ if old_string == new_string:
335
+ return fault("old_string and new_string are identical — nothing to change.")
336
+
337
+ try:
338
+ path = validate_file_path(raw_path)
339
+ except ValueError as exc:
340
+ return fault(str(exc))
341
+
342
+ if not path.exists():
343
+ return fault(f"File not found: {path}")
344
+
345
+ if not path.is_file():
346
+ return fault(f"Not a regular file: {path}")
347
+
348
+ async with path_lock(path):
349
+ return _apply_edit(path, old_string, new_string, replace_all=replace_all)
350
+
351
+
352
+ def _apply_edit(
353
+ path: Path,
354
+ old_string: str,
355
+ new_string: str,
356
+ *,
357
+ replace_all: bool,
358
+ ) -> ToolOutcome:
359
+ """Validate and apply an exact-match replacement."""
360
+ try:
361
+ content = path.read_text(encoding="utf-8", errors="replace")
362
+ except OSError as exc:
363
+ return fault(f"Failed to read file: {exc}")
364
+
365
+ count = content.count(old_string)
366
+
367
+ if count == 0:
368
+ return _no_match_hint(content, old_string, path)
369
+
370
+ if count > 1 and not replace_all:
371
+ return fault(
372
+ f"old_string matches {count} locations in {path}. "
373
+ "Provide more surrounding context to make the match unique, "
374
+ "or set replace_all to true."
375
+ )
376
+
377
+ updated = content.replace(old_string, new_string, -1 if replace_all else 1)
378
+
379
+ try:
380
+ path.write_text(updated, encoding="utf-8")
381
+ except OSError as exc:
382
+ return fault(f"Failed to write file: {exc}")
383
+
384
+ replacements = count if replace_all else 1
385
+ suffix = "s" if replacements > 1 else ""
386
+ return text_outcome(
387
+ f"Applied {replacements} replacement{suffix} in {path}"
388
+ )
389
+
390
+
391
+ def _no_match_hint(content: str, needle: str, path: Path) -> ToolOutcome:
392
+ """Produce a helpful error when old_string is not found."""
393
+ lines = content.split("\n")
394
+ # Try to find a partial match (first line of needle) to help locate the issue
395
+ first_line = needle.split("\n")[0].strip()
396
+ hints: list[str] = []
397
+ if first_line:
398
+ for i, line in enumerate(lines, 1):
399
+ if first_line in line:
400
+ hints.append(f" line {i}: {line.rstrip()}")
401
+ if len(hints) >= 3:
402
+ break
403
+
404
+ msg = f"old_string not found in {path}."
405
+ if hints:
406
+ msg += (
407
+ f"\n\nPartial matches for the first line "
408
+ f"({first_line!r}):\n" + "\n".join(hints)
409
+ )
410
+ msg += (
411
+ "\n\nCommon causes: wrong indentation, "
412
+ "extra/missing whitespace, or stale content. "
413
+ "Read the file first to verify the exact text."
414
+ )
415
+ else:
416
+ msg += " Read the file first to verify its current content."
417
+
418
+ return fault(msg)
419
+
420
+
421
+ def _format_edit_call(arguments: Mapping[str, JValue]) -> str | None:
422
+ path = arguments.get("file_path", "?")
423
+ return str(path)
424
+
425
+
426
+ def make_edit_tool() -> ToolSpec:
427
+ """Create the Edit file tool."""
428
+ return ToolSpec(
429
+ name="Edit",
430
+ label="Edit File",
431
+ description=(
432
+ "Make exact string replacements in a file. "
433
+ "By default, old_string must appear exactly once (prevents ambiguous edits). "
434
+ "Set replace_all to true for global replacement."
435
+ ),
436
+ parameters=_EDIT_PARAMS,
437
+ run=_run_edit,
438
+ parallelism="sequential",
439
+ format_call=_format_edit_call,
440
+ )
441
+
442
+
443
+ # ═══════════════════════════════════════════════════════════════════════════
444
+ # Public API
445
+ # ═══════════════════════════════════════════════════════════════════════════
446
+
447
+
448
+ def file_tools() -> list[ToolSpec]:
449
+ """Build all file-related tools."""
450
+ return [
451
+ make_read_tool(),
452
+ make_write_tool(),
453
+ make_edit_tool(),
454
+ ]
455
+
456
+
457
+ __all__ = [
458
+ "file_tools",
459
+ "make_edit_tool",
460
+ "make_read_tool",
461
+ "make_write_tool",
462
+ ]