nanoPyCodeAgent 0.6.0__py3-none-any.whl → 0.7.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.
nanopycodeagent/agent.py CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  Run the program, type a message, and Agent replies. The full conversation is
4
4
  kept in memory so each turn has context. The model can call a ``read`` tool
5
- to view files, a ``write`` tool to create or overwrite them, and a ``bash``
6
- tool to run shell commands; every call and its output are echoed to the
7
- terminal as they happen. Type ``/exit`` to quit.
5
+ to view files, a ``write`` tool to create or overwrite them, an ``edit``
6
+ tool to replace part of one, and a ``bash`` tool to run shell commands;
7
+ every call and its output are echoed to the terminal as they happen. Type
8
+ ``/exit`` to quit.
8
9
 
9
10
  The loop handles only the happy path: anything unexpected — a network error,
10
11
  a Ctrl-C mid-turn — crashes the session, and restarting it is the recovery.
@@ -29,6 +30,7 @@ import anthropic
29
30
  from anthropic.types import MessageParam, ToolResultBlockParam, ToolUseBlock
30
31
 
31
32
  from .bash_tool import BASH_TOOL, run_bash
33
+ from .edit_tool import EDIT_TOOL, edit_preview, run_edit
32
34
  from .read_tool import READ_TOOL, run_read
33
35
  from .settings import load_settings_env
34
36
  from .terminal import Spinner, print_tool_output, print_tool_use
@@ -40,14 +42,15 @@ DEFAULT_MODEL = "claude-sonnet-4-6"
40
42
  MAX_TOKENS = 8192
41
43
  SYSTEM_PROMPT = (
42
44
  "You are nanoPyCodeAgent, a concise and helpful coding assistant. "
43
- "Prefer the read tool for viewing files and the write tool for creating "
44
- "files or rewriting them whole. Use the bash tool to run commands, "
45
- "search with grep, and complete tasks that need real command output "
46
- "instead of guessing."
45
+ "Prefer the read tool for viewing files, the edit tool for changing "
46
+ "part of an existing file, and the write tool for creating files or "
47
+ "rewriting them whole. Use the bash tool to run commands, search with "
48
+ "grep, and complete tasks that need real command output instead of "
49
+ "guessing."
47
50
  )
48
51
 
49
52
  # Every tool offered to the model on each request.
50
- TOOLS = [READ_TOOL, WRITE_TOOL, BASH_TOOL]
53
+ TOOLS = [READ_TOOL, WRITE_TOOL, EDIT_TOOL, BASH_TOOL]
51
54
 
52
55
 
53
56
  def _package_version() -> str:
@@ -81,6 +84,16 @@ def _run_one_tool(block: ToolUseBlock) -> ToolResultBlockParam:
81
84
  # goes and how it starts, not hundreds of lines.
82
85
  print_tool_use(f"[write] {path}\n{content_preview(content)}")
83
86
  output, is_error = run_write(path, content)
87
+ elif block.name == "edit":
88
+ path = block.input["path"]
89
+ old_text = block.input["old_text"]
90
+ new_text = block.input["new_text"]
91
+ # The echo folds both sides into a small -/+ diff: the terminal
92
+ # shows what is being swapped, not the whole strings again.
93
+ print_tool_use(f"[edit] {path}\n{edit_preview(old_text, new_text)}")
94
+ output, is_error = run_edit(
95
+ path, old_text, new_text, replace_all=block.input.get("replace_all", False)
96
+ )
84
97
  else: # bash — the only other tool offered
85
98
  command = block.input["command"]
86
99
  print_tool_use(f"[bash]$ {command}")
@@ -0,0 +1,260 @@
1
+ """The ``edit`` tool: its definition and its execution.
2
+
3
+ Each call replaces one exact stretch of an existing UTF-8 text file, so a
4
+ small change costs a small message: the model sends the old text and the
5
+ new text instead of regenerating the whole file the way ``write`` needs it
6
+ to. The old text doubles as a precondition — it has to be present, and by
7
+ default exactly once — so an edit fails loudly when the file has moved on,
8
+ where a whole-file overwrite would silently bury the change.
9
+
10
+ Matching is exact. The only conversions applied to the model's input are
11
+ the UTF-8 BOM and, for a CRLF file, a single LF→CRLF retry; both are
12
+ forced by what ``read`` shows the model, both are stated in the tool
13
+ description, and neither widens the match beyond the literal it was given.
14
+ There is no regex, whitespace, indentation or similarity fallback: with no
15
+ approval step between the match and the write, a fuzzy hit would let the
16
+ tool change more than the model asked for and report success.
17
+ """
18
+
19
+ import shlex
20
+ from pathlib import Path
21
+
22
+ from anthropic.types import ToolParam
23
+
24
+ from .read_tool import MAX_READ_BYTES
25
+
26
+ # The terminal echo folds each side of the edit to this many lines, and any
27
+ # one line to this many characters. The full strings are already in the
28
+ # model's tool input, so the echo only has to show what is being swapped.
29
+ EDIT_PREVIEW_LINES = 6
30
+ EDIT_PREVIEW_LINE_CHARS = 200
31
+
32
+ EDIT_TOOL: ToolParam = {
33
+ "name": "edit",
34
+ "description": (
35
+ "Replace an exact stretch of text in an existing UTF-8 text file. "
36
+ "Prefer this over write for changing part of a file: only the old "
37
+ "and new text travel, and the rest of the file is left untouched. "
38
+ "Use write to create a file or rewrite it whole, and bash to "
39
+ "transform many files at once. The match is literal, not a regex, "
40
+ "and must be unique unless replace_all is set; nothing is trimmed, "
41
+ "re-indented or fuzzily matched, so a call that does not match "
42
+ "fails instead of guessing a place to edit. The two exceptions are "
43
+ "the UTF-8 BOM and line endings: in a CRLF file, a multi-line "
44
+ "old_text written with plain newlines still matches and is written "
45
+ "back with CRLF. On failure, read the file again rather than "
46
+ "resending the same call."
47
+ ),
48
+ "input_schema": {
49
+ "type": "object",
50
+ "properties": {
51
+ "path": {
52
+ "type": "string",
53
+ "description": (
54
+ "Path to the file, absolute or relative to the agent's "
55
+ "working directory. A leading ~ is expanded. The file "
56
+ "must already exist."
57
+ ),
58
+ },
59
+ "old_text": {
60
+ "type": "string",
61
+ "description": (
62
+ "The text to replace, copied from the file character "
63
+ "for character — without read's line-number prefix. Two "
64
+ "to four lines are usually enough to be unique; add "
65
+ "surrounding lines when they are not."
66
+ ),
67
+ },
68
+ "new_text": {
69
+ "type": "string",
70
+ "description": (
71
+ "The replacement text. An empty string deletes old_text "
72
+ "exactly, taking nothing else with it."
73
+ ),
74
+ },
75
+ "replace_all": {
76
+ "type": "boolean",
77
+ "description": (
78
+ "Replace every occurrence instead of requiring a unique "
79
+ "one (default false). Use it only when every identical "
80
+ "occurrence should change, e.g. renaming a local "
81
+ "variable."
82
+ ),
83
+ },
84
+ },
85
+ "required": ["path", "old_text", "new_text"],
86
+ },
87
+ }
88
+
89
+
90
+ def _to_crlf(text: str) -> str:
91
+ """Return ``text`` with every line ending written as CRLF."""
92
+ return text.replace("\r\n", "\n").replace("\n", "\r\n")
93
+
94
+
95
+ def _fold(text: str, marker: str) -> str:
96
+ """Fold one side of an edit into a few marked, length-capped lines."""
97
+ if not text:
98
+ return ""
99
+ lines = text.split("\n")
100
+ if lines and lines[-1] == "":
101
+ lines.pop() # the newline ending the last line starts no new one
102
+ shown = [
103
+ line
104
+ if len(line) <= EDIT_PREVIEW_LINE_CHARS
105
+ else line[:EDIT_PREVIEW_LINE_CHARS] + "..."
106
+ for line in lines[:EDIT_PREVIEW_LINES]
107
+ ]
108
+ hidden = len(lines) - len(shown)
109
+ if hidden > 0:
110
+ shown.append(f"... (+{hidden} more lines)")
111
+ return "\n".join(f"{marker} {line}" for line in shown)
112
+
113
+
114
+ def edit_preview(old_text: str, new_text: str) -> str:
115
+ """Fold both sides of an edit into a small ``-``/``+`` diff for the echo.
116
+
117
+ A deletion shows only the removed side, so an empty ``new_text`` reads
118
+ as a removal rather than as a swap for nothing.
119
+ """
120
+ sides = (_fold(old_text, "-"), _fold(new_text, "+"))
121
+ return "\n".join(side for side in sides if side)
122
+
123
+
124
+ def _match(text: str, old_text: str, new_text: str) -> tuple[str, str, int, bool]:
125
+ """Locate ``old_text`` in ``text``; return what to use and how often.
126
+
127
+ The first pass is a raw exact search. Only when it finds nothing does a
128
+ single CRLF pass follow, and only for the case that makes it necessary:
129
+ ``read`` drops the ``\\r`` of a CRLF file when it shows it, so a
130
+ multi-line ``old_text`` copied out of that view can hold LF alone —
131
+ the model cannot produce the file's real bytes. An ``old_text`` that
132
+ does carry ``\\r`` is taken at its word and gets the raw pass only.
133
+
134
+ The two passes are never merged: whichever one matches decides both
135
+ uniqueness and the replacement count, so there is no way for hits in
136
+ different encodings to overlap or to be counted twice. In a file with
137
+ mixed line endings that means one call reaches one style and the other
138
+ fails closed, which the caller's error message says out loud.
139
+
140
+ Returns ``(old, new, count, crlf_pass_ran)``.
141
+ """
142
+ count = text.count(old_text)
143
+ if count:
144
+ return old_text, new_text, count, False
145
+ if "\r\n" in text and "\n" in old_text and "\r" not in old_text:
146
+ old_crlf = _to_crlf(old_text)
147
+ return old_crlf, _to_crlf(new_text), text.count(old_crlf), True
148
+ return old_text, new_text, 0, False
149
+
150
+
151
+ def run_edit(
152
+ path_str: str, old_text: str, new_text: str, replace_all: bool = False
153
+ ) -> tuple[str, bool]:
154
+ """Replace ``old_text`` with ``new_text`` and return ``(output, is_error)``.
155
+
156
+ The file has to exist and be a regular UTF-8 text file: creating one and
157
+ rewriting one whole are ``write``'s job, and an empty ``old_text`` is
158
+ refused rather than quietly becoming either. ``is_error`` is true for a
159
+ bad argument, a missing or unreadable target, a file this tool cannot
160
+ round-trip byte for byte, no match, or an ambiguous match. Every error
161
+ says what to do next — read again, add context, set ``replace_all``,
162
+ fall back to bash — instead of only what failed.
163
+
164
+ Invalid UTF-8 and NUL bytes are refused: ``read`` shows those files with
165
+ replacement characters so they can be inspected, and writing that view
166
+ back would corrupt the original bytes for good. A UTF-8 BOM is stripped
167
+ before matching and restored on write, since ``read`` leaves it in place
168
+ and an invisible character would otherwise break every ``old_text``
169
+ aimed at the first line.
170
+
171
+ The write is a plain read-compute-write, like ``write``'s: no mtime
172
+ check, no compare-and-swap, no atomic replace. The unique ``old_text``
173
+ is the precondition; in this agent's single-threaded loop the rest
174
+ would be pretend safety.
175
+ """
176
+ if not old_text:
177
+ return (
178
+ "[old_text is empty; use write to create a file or replace it whole]",
179
+ True,
180
+ )
181
+ if old_text == new_text:
182
+ return "[old_text and new_text are identical: nothing to change]", True
183
+
184
+ path = Path(path_str).expanduser()
185
+ if not path.exists():
186
+ return f"[file not found: {path_str}; use write to create it]", True
187
+ if path.is_dir():
188
+ return f"[{path_str} is a directory, not a file]", True
189
+ if not path.is_file():
190
+ # Same reasoning as read's and write's: a FIFO blocks until the other
191
+ # end shows up, and a device file is not workspace text.
192
+ return f"[{path_str} is not a regular file; edit is text-only]", True
193
+ try:
194
+ size = path.stat().st_size
195
+ if size > MAX_READ_BYTES:
196
+ return (
197
+ f"[{path_str} is {size} bytes, over the {MAX_READ_BYTES}-byte "
198
+ f"cap; edit rewrites the whole file in memory, so change it "
199
+ f"with bash instead, e.g. with sed -i]",
200
+ True,
201
+ )
202
+ data = path.read_bytes()
203
+ except FileNotFoundError: # the file went away after the check above
204
+ return f"[file not found: {path_str}]", True
205
+ except OSError as exc:
206
+ return f"[cannot read {path_str}: {exc}]", True
207
+
208
+ # Unlike read, which samples the head to spot a binary file, edit has to
209
+ # scan all of it: a NUL anywhere would come back through the round-trip.
210
+ if b"\x00" in data:
211
+ return f"[{path_str} contains NUL bytes; edit is text-only]", True
212
+ try:
213
+ text = data.decode("utf-8") # strict: no replacement characters
214
+ except UnicodeDecodeError as exc:
215
+ return (
216
+ f"[{path_str} is not valid UTF-8: {exc}; edit only changes text "
217
+ f"it can write back byte for byte — use bash instead]",
218
+ True,
219
+ )
220
+
221
+ bom = text.startswith("\ufeff")
222
+ if bom:
223
+ text = text[1:]
224
+
225
+ old, new, count, crlf_pass_ran = _match(text, old_text, new_text)
226
+ if count == 0:
227
+ retried = " — the CRLF form was already tried" if crlf_pass_ran else ""
228
+ return (
229
+ f"[no match for old_text in {path_str}; read the file again and "
230
+ f"copy the text exactly{retried}]",
231
+ True,
232
+ )
233
+ if count > 1 and not replace_all:
234
+ return (
235
+ f"[old_text matches {count} times in {path_str}; add surrounding "
236
+ f"context to make it unique, or set replace_all=true]",
237
+ True,
238
+ )
239
+
240
+ # str.replace walks left to right over non-overlapping matches, the same
241
+ # ones str.count reported, so the count above is what actually changes.
242
+ line = text.count("\n", 0, text.index(old)) + 1
243
+ edited = text.replace(old, new) if replace_all else text.replace(old, new, 1)
244
+ try:
245
+ path.write_bytes(("\ufeff" + edited if bom else edited).encode("utf-8"))
246
+ except OSError as exc:
247
+ return (
248
+ f"[cannot write {path_str}: {exc}; inspect it with bash, e.g. "
249
+ f"ls -l -- {shlex.quote(str(path))}]",
250
+ True,
251
+ )
252
+
253
+ replaced = count if replace_all else 1
254
+ if replaced == 1:
255
+ return f"[edited {path_str}: replaced 1 occurrence at line {line}]", False
256
+ return (
257
+ f"[edited {path_str}: replaced {replaced} occurrences, "
258
+ f"first at line {line}]",
259
+ False,
260
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nanoPyCodeAgent
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: A nano code agent built from scratch in pure Python.
5
5
  Project-URL: Homepage, https://github.com/minixalpha/nanoPyCodeAgent
6
6
  Project-URL: Repository, https://github.com/minixalpha/nanoPyCodeAgent
@@ -1,12 +1,13 @@
1
1
  nanopycodeagent/__init__.py,sha256=pYFCw7WNkZsGXbqNLkXituX4iBy86RJ4LKKAr78XwM8,70
2
- nanopycodeagent/agent.py,sha256=4HC6ZXm3zMKso1-VS9VYH4bIrFcR33U_gIbBcrfsnX4,7219
2
+ nanopycodeagent/agent.py,sha256=FFRhra91Du9DOyir-3NSOwj4uI6JVsTqitdXH3pBPwc,7893
3
3
  nanopycodeagent/bash_tool.py,sha256=fTaGz4gfFpBykBaw4jyYeKgibOKrIfJMKqBYoBEE4og,2958
4
+ nanopycodeagent/edit_tool.py,sha256=eOK-yREnkIKwgTZqsFy0y7N9iRCyC2JFzM1LBCRInQ0,11131
4
5
  nanopycodeagent/read_tool.py,sha256=4AddoZ8uHGzPNKMFagrskjmOWv5nhpk3oJbG8eBBgcM,7374
5
6
  nanopycodeagent/settings.py,sha256=Tn9si3uRTt2IioP4GTYJGuCYVBCyTTJErq9FmuTs0pw,1296
6
7
  nanopycodeagent/terminal.py,sha256=jSe0CXayttR25RBt_qmCsFURVDW0uha391hBKgW3XwQ,3340
7
8
  nanopycodeagent/write_tool.py,sha256=dnHoNGT6HpQvJXQvxXPQcS4yBTtfwCkSV4hLAn9_YgY,6168
8
- nanopycodeagent-0.6.0.dist-info/METADATA,sha256=x0dali3scKdug_qwwMy2XzPxNpZh3pPv5oIwbPD9VLg,3028
9
- nanopycodeagent-0.6.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
- nanopycodeagent-0.6.0.dist-info/entry_points.txt,sha256=4gU7YNzQaf2RKlOdfebOWYNYIUk2LDoyhu3G4Vgole8,57
11
- nanopycodeagent-0.6.0.dist-info/licenses/LICENSE,sha256=g24TppoiLdeF_1_-Y7H2H_eOavKseWA5utuK83Vq1sA,1067
12
- nanopycodeagent-0.6.0.dist-info/RECORD,,
9
+ nanopycodeagent-0.7.0.dist-info/METADATA,sha256=c-rPxaUijIsCE2aI9Xg7-NAXwiMYfr_Uxad6sG5-Ec8,3028
10
+ nanopycodeagent-0.7.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
11
+ nanopycodeagent-0.7.0.dist-info/entry_points.txt,sha256=4gU7YNzQaf2RKlOdfebOWYNYIUk2LDoyhu3G4Vgole8,57
12
+ nanopycodeagent-0.7.0.dist-info/licenses/LICENSE,sha256=g24TppoiLdeF_1_-Y7H2H_eOavKseWA5utuK83Vq1sA,1067
13
+ nanopycodeagent-0.7.0.dist-info/RECORD,,