nanoPyCodeAgent 0.4.0__py3-none-any.whl → 0.5.1__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
@@ -1,9 +1,9 @@
1
1
  """A minimal agent loop built on the Anthropic Python SDK.
2
2
 
3
3
  Run the program, type a message, and Agent replies. The full conversation is
4
- kept in memory so each turn has context. The model can call a single ``bash``
5
- tool to run shell commands; every command and its output are echoed to the
6
- terminal as they happen. Type ``/exit`` to quit.
4
+ kept in memory so each turn has context. The model can call a ``read`` tool
5
+ to view files and a ``bash`` tool to run shell commands; every call and its
6
+ output are echoed to the terminal as they happen. Type ``/exit`` to quit.
7
7
 
8
8
  The loop handles only the happy path: anything unexpected — a network error,
9
9
  a Ctrl-C mid-turn — crashes the session, and restarting it is the recovery.
@@ -12,11 +12,23 @@ preserved at the ``hardened-agent-loop`` tag.
12
12
  """
13
13
 
14
14
  import os
15
+ from importlib.metadata import PackageNotFoundError, version
16
+
17
+ try:
18
+ # Importing readline routes input() through a line editor that redraws the
19
+ # whole line. Without it the tty erases one column per backspace, which
20
+ # leaves half of a double-width character (CJK, emoji) on screen even
21
+ # though it is gone from the buffer. Editing and history come along for
22
+ # the ride. Not available on every platform, so the import is optional.
23
+ import readline # noqa: F401
24
+ except ImportError: # pragma: no cover - platform without readline
25
+ pass
15
26
 
16
27
  import anthropic
17
28
  from anthropic.types import MessageParam, ToolResultBlockParam, ToolUseBlock
18
29
 
19
30
  from .bash_tool import BASH_TOOL, run_bash
31
+ from .read_tool import READ_TOOL, run_read
20
32
  from .settings import load_settings_env
21
33
  from .terminal import print_tool
22
34
 
@@ -26,16 +38,43 @@ DEFAULT_MODEL = "claude-sonnet-4-6"
26
38
  MAX_TOKENS = 8192
27
39
  SYSTEM_PROMPT = (
28
40
  "You are nanoPyCodeAgent, a concise and helpful coding assistant. "
29
- "Use the bash tool to inspect files, run code, and complete tasks that "
30
- "need real command output instead of guessing."
41
+ "Prefer the read tool for viewing files. Use the bash tool to run "
42
+ "commands, search with grep, and complete tasks that need real command "
43
+ "output instead of guessing."
31
44
  )
32
45
 
46
+ # Every tool offered to the model on each request.
47
+ TOOLS = [READ_TOOL, BASH_TOOL]
48
+
49
+
50
+ def _package_version() -> str:
51
+ """Return the installed package version.
52
+
53
+ The version comes from the package metadata written at install time
54
+ (hatch-vcs derives it from the git tag). When the package is not
55
+ installed — e.g. the module is run straight from a source checkout —
56
+ there is no metadata to read, so fall back to a placeholder.
57
+ """
58
+ try:
59
+ return version("nanoPyCodeAgent")
60
+ except PackageNotFoundError:
61
+ return "unknown"
62
+
33
63
 
34
64
  def _run_one_tool(block: ToolUseBlock) -> ToolResultBlockParam:
35
- """Execute one ``tool_use`` block, echoing the command and its output."""
36
- command = block.input["command"]
37
- print_tool(f"[bash]$ {command}")
38
- output, is_error = run_bash(command)
65
+ """Execute one ``tool_use`` block, echoing the call and its output."""
66
+ if block.name == "read":
67
+ path = block.input["path"]
68
+ print_tool(f"[read] {path}")
69
+ output, is_error = run_read(
70
+ path,
71
+ offset=block.input.get("offset", 1),
72
+ limit=block.input.get("limit"),
73
+ )
74
+ else: # bash — the only other tool offered
75
+ command = block.input["command"]
76
+ print_tool(f"[bash]$ {command}")
77
+ output, is_error = run_bash(command)
39
78
  print_tool(output)
40
79
  return {
41
80
  "type": "tool_result",
@@ -48,7 +87,7 @@ def _run_one_tool(block: ToolUseBlock) -> ToolResultBlockParam:
48
87
  def run() -> None:
49
88
  """Start the read → ask → answer loop until the user types ``/exit``.
50
89
 
51
- A reply may include bash tool calls; they are executed and their results
90
+ A reply may include tool calls; they are executed and their results
52
91
  fed back to the model until it finishes the turn without tool use.
53
92
  """
54
93
  # Fill any unset ANTHROPIC_* keys from the config file (environment
@@ -67,13 +106,20 @@ def run() -> None:
67
106
  return
68
107
 
69
108
  model = os.environ.get("ANTHROPIC_MODEL", "").strip() or DEFAULT_MODEL
70
- print(f"nanoPyCodeAgent — model {model} (set ANTHROPIC_MODEL to override).")
109
+ print(
110
+ f"nanoPyCodeAgent v{_package_version()} — model {model} "
111
+ "(set ANTHROPIC_MODEL to override)."
112
+ )
71
113
  print("Type a message to chat, or /exit to quit.")
72
114
 
73
115
  messages: list[MessageParam] = []
74
116
  while True:
75
117
  try:
76
- user_input = input("\nYou> ").strip()
118
+ # The blank line before the prompt is printed separately: readline
119
+ # measures the prompt to place the cursor, and a newline inside it
120
+ # throws that off.
121
+ print()
122
+ user_input = input("You> ").strip()
77
123
  except (EOFError, KeyboardInterrupt):
78
124
  print()
79
125
  break
@@ -93,7 +139,7 @@ def run() -> None:
93
139
  model=model,
94
140
  max_tokens=MAX_TOKENS,
95
141
  system=SYSTEM_PROMPT,
96
- tools=[BASH_TOOL],
142
+ tools=TOOLS,
97
143
  messages=messages,
98
144
  ) as stream:
99
145
  for text in stream.text_stream:
@@ -0,0 +1,176 @@
1
+ """The ``read`` tool: its definition and its execution.
2
+
3
+ Each call reads a window of a UTF-8 text file and returns it with 1-based
4
+ line numbers, ``cat -n`` style, plus a continuation hint whenever the
5
+ window stops short of the end of the file.
6
+ """
7
+
8
+ import shlex
9
+ from pathlib import Path
10
+
11
+ from anthropic.types import ToolParam
12
+
13
+ # Guardrails for the read tool: one call returns at most this many lines and
14
+ # this many characters, whichever is hit first, so a single file cannot flood
15
+ # the context. Truncation happens at line boundaries, never mid-line.
16
+ MAX_READ_LINES = 2_000
17
+ MAX_READ_CHARS = 50_000
18
+
19
+ # The output caps above only bound what reaches the context; the file itself
20
+ # is read whole, so a byte cap is what bounds memory. It is generous next to
21
+ # the output caps because a small window may sit deep inside a large source
22
+ # file — it only has to keep a multi-gigabyte log from being loaded at all.
23
+ MAX_READ_BYTES = 10_000_000
24
+
25
+ READ_TOOL: ToolParam = {
26
+ "name": "read",
27
+ "description": (
28
+ "Read a UTF-8 text file and return its content with 1-based line "
29
+ "numbers, `cat -n` style. Use `offset` (first line to read) and "
30
+ "`limit` (number of lines) to read a window of a large file. Output "
31
+ "is capped by line count and character count; a truncated result "
32
+ "names the range returned, the file's total line count, and the "
33
+ "offset to continue from. Binary files are rejected. Prefer this "
34
+ "over `cat`/`sed` in bash for viewing files; use bash for searching "
35
+ "(grep) and for slicing single lines too long to return."
36
+ ),
37
+ "input_schema": {
38
+ "type": "object",
39
+ "properties": {
40
+ "path": {
41
+ "type": "string",
42
+ "description": (
43
+ "Path to the file, absolute or relative to the agent's "
44
+ "working directory. A leading ~ is expanded."
45
+ ),
46
+ },
47
+ "offset": {
48
+ "type": "integer",
49
+ "description": "1-based line number to start from (default 1).",
50
+ },
51
+ "limit": {
52
+ "type": "integer",
53
+ "description": (
54
+ f"Maximum number of lines to return. Omit to read up to "
55
+ f"the {MAX_READ_LINES}-line cap; larger values are "
56
+ f"clamped to it."
57
+ ),
58
+ },
59
+ },
60
+ "required": ["path"],
61
+ },
62
+ }
63
+
64
+
65
+ def run_read(
66
+ path_str: str, offset: int = 1, limit: int | None = None
67
+ ) -> tuple[str, bool]:
68
+ """Read a window of the file and return ``(output, is_error)``.
69
+
70
+ ``is_error`` is true when the tool could not produce a reading: a bad
71
+ argument, a missing file, a directory or any other non-regular file, a
72
+ file over the byte cap, a binary file, or a single line over the
73
+ character cap. Only regular files are opened — a FIFO or a device file
74
+ would block or never end, and there is no timeout here to escape that.
75
+ Every error message states what to do next — the file's total line
76
+ count, a bash command to slice an over-long line — rather than just
77
+ what failed.
78
+
79
+ Content is decoded as UTF-8 with invalid bytes replaced, matching the
80
+ bash tool's tolerance. The whole file is read into memory: fine for
81
+ source and config files, and the output caps bound what reaches the
82
+ context either way. Files over the byte cap are turned away before the
83
+ read, since the output caps would otherwise apply only after the whole
84
+ file had already been loaded.
85
+ """
86
+ if offset < 1:
87
+ return f"[invalid offset {offset}: line numbers start at 1]", True
88
+ if limit is not None and limit < 1:
89
+ return f"[invalid limit {limit}: must be at least 1]", True
90
+
91
+ path = Path(path_str).expanduser()
92
+ if not path.exists():
93
+ return f"[file not found: {path_str}]", True
94
+ if path.is_dir():
95
+ return f"[{path_str} is a directory, not a file]", True
96
+ if not path.is_file():
97
+ # A FIFO blocks until something writes to it and a device file like
98
+ # /dev/zero never ends. Reading either would hang the session, and
99
+ # unlike the bash tool there is no timeout to escape it.
100
+ return f"[{path_str} is not a regular file; read is text-only]", True
101
+ try:
102
+ size = path.stat().st_size
103
+ if size > MAX_READ_BYTES:
104
+ return (
105
+ f"[{path_str} is {size} bytes, over the "
106
+ f"{MAX_READ_BYTES}-byte cap; read loads the whole file into "
107
+ f"memory, so slice it with bash instead, e.g. with "
108
+ f"sed -n '1,200p']",
109
+ True,
110
+ )
111
+ data = path.read_bytes()
112
+ except FileNotFoundError: # the file went away after the check above
113
+ return f"[file not found: {path_str}]", True
114
+ except OSError as exc:
115
+ return f"[cannot read {path_str}: {exc}]", True
116
+
117
+ # Null bytes near the start mark a binary file; a text reading of it
118
+ # would only waste context.
119
+ if b"\x00" in data[:8192]:
120
+ return f"[{path_str} looks like a binary file; read is text-only]", True
121
+
122
+ # Split on \n alone, not with splitlines(): that also breaks on \x0c,
123
+ # \x0b, \x85 and U+2028, which would number the lines differently from
124
+ # sed, grep -n and the user's editor. A trailing \r is dropped so CRLF
125
+ # files read like LF ones.
126
+ lines = [
127
+ line.removesuffix("\r")
128
+ for line in data.decode("utf-8", errors="replace").split("\n")
129
+ ]
130
+ if lines and lines[-1] == "":
131
+ lines.pop() # the newline ending the last line starts no new one
132
+ total = len(lines)
133
+ if total == 0:
134
+ return "(empty file)", False
135
+ if offset > total:
136
+ return (
137
+ f"[offset {offset} is past the end of {path_str} ({total} lines)]",
138
+ True,
139
+ )
140
+
141
+ max_lines = MAX_READ_LINES if limit is None else min(limit, MAX_READ_LINES)
142
+ window = lines[offset - 1 : offset - 1 + max_lines]
143
+
144
+ # A single line over the character cap cannot be returned whole, and
145
+ # returning a bare fragment would hide where it came from — point at
146
+ # bash instead. The path goes into a command the model may run, so it
147
+ # is the expanded one (quoting would stop the shell from expanding ~),
148
+ # quoted so spaces and $(...) in a filename cannot reshape the command,
149
+ # and behind -- so a leading dash cannot read as an option.
150
+ if len(window[0]) > MAX_READ_CHARS:
151
+ return (
152
+ f"[line {offset} is {len(window[0])} characters long, over the "
153
+ f"{MAX_READ_CHARS}-character cap; slice it with bash instead, "
154
+ f"e.g. sed -n '{offset}p' -- {shlex.quote(str(path))} "
155
+ f"| cut -c 1-2000]",
156
+ True,
157
+ )
158
+
159
+ selected: list[str] = []
160
+ chars = 0
161
+ for number, line in enumerate(window, start=offset):
162
+ rendered = f"{number:6}\t{line}"
163
+ added = len(rendered) + (1 if selected else 0) # + the joining newline
164
+ if selected and chars + added > MAX_READ_CHARS:
165
+ break
166
+ selected.append(rendered)
167
+ chars += added
168
+
169
+ last = offset + len(selected) - 1
170
+ body = "\n".join(selected)
171
+ if last < total:
172
+ body += (
173
+ f"\n[showing lines {offset}-{last} of {total}; "
174
+ f"continue with offset={last + 1}]"
175
+ )
176
+ return body, False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nanoPyCodeAgent
3
- Version: 0.4.0
3
+ Version: 0.5.1
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,11 @@
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,,
@@ -1,10 +0,0 @@
1
- nanopycodeagent/__init__.py,sha256=pYFCw7WNkZsGXbqNLkXituX4iBy86RJ4LKKAr78XwM8,70
2
- nanopycodeagent/agent.py,sha256=AvfiDvaPVycbA3H_jJhD8DA_gELbcPOxrZWrZfa2hek,4354
3
- nanopycodeagent/bash_tool.py,sha256=fTaGz4gfFpBykBaw4jyYeKgibOKrIfJMKqBYoBEE4og,2958
4
- nanopycodeagent/settings.py,sha256=Tn9si3uRTt2IioP4GTYJGuCYVBCyTTJErq9FmuTs0pw,1296
5
- nanopycodeagent/terminal.py,sha256=ZkRvKbGQGiu7SI8Pe40THVYR1-4CMBEMVEgAH8h8Ytw,995
6
- nanopycodeagent-0.4.0.dist-info/METADATA,sha256=a0FBbVdPfx9K8zYiGA6fMZh2ZqC6tvFUm2OZTymLeUs,3028
7
- nanopycodeagent-0.4.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
- nanopycodeagent-0.4.0.dist-info/entry_points.txt,sha256=4gU7YNzQaf2RKlOdfebOWYNYIUk2LDoyhu3G4Vgole8,57
9
- nanopycodeagent-0.4.0.dist-info/licenses/LICENSE,sha256=g24TppoiLdeF_1_-Y7H2H_eOavKseWA5utuK83Vq1sA,1067
10
- nanopycodeagent-0.4.0.dist-info/RECORD,,