nanoPyCodeAgent 0.5.1__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 +46 -14
- nanopycodeagent/edit_tool.py +260 -0
- nanopycodeagent/terminal.py +76 -9
- nanopycodeagent/write_tool.py +153 -0
- {nanopycodeagent-0.5.1.dist-info → nanopycodeagent-0.7.0.dist-info}/METADATA +2 -2
- nanopycodeagent-0.7.0.dist-info/RECORD +13 -0
- {nanopycodeagent-0.5.1.dist-info → nanopycodeagent-0.7.0.dist-info}/WHEEL +1 -1
- nanopycodeagent-0.5.1.dist-info/RECORD +0 -11
- {nanopycodeagent-0.5.1.dist-info → nanopycodeagent-0.7.0.dist-info}/entry_points.txt +0 -0
- {nanopycodeagent-0.5.1.dist-info → nanopycodeagent-0.7.0.dist-info}/licenses/LICENSE +0 -0
nanopycodeagent/agent.py
CHANGED
|
@@ -2,8 +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
|
|
6
|
-
|
|
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.
|
|
7
9
|
|
|
8
10
|
The loop handles only the happy path: anything unexpected — a network error,
|
|
9
11
|
a Ctrl-C mid-turn — crashes the session, and restarting it is the recovery.
|
|
@@ -28,9 +30,11 @@ import anthropic
|
|
|
28
30
|
from anthropic.types import MessageParam, ToolResultBlockParam, ToolUseBlock
|
|
29
31
|
|
|
30
32
|
from .bash_tool import BASH_TOOL, run_bash
|
|
33
|
+
from .edit_tool import EDIT_TOOL, edit_preview, run_edit
|
|
31
34
|
from .read_tool import READ_TOOL, run_read
|
|
32
35
|
from .settings import load_settings_env
|
|
33
|
-
from .terminal import
|
|
36
|
+
from .terminal import Spinner, print_tool_output, print_tool_use
|
|
37
|
+
from .write_tool import WRITE_TOOL, content_preview, run_write
|
|
34
38
|
|
|
35
39
|
# The model used when ANTHROPIC_MODEL is set in neither the environment nor
|
|
36
40
|
# the config file.
|
|
@@ -38,13 +42,15 @@ DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
|
38
42
|
MAX_TOKENS = 8192
|
|
39
43
|
SYSTEM_PROMPT = (
|
|
40
44
|
"You are nanoPyCodeAgent, a concise and helpful coding assistant. "
|
|
41
|
-
"Prefer the read tool for viewing files
|
|
42
|
-
"
|
|
43
|
-
"
|
|
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."
|
|
44
50
|
)
|
|
45
51
|
|
|
46
52
|
# Every tool offered to the model on each request.
|
|
47
|
-
TOOLS = [READ_TOOL, BASH_TOOL]
|
|
53
|
+
TOOLS = [READ_TOOL, WRITE_TOOL, EDIT_TOOL, BASH_TOOL]
|
|
48
54
|
|
|
49
55
|
|
|
50
56
|
def _package_version() -> str:
|
|
@@ -65,17 +71,35 @@ def _run_one_tool(block: ToolUseBlock) -> ToolResultBlockParam:
|
|
|
65
71
|
"""Execute one ``tool_use`` block, echoing the call and its output."""
|
|
66
72
|
if block.name == "read":
|
|
67
73
|
path = block.input["path"]
|
|
68
|
-
|
|
74
|
+
print_tool_use(f"[read] {path}")
|
|
69
75
|
output, is_error = run_read(
|
|
70
76
|
path,
|
|
71
77
|
offset=block.input.get("offset", 1),
|
|
72
78
|
limit=block.input.get("limit"),
|
|
73
79
|
)
|
|
80
|
+
elif block.name == "write":
|
|
81
|
+
path = block.input["path"]
|
|
82
|
+
content = block.input["content"]
|
|
83
|
+
# The echo folds the content: the terminal shows where the write
|
|
84
|
+
# goes and how it starts, not hundreds of lines.
|
|
85
|
+
print_tool_use(f"[write] {path}\n{content_preview(content)}")
|
|
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
|
+
)
|
|
74
97
|
else: # bash — the only other tool offered
|
|
75
98
|
command = block.input["command"]
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
print_tool_use(f"[bash]$ {command}")
|
|
100
|
+
with Spinner("Running..."):
|
|
101
|
+
output, is_error = run_bash(command)
|
|
102
|
+
print_tool_output(output)
|
|
79
103
|
return {
|
|
80
104
|
"type": "tool_result",
|
|
81
105
|
"tool_use_id": block.id,
|
|
@@ -132,10 +156,13 @@ def run() -> None:
|
|
|
132
156
|
# The model may ask to run tools; keep streaming replies and feeding
|
|
133
157
|
# results back until it finishes a reply without tool calls.
|
|
134
158
|
while True:
|
|
135
|
-
|
|
159
|
+
# A spinner marks the wait for the reply; the first streamed
|
|
160
|
+
# token replaces it with the Agent> prompt. A tool-only reply
|
|
161
|
+
# streams no text, so the prompt is skipped for it entirely.
|
|
162
|
+
replied = False
|
|
136
163
|
# Stream the reply so text shows up as it is generated, then grab
|
|
137
164
|
# the accumulated message for the conversation history.
|
|
138
|
-
with client.messages.stream(
|
|
165
|
+
with Spinner() as spinner, client.messages.stream(
|
|
139
166
|
model=model,
|
|
140
167
|
max_tokens=MAX_TOKENS,
|
|
141
168
|
system=SYSTEM_PROMPT,
|
|
@@ -143,9 +170,14 @@ def run() -> None:
|
|
|
143
170
|
messages=messages,
|
|
144
171
|
) as stream:
|
|
145
172
|
for text in stream.text_stream:
|
|
173
|
+
if not replied:
|
|
174
|
+
spinner.stop()
|
|
175
|
+
print("\nAgent> ", end="", flush=True)
|
|
176
|
+
replied = True
|
|
146
177
|
print(text, end="", flush=True)
|
|
147
178
|
message = stream.get_final_message()
|
|
148
|
-
|
|
179
|
+
if replied:
|
|
180
|
+
print()
|
|
149
181
|
|
|
150
182
|
messages.append({"role": "assistant", "content": message.content})
|
|
151
183
|
if message.stop_reason != "tool_use":
|
|
@@ -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
|
+
)
|
nanopycodeagent/terminal.py
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Terminal presentation: tool-activity shading and a wait spinner."""
|
|
2
2
|
|
|
3
|
+
import itertools
|
|
3
4
|
import os
|
|
4
5
|
import sys
|
|
6
|
+
import threading
|
|
5
7
|
|
|
6
|
-
#
|
|
7
|
-
# read apart
|
|
8
|
-
|
|
8
|
+
# Grays from the 256-color palette. The echoed call sits on a lighter shade
|
|
9
|
+
# than its output so the two read apart at a glance, and both read apart
|
|
10
|
+
# from the user's prompts and the model's prose. The output keeps the
|
|
11
|
+
# darker shade: it stays subdued while remaining visible on common dark
|
|
12
|
+
# terminal backgrounds.
|
|
13
|
+
_USE_BG = "\x1b[48;5;238m"
|
|
14
|
+
_OUTPUT_BG = "\x1b[48;5;236m"
|
|
9
15
|
_RESET = "\x1b[0m"
|
|
10
16
|
|
|
11
17
|
|
|
@@ -14,8 +20,8 @@ def _use_color() -> bool:
|
|
|
14
20
|
return sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
15
21
|
|
|
16
22
|
|
|
17
|
-
def
|
|
18
|
-
"""Print
|
|
23
|
+
def _print_shaded(text: str, bg: str) -> None:
|
|
24
|
+
"""Print ``text`` with every line shaded in ``bg``.
|
|
19
25
|
|
|
20
26
|
Each line carries its own set-background / erase-to-EOL / reset, so the
|
|
21
27
|
shading spans the full terminal width and never leaks past a line
|
|
@@ -23,7 +29,68 @@ def print_tool(text: str) -> None:
|
|
|
23
29
|
disrupt the shading — a cosmetic trade for staying simple.
|
|
24
30
|
"""
|
|
25
31
|
if _use_color():
|
|
26
|
-
text = "\n".join(
|
|
27
|
-
f"{_TOOL_BG}{line}\x1b[K{_RESET}" for line in text.split("\n")
|
|
28
|
-
)
|
|
32
|
+
text = "\n".join(f"{bg}{line}\x1b[K{_RESET}" for line in text.split("\n"))
|
|
29
33
|
print(text, flush=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def print_tool_use(text: str) -> None:
|
|
37
|
+
"""Print an echoed tool call (e.g. ``[bash]$ ls``) on the lighter shade."""
|
|
38
|
+
_print_shaded(text, _USE_BG)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def print_tool_output(text: str) -> None:
|
|
42
|
+
"""Print a tool's output on the darker shade."""
|
|
43
|
+
_print_shaded(text, _OUTPUT_BG)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# The classic braille "dots" spinner: one glyph per frame, different dots
|
|
47
|
+
# raised in each, cycled fast enough to read as rotation.
|
|
48
|
+
_SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
49
|
+
_SPINNER_INTERVAL = 0.08
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Spinner:
|
|
53
|
+
"""Animate ``⠋ Working...`` in place on one line until stopped.
|
|
54
|
+
|
|
55
|
+
A background thread redraws the line with carriage returns while the
|
|
56
|
+
caller blocks on slow work, and erases it on stop, so the spinner
|
|
57
|
+
leaves no trace in the transcript. When stdout is not a terminal (or
|
|
58
|
+
NO_COLOR is set) nothing is printed at all. ``stop`` is idempotent:
|
|
59
|
+
stopping early — say, when the first streamed token arrives — and
|
|
60
|
+
again on context-manager exit is fine.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, text: str = "Working..."):
|
|
64
|
+
self._text = text
|
|
65
|
+
self._stop_event = threading.Event()
|
|
66
|
+
self._thread: threading.Thread | None = None
|
|
67
|
+
|
|
68
|
+
def start(self) -> None:
|
|
69
|
+
if not _use_color():
|
|
70
|
+
return
|
|
71
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
72
|
+
self._thread.start()
|
|
73
|
+
|
|
74
|
+
def stop(self) -> None:
|
|
75
|
+
if self._thread is None:
|
|
76
|
+
return
|
|
77
|
+
self._stop_event.set()
|
|
78
|
+
self._thread.join()
|
|
79
|
+
|
|
80
|
+
def __enter__(self) -> "Spinner":
|
|
81
|
+
self.start()
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
85
|
+
self.stop()
|
|
86
|
+
|
|
87
|
+
def _spin(self) -> None:
|
|
88
|
+
# Draw a frame, then sleep on the stop event so stop() interrupts
|
|
89
|
+
# the pause instead of waiting out the full interval.
|
|
90
|
+
for frame in itertools.cycle(_SPINNER_FRAMES):
|
|
91
|
+
sys.stdout.write(f"\r{frame} {self._text}")
|
|
92
|
+
sys.stdout.flush()
|
|
93
|
+
if self._stop_event.wait(_SPINNER_INTERVAL):
|
|
94
|
+
break
|
|
95
|
+
sys.stdout.write("\r\x1b[K") # erase the spinner line
|
|
96
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The ``write`` tool: its definition and its execution.
|
|
2
|
+
|
|
3
|
+
Each call writes a whole UTF-8 text file from a structured
|
|
4
|
+
``{path, content}`` input: the content lands on disk as data, never
|
|
5
|
+
passing through shell expansion, heredoc delimiters or quoting, which is
|
|
6
|
+
what makes a heredoc silently corrupt a file while exiting 0.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import shlex
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from anthropic.types import ToolParam
|
|
13
|
+
|
|
14
|
+
# The terminal echo folds the content to this many lines, and any one line
|
|
15
|
+
# to this many characters, so a large write does not flood the terminal the
|
|
16
|
+
# way the heredoc it replaces would. The full content reaches the file (and
|
|
17
|
+
# the model already holds it); only the echo is folded.
|
|
18
|
+
WRITE_PREVIEW_LINES = 10
|
|
19
|
+
WRITE_PREVIEW_LINE_CHARS = 200
|
|
20
|
+
|
|
21
|
+
WRITE_TOOL: ToolParam = {
|
|
22
|
+
"name": "write",
|
|
23
|
+
"description": (
|
|
24
|
+
"Write a UTF-8 text file: create it if missing (parent directories "
|
|
25
|
+
"included), or replace its entire content if it exists. The result "
|
|
26
|
+
"says which of the two happened. Use this for new files and "
|
|
27
|
+
"whole-file rewrites instead of shell redirection or heredocs; use "
|
|
28
|
+
"bash to append or to transform many files at once. The write is a "
|
|
29
|
+
"plain overwrite — last writer wins, with no check that the file "
|
|
30
|
+
"was read first or is unchanged since."
|
|
31
|
+
),
|
|
32
|
+
"input_schema": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"path": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"description": (
|
|
38
|
+
"Path to the file, absolute or relative to the agent's "
|
|
39
|
+
"working directory. A leading ~ is expanded."
|
|
40
|
+
),
|
|
41
|
+
},
|
|
42
|
+
"content": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": (
|
|
45
|
+
"The complete file content, written exactly as given — "
|
|
46
|
+
"no newline or whitespace normalization."
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
"required": ["path", "content"],
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _logical_lines(content: str) -> list[str]:
|
|
56
|
+
"""Split ``content`` into the lines read would number.
|
|
57
|
+
|
|
58
|
+
The split is on ``\\n`` alone, and the newline ending the last line
|
|
59
|
+
starts no new one — so ``"a\\nb\\n"`` is two lines, not three.
|
|
60
|
+
"""
|
|
61
|
+
lines = content.split("\n")
|
|
62
|
+
if lines and lines[-1] == "":
|
|
63
|
+
lines.pop()
|
|
64
|
+
return lines
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def content_preview(content: str) -> str:
|
|
68
|
+
"""Fold ``content`` for the terminal echo of a write call.
|
|
69
|
+
|
|
70
|
+
The first few lines are shown, over-long lines are cut short, and a
|
|
71
|
+
closing note counts the hidden lines, so the echo reads as "where the
|
|
72
|
+
write goes and how it starts" rather than the whole file.
|
|
73
|
+
"""
|
|
74
|
+
lines = _logical_lines(content)
|
|
75
|
+
if not lines:
|
|
76
|
+
return "(empty)"
|
|
77
|
+
shown = [
|
|
78
|
+
line
|
|
79
|
+
if len(line) <= WRITE_PREVIEW_LINE_CHARS
|
|
80
|
+
else line[:WRITE_PREVIEW_LINE_CHARS] + "..."
|
|
81
|
+
for line in lines[:WRITE_PREVIEW_LINES]
|
|
82
|
+
]
|
|
83
|
+
hidden = len(lines) - len(shown)
|
|
84
|
+
if hidden > 0:
|
|
85
|
+
shown.append(f"... (+{hidden} more lines)")
|
|
86
|
+
return "\n".join(shown)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def run_write(path_str: str, content: str) -> tuple[str, bool]:
|
|
90
|
+
"""Write ``content`` to the file whole and return ``(output, is_error)``.
|
|
91
|
+
|
|
92
|
+
The file is created when missing — parent directories included — and
|
|
93
|
+
replaced entirely when it exists; the result names which of the two
|
|
94
|
+
happened, plus the byte and line counts the model can check its intent
|
|
95
|
+
against. ``is_error`` is true when the target is a directory or any
|
|
96
|
+
other non-regular file, or when the write itself fails. A FIFO is
|
|
97
|
+
refused because opening one for writing blocks until a reader appears,
|
|
98
|
+
with no timeout here to escape that; a device file is refused because
|
|
99
|
+
writing to one changes the machine, not the workspace. A symlink to a
|
|
100
|
+
regular file is followed: the write lands in its target.
|
|
101
|
+
|
|
102
|
+
Content is encoded as UTF-8 and written as given — no newline or
|
|
103
|
+
whitespace normalization, no trailing-newline fix-up. The write is a
|
|
104
|
+
plain overwrite: nothing checks that the file was read first or is
|
|
105
|
+
unchanged since, and there is no atomic-replace step. In this agent's
|
|
106
|
+
single-threaded loop those guards would only be pretend safety, so the
|
|
107
|
+
semantics are stated plainly instead: last writer wins.
|
|
108
|
+
"""
|
|
109
|
+
path = Path(path_str).expanduser()
|
|
110
|
+
if path.is_dir():
|
|
111
|
+
return (
|
|
112
|
+
f"[{path_str} is a directory, not a file; give a path to a file "
|
|
113
|
+
f"inside it]",
|
|
114
|
+
True,
|
|
115
|
+
)
|
|
116
|
+
existed = path.exists()
|
|
117
|
+
if existed and not path.is_file():
|
|
118
|
+
# Opening a FIFO for writing blocks until a reader shows up, and a
|
|
119
|
+
# device file must not be overwritten as if it were text. Same
|
|
120
|
+
# check as read's, from the writing side.
|
|
121
|
+
return (
|
|
122
|
+
f"[{path_str} is not a regular file; write refuses FIFOs and "
|
|
123
|
+
f"device files — if it really is the target, write it with "
|
|
124
|
+
f"bash, whose timeout escapes a blocked open]",
|
|
125
|
+
True,
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
data = content.encode("utf-8")
|
|
129
|
+
except UnicodeEncodeError as exc:
|
|
130
|
+
# A lone surrogate cannot reach disk as UTF-8; writing a replacement
|
|
131
|
+
# instead would corrupt the file silently — the heredoc failure mode
|
|
132
|
+
# this tool exists to remove.
|
|
133
|
+
return f"[content is not encodable as UTF-8: {exc}; resend it]", True
|
|
134
|
+
try:
|
|
135
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
path.write_bytes(data)
|
|
137
|
+
except OSError as exc:
|
|
138
|
+
# ``exc`` names the OS reason — permission denied, a file sitting
|
|
139
|
+
# in the parent path, a full disk. The hint points at the usual
|
|
140
|
+
# first suspect, following read's bash-hint conventions: expanded
|
|
141
|
+
# path, quoted, behind -- so it cannot read as an option.
|
|
142
|
+
return (
|
|
143
|
+
f"[cannot write {path_str}: {exc}; inspect the parent with "
|
|
144
|
+
f"bash, e.g. ls -ld -- {shlex.quote(str(path.parent))}]",
|
|
145
|
+
True,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
verb = "overwrote" if existed else "created"
|
|
149
|
+
return (
|
|
150
|
+
f"[{verb} {path_str}: {len(data)} bytes, "
|
|
151
|
+
f"{len(_logical_lines(content))} lines]",
|
|
152
|
+
False,
|
|
153
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: nanoPyCodeAgent
|
|
3
|
-
Version: 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
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
nanopycodeagent/__init__.py,sha256=pYFCw7WNkZsGXbqNLkXituX4iBy86RJ4LKKAr78XwM8,70
|
|
2
|
+
nanopycodeagent/agent.py,sha256=FFRhra91Du9DOyir-3NSOwj4uI6JVsTqitdXH3pBPwc,7893
|
|
3
|
+
nanopycodeagent/bash_tool.py,sha256=fTaGz4gfFpBykBaw4jyYeKgibOKrIfJMKqBYoBEE4og,2958
|
|
4
|
+
nanopycodeagent/edit_tool.py,sha256=eOK-yREnkIKwgTZqsFy0y7N9iRCyC2JFzM1LBCRInQ0,11131
|
|
5
|
+
nanopycodeagent/read_tool.py,sha256=4AddoZ8uHGzPNKMFagrskjmOWv5nhpk3oJbG8eBBgcM,7374
|
|
6
|
+
nanopycodeagent/settings.py,sha256=Tn9si3uRTt2IioP4GTYJGuCYVBCyTTJErq9FmuTs0pw,1296
|
|
7
|
+
nanopycodeagent/terminal.py,sha256=jSe0CXayttR25RBt_qmCsFURVDW0uha391hBKgW3XwQ,3340
|
|
8
|
+
nanopycodeagent/write_tool.py,sha256=dnHoNGT6HpQvJXQvxXPQcS4yBTtfwCkSV4hLAn9_YgY,6168
|
|
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,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
nanopycodeagent/__init__.py,sha256=pYFCw7WNkZsGXbqNLkXituX4iBy86RJ4LKKAr78XwM8,70
|
|
2
|
-
nanopycodeagent/agent.py,sha256=vGu7Nry0wCd_DQr2024vxkSuyFwR7Wwp5U-BZz-3U4Y,6157
|
|
3
|
-
nanopycodeagent/bash_tool.py,sha256=fTaGz4gfFpBykBaw4jyYeKgibOKrIfJMKqBYoBEE4og,2958
|
|
4
|
-
nanopycodeagent/read_tool.py,sha256=4AddoZ8uHGzPNKMFagrskjmOWv5nhpk3oJbG8eBBgcM,7374
|
|
5
|
-
nanopycodeagent/settings.py,sha256=Tn9si3uRTt2IioP4GTYJGuCYVBCyTTJErq9FmuTs0pw,1296
|
|
6
|
-
nanopycodeagent/terminal.py,sha256=ZkRvKbGQGiu7SI8Pe40THVYR1-4CMBEMVEgAH8h8Ytw,995
|
|
7
|
-
nanopycodeagent-0.5.1.dist-info/METADATA,sha256=Z4OVaX6JxPWB3Gg2qpamtUBShim2D35epNKKNorIIN8,3028
|
|
8
|
-
nanopycodeagent-0.5.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
-
nanopycodeagent-0.5.1.dist-info/entry_points.txt,sha256=4gU7YNzQaf2RKlOdfebOWYNYIUk2LDoyhu3G4Vgole8,57
|
|
10
|
-
nanopycodeagent-0.5.1.dist-info/licenses/LICENSE,sha256=g24TppoiLdeF_1_-Y7H2H_eOavKseWA5utuK83Vq1sA,1067
|
|
11
|
-
nanopycodeagent-0.5.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|