nanoPyCodeAgent 0.3.0__py3-none-any.whl → 0.5.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 +86 -119
- nanopycodeagent/bash_tool.py +77 -0
- nanopycodeagent/read_tool.py +176 -0
- nanopycodeagent/settings.py +35 -0
- nanopycodeagent/terminal.py +29 -0
- {nanopycodeagent-0.3.0.dist-info → nanopycodeagent-0.5.0.dist-info}/METADATA +1 -1
- nanopycodeagent-0.5.0.dist-info/RECORD +11 -0
- nanopycodeagent-0.3.0.dist-info/RECORD +0 -7
- {nanopycodeagent-0.3.0.dist-info → nanopycodeagent-0.5.0.dist-info}/WHEEL +0 -0
- {nanopycodeagent-0.3.0.dist-info → nanopycodeagent-0.5.0.dist-info}/entry_points.txt +0 -0
- {nanopycodeagent-0.3.0.dist-info → nanopycodeagent-0.5.0.dist-info}/licenses/LICENSE +0 -0
nanopycodeagent/agent.py
CHANGED
|
@@ -1,107 +1,88 @@
|
|
|
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.
|
|
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
|
+
|
|
8
|
+
The loop handles only the happy path: anything unexpected — a network error,
|
|
9
|
+
a Ctrl-C mid-turn — crashes the session, and restarting it is the recovery.
|
|
10
|
+
That trade keeps the core flow readable; the hardened variant it replaced is
|
|
11
|
+
preserved at the ``hardened-agent-loop`` tag.
|
|
5
12
|
"""
|
|
6
13
|
|
|
7
|
-
import json
|
|
8
14
|
import os
|
|
9
|
-
from
|
|
15
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
16
|
|
|
11
17
|
import anthropic
|
|
12
|
-
import
|
|
13
|
-
from anthropic.types import MessageParam
|
|
18
|
+
from anthropic.types import MessageParam, ToolResultBlockParam, ToolUseBlock
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
from .bash_tool import BASH_TOOL, run_bash
|
|
21
|
+
from .read_tool import READ_TOOL, run_read
|
|
22
|
+
from .settings import load_settings_env
|
|
23
|
+
from .terminal import print_tool
|
|
24
|
+
|
|
25
|
+
# The model used when ANTHROPIC_MODEL is set in neither the environment nor
|
|
26
|
+
# the config file.
|
|
17
27
|
DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
18
28
|
MAX_TOKENS = 8192
|
|
19
|
-
SYSTEM_PROMPT =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"""Resolve the user-level config path, or ``None`` if home is unknown.
|
|
29
|
+
SYSTEM_PROMPT = (
|
|
30
|
+
"You are nanoPyCodeAgent, a concise and helpful coding assistant. "
|
|
31
|
+
"Prefer the read tool for viewing files. Use the bash tool to run "
|
|
32
|
+
"commands, search with grep, and complete tasks that need real command "
|
|
33
|
+
"output instead of guessing."
|
|
34
|
+
)
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
containers). Guarding it here keeps ``import nanopycodeagent`` — which runs
|
|
30
|
-
eagerly behind the console script — from crashing at import; a ``None`` path
|
|
31
|
-
simply means "no user config file".
|
|
32
|
-
"""
|
|
33
|
-
try:
|
|
34
|
-
return Path.home() / ".nanoPyCodeAgent" / "settings.json"
|
|
35
|
-
except RuntimeError:
|
|
36
|
-
return None
|
|
36
|
+
# Every tool offered to the model on each request.
|
|
37
|
+
TOOLS = [READ_TOOL, BASH_TOOL]
|
|
37
38
|
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
def _package_version() -> str:
|
|
41
|
+
"""Return the installed package version.
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
``path`` defaults to the module-level ``SETTINGS_PATH`` (resolved at call
|
|
46
|
-
time, so it stays overridable). Only ``ANTHROPIC_*`` keys that are not
|
|
47
|
-
already present are set, so environment variables take precedence over the
|
|
48
|
-
config file and unrelated variables are never injected. Behaviour by case:
|
|
49
|
-
|
|
50
|
-
- Missing file, or a home directory that cannot be resolved: silently
|
|
51
|
-
ignored (running without a config file is normal).
|
|
52
|
-
- Unreadable / non-UTF-8 file, malformed JSON, non-object top level, or a
|
|
53
|
-
non-object ``env``: a warning is printed and the file is otherwise
|
|
54
|
-
ignored — a bad config never blocks startup.
|
|
55
|
-
- Empty, whitespace-only, or non-string values, and values the OS rejects
|
|
56
|
-
(e.g. an embedded NUL): skipped (the documented example ships these keys
|
|
57
|
-
as empty-string placeholders).
|
|
43
|
+
The version comes from the package metadata written at install time
|
|
44
|
+
(hatch-vcs derives it from the git tag). When the package is not
|
|
45
|
+
installed — e.g. the module is run straight from a source checkout —
|
|
46
|
+
there is no metadata to read, so fall back to a placeholder.
|
|
58
47
|
"""
|
|
59
|
-
if path is None:
|
|
60
|
-
path = SETTINGS_PATH
|
|
61
|
-
if path is None:
|
|
62
|
-
return # home dir unresolvable → behave as if no config file exists
|
|
63
|
-
try:
|
|
64
|
-
raw = path.read_text(encoding="utf-8")
|
|
65
|
-
except FileNotFoundError:
|
|
66
|
-
return
|
|
67
|
-
except (OSError, UnicodeDecodeError) as exc:
|
|
68
|
-
print(f"Warning: could not read config file {path}: {exc}")
|
|
69
|
-
return
|
|
70
|
-
|
|
71
48
|
try:
|
|
72
|
-
|
|
73
|
-
except
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
print(f"Warning: ignoring invalid config entry {key!r}: {exc}")
|
|
49
|
+
return version("nanoPyCodeAgent")
|
|
50
|
+
except PackageNotFoundError:
|
|
51
|
+
return "unknown"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run_one_tool(block: ToolUseBlock) -> ToolResultBlockParam:
|
|
55
|
+
"""Execute one ``tool_use`` block, echoing the call and its output."""
|
|
56
|
+
if block.name == "read":
|
|
57
|
+
path = block.input["path"]
|
|
58
|
+
print_tool(f"[read] {path}")
|
|
59
|
+
output, is_error = run_read(
|
|
60
|
+
path,
|
|
61
|
+
offset=block.input.get("offset", 1),
|
|
62
|
+
limit=block.input.get("limit"),
|
|
63
|
+
)
|
|
64
|
+
else: # bash — the only other tool offered
|
|
65
|
+
command = block.input["command"]
|
|
66
|
+
print_tool(f"[bash]$ {command}")
|
|
67
|
+
output, is_error = run_bash(command)
|
|
68
|
+
print_tool(output)
|
|
69
|
+
return {
|
|
70
|
+
"type": "tool_result",
|
|
71
|
+
"tool_use_id": block.id,
|
|
72
|
+
"content": output,
|
|
73
|
+
"is_error": is_error,
|
|
74
|
+
}
|
|
99
75
|
|
|
100
76
|
|
|
101
77
|
def run() -> None:
|
|
102
|
-
"""Start the read → ask → answer loop until the user types ``/exit``.
|
|
103
|
-
|
|
104
|
-
|
|
78
|
+
"""Start the read → ask → answer loop until the user types ``/exit``.
|
|
79
|
+
|
|
80
|
+
A reply may include tool calls; they are executed and their results
|
|
81
|
+
fed back to the model until it finishes the turn without tool use.
|
|
82
|
+
"""
|
|
83
|
+
# Fill any unset ANTHROPIC_* keys from the config file (environment
|
|
84
|
+
# variables take precedence), then let the SDK read credentials from
|
|
85
|
+
# os.environ.
|
|
105
86
|
load_settings_env()
|
|
106
87
|
client = anthropic.Anthropic()
|
|
107
88
|
if client.api_key is None and client.auth_token is None:
|
|
@@ -114,36 +95,29 @@ def run() -> None:
|
|
|
114
95
|
)
|
|
115
96
|
return
|
|
116
97
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
messages: list[MessageParam] = []
|
|
123
|
-
if configured_model:
|
|
124
|
-
print(f"nanoPyCodeAgent — using model {model} (from ANTHROPIC_MODEL).")
|
|
125
|
-
else:
|
|
126
|
-
print(
|
|
127
|
-
f"nanoPyCodeAgent — using default model {model} "
|
|
128
|
-
"(set ANTHROPIC_MODEL to override)."
|
|
129
|
-
)
|
|
98
|
+
model = os.environ.get("ANTHROPIC_MODEL", "").strip() or DEFAULT_MODEL
|
|
99
|
+
print(
|
|
100
|
+
f"nanoPyCodeAgent v{_package_version()} — model {model} "
|
|
101
|
+
"(set ANTHROPIC_MODEL to override)."
|
|
102
|
+
)
|
|
130
103
|
print("Type a message to chat, or /exit to quit.")
|
|
131
104
|
|
|
105
|
+
messages: list[MessageParam] = []
|
|
132
106
|
while True:
|
|
133
107
|
try:
|
|
134
108
|
user_input = input("\nYou> ").strip()
|
|
135
109
|
except (EOFError, KeyboardInterrupt):
|
|
136
110
|
print()
|
|
137
111
|
break
|
|
138
|
-
|
|
139
112
|
if not user_input:
|
|
140
113
|
continue
|
|
141
114
|
if user_input == "/exit":
|
|
142
115
|
break
|
|
143
116
|
|
|
144
117
|
messages.append({"role": "user", "content": user_input})
|
|
145
|
-
|
|
146
|
-
|
|
118
|
+
# The model may ask to run tools; keep streaming replies and feeding
|
|
119
|
+
# results back until it finishes a reply without tool calls.
|
|
120
|
+
while True:
|
|
147
121
|
print("\nAgent> ", end="", flush=True)
|
|
148
122
|
# Stream the reply so text shows up as it is generated, then grab
|
|
149
123
|
# the accumulated message for the conversation history.
|
|
@@ -151,31 +125,24 @@ def run() -> None:
|
|
|
151
125
|
model=model,
|
|
152
126
|
max_tokens=MAX_TOKENS,
|
|
153
127
|
system=SYSTEM_PROMPT,
|
|
128
|
+
tools=TOOLS,
|
|
154
129
|
messages=messages,
|
|
155
130
|
) as stream:
|
|
156
131
|
for text in stream.text_stream:
|
|
157
132
|
print(text, end="", flush=True)
|
|
158
133
|
message = stream.get_final_message()
|
|
159
134
|
print()
|
|
160
|
-
except KeyboardInterrupt:
|
|
161
|
-
# Ctrl-C while streaming the reply cancels cleanly, mirroring the
|
|
162
|
-
# graceful quit offered at the input prompt.
|
|
163
|
-
print()
|
|
164
|
-
break
|
|
165
|
-
except anthropic.AuthenticationError:
|
|
166
|
-
print(
|
|
167
|
-
"\nAuthentication failed. Check that ANTHROPIC_API_KEY is set correctly."
|
|
168
|
-
)
|
|
169
|
-
break
|
|
170
|
-
except (anthropic.APIError, httpx.HTTPError) as exc:
|
|
171
|
-
# httpx.HTTPError: the SDK wraps transport errors only around the
|
|
172
|
-
# initial send, so a network failure mid-stream surfaces as a raw
|
|
173
|
-
# httpx error during iteration rather than an anthropic.APIError.
|
|
174
|
-
print(f"\nRequest failed: {exc}")
|
|
175
|
-
messages.pop() # drop the unanswered user turn so history stays valid
|
|
176
|
-
continue
|
|
177
135
|
|
|
178
|
-
|
|
179
|
-
|
|
136
|
+
messages.append({"role": "assistant", "content": message.content})
|
|
137
|
+
if message.stop_reason != "tool_use":
|
|
138
|
+
break
|
|
139
|
+
# Every tool_use block needs a matching tool_result in the next
|
|
140
|
+
# user message, or the API rejects the request.
|
|
141
|
+
results = [
|
|
142
|
+
_run_one_tool(block)
|
|
143
|
+
for block in message.content
|
|
144
|
+
if block.type == "tool_use"
|
|
145
|
+
]
|
|
146
|
+
messages.append({"role": "user", "content": results})
|
|
180
147
|
|
|
181
148
|
print("Bye!")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The ``bash`` tool: its definition and its execution.
|
|
2
|
+
|
|
3
|
+
Each call runs a command with ``bash -c`` in a fresh shell and returns one
|
|
4
|
+
result string: stdout, then labelled stderr, then the exit code when non-zero.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from anthropic.types import ToolParam
|
|
10
|
+
|
|
11
|
+
# Guardrails for the bash tool: a hung command is killed after this many
|
|
12
|
+
# seconds, and results are truncated so one command cannot flood the context.
|
|
13
|
+
BASH_TIMEOUT_SECONDS = 120
|
|
14
|
+
MAX_TOOL_OUTPUT_CHARS = 20_000
|
|
15
|
+
|
|
16
|
+
BASH_TOOL: ToolParam = {
|
|
17
|
+
"name": "bash",
|
|
18
|
+
"description": (
|
|
19
|
+
"Run a command with `bash -c` on the user's machine and return its "
|
|
20
|
+
"output: stdout, then stderr (labelled), then the exit code when "
|
|
21
|
+
"non-zero. Each call is a fresh shell in the agent's working "
|
|
22
|
+
"directory, so environment variables and `cd` do not persist between "
|
|
23
|
+
"calls. Long output is truncated and long-running commands are killed "
|
|
24
|
+
"after a timeout."
|
|
25
|
+
),
|
|
26
|
+
"input_schema": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {
|
|
29
|
+
"command": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "The bash command to run.",
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"required": ["command"],
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_bash(command: str) -> tuple[str, bool]:
|
|
40
|
+
"""Run ``command`` with ``bash -c`` and return ``(output, is_error)``.
|
|
41
|
+
|
|
42
|
+
``is_error`` is true only when the tool itself failed — here, a timeout.
|
|
43
|
+
A command that ran to completion is a successful tool call whatever its
|
|
44
|
+
exit code: the code is reported in the output text, where the model can
|
|
45
|
+
tell a negative answer (``grep`` finding nothing) from a failure.
|
|
46
|
+
Non-UTF-8 output bytes are replaced rather than raising, and stdin is
|
|
47
|
+
``/dev/null`` so a command that prompts sees EOF instead of eating the
|
|
48
|
+
user's keystrokes.
|
|
49
|
+
|
|
50
|
+
Known trades for simplicity: a background child inherits the output
|
|
51
|
+
pipes, so ``some_server &`` blocks until the timeout; the timeout kills
|
|
52
|
+
bash itself, not necessarily everything it forked; and text mode
|
|
53
|
+
translates ``\\r`` in output to ``\\n`` (universal newlines).
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
process = subprocess.run(
|
|
57
|
+
["bash", "-c", command],
|
|
58
|
+
capture_output=True,
|
|
59
|
+
text=True,
|
|
60
|
+
errors="replace",
|
|
61
|
+
stdin=subprocess.DEVNULL,
|
|
62
|
+
timeout=BASH_TIMEOUT_SECONDS,
|
|
63
|
+
)
|
|
64
|
+
except subprocess.TimeoutExpired:
|
|
65
|
+
return f"[command timed out after {BASH_TIMEOUT_SECONDS} seconds]", True
|
|
66
|
+
|
|
67
|
+
parts = []
|
|
68
|
+
if stdout := process.stdout.rstrip("\n"):
|
|
69
|
+
parts.append(stdout)
|
|
70
|
+
if stderr := process.stderr.rstrip("\n"):
|
|
71
|
+
parts.append("[stderr]\n" + stderr)
|
|
72
|
+
if process.returncode != 0:
|
|
73
|
+
parts.append(f"[exit code: {process.returncode}]")
|
|
74
|
+
output = "\n".join(parts) or "(no output)"
|
|
75
|
+
if len(output) > MAX_TOOL_OUTPUT_CHARS:
|
|
76
|
+
output = output[:MAX_TOOL_OUTPUT_CHARS] + "\n[... output truncated ...]"
|
|
77
|
+
return output, False
|
|
@@ -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
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""User-level configuration for nanoPyCodeAgent.
|
|
2
|
+
|
|
3
|
+
The config file at ``~/.nanoPyCodeAgent/settings.json`` may carry an ``env``
|
|
4
|
+
mapping that supplies ``ANTHROPIC_*`` values for keys that are not already set
|
|
5
|
+
in the environment (environment variables win).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
SETTINGS_PATH = Path.home() / ".nanoPyCodeAgent" / "settings.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_settings_env(path: Path | None = None) -> None:
|
|
16
|
+
"""Apply the ``env`` mapping from the config file into ``os.environ``.
|
|
17
|
+
|
|
18
|
+
Only ``ANTHROPIC_*`` keys that are not already present are set, so
|
|
19
|
+
environment variables take precedence over the config file and unrelated
|
|
20
|
+
variables are never injected. Empty, whitespace-only, and non-string
|
|
21
|
+
values are skipped (the documented example ships the keys as empty-string
|
|
22
|
+
placeholders). A missing file is normal; a malformed one raises — fix or
|
|
23
|
+
delete it.
|
|
24
|
+
"""
|
|
25
|
+
if path is None:
|
|
26
|
+
path = SETTINGS_PATH
|
|
27
|
+
try:
|
|
28
|
+
raw = path.read_text(encoding="utf-8")
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
return
|
|
31
|
+
for key, value in json.loads(raw).get("env", {}).items():
|
|
32
|
+
if not key.startswith("ANTHROPIC_"):
|
|
33
|
+
continue
|
|
34
|
+
if isinstance(value, str) and value.strip():
|
|
35
|
+
os.environ.setdefault(key, value.strip())
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Background shading for tool activity in the terminal."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
# Dark gray from the 256-color palette, so echoed commands and their output
|
|
7
|
+
# read apart from the user's prompts and the model's prose.
|
|
8
|
+
_TOOL_BG = "\x1b[48;5;236m"
|
|
9
|
+
_RESET = "\x1b[0m"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _use_color() -> bool:
|
|
13
|
+
"""Whether to emit ANSI colors: a real terminal, and NO_COLOR unset."""
|
|
14
|
+
return sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def print_tool(text: str) -> None:
|
|
18
|
+
"""Print tool activity (an echoed command or its output) shaded.
|
|
19
|
+
|
|
20
|
+
Each line carries its own set-background / erase-to-EOL / reset, so the
|
|
21
|
+
shading spans the full terminal width and never leaks past a line
|
|
22
|
+
boundary. Escape sequences inside ``text`` are printed as-is and may
|
|
23
|
+
disrupt the shading — a cosmetic trade for staying simple.
|
|
24
|
+
"""
|
|
25
|
+
if _use_color():
|
|
26
|
+
text = "\n".join(
|
|
27
|
+
f"{_TOOL_BG}{line}\x1b[K{_RESET}" for line in text.split("\n")
|
|
28
|
+
)
|
|
29
|
+
print(text, flush=True)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanoPyCodeAgent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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,11 @@
|
|
|
1
|
+
nanopycodeagent/__init__.py,sha256=pYFCw7WNkZsGXbqNLkXituX4iBy86RJ4LKKAr78XwM8,70
|
|
2
|
+
nanopycodeagent/agent.py,sha256=AAOnt1q-6FQgcAOODxHv2iM0UwlbqSnPD8kmtPGPo68,5452
|
|
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.0.dist-info/METADATA,sha256=Bep9XESPN7PJ34J9zPvh4YtXdfQ1h2Yeh1Ht5uZdG9E,3028
|
|
8
|
+
nanopycodeagent-0.5.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
nanopycodeagent-0.5.0.dist-info/entry_points.txt,sha256=4gU7YNzQaf2RKlOdfebOWYNYIUk2LDoyhu3G4Vgole8,57
|
|
10
|
+
nanopycodeagent-0.5.0.dist-info/licenses/LICENSE,sha256=g24TppoiLdeF_1_-Y7H2H_eOavKseWA5utuK83Vq1sA,1067
|
|
11
|
+
nanopycodeagent-0.5.0.dist-info/RECORD,,
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
nanopycodeagent/__init__.py,sha256=pYFCw7WNkZsGXbqNLkXituX4iBy86RJ4LKKAr78XwM8,70
|
|
2
|
-
nanopycodeagent/agent.py,sha256=ceIw3HuZD3UHPtyxS3eGbfOE6FrKABcGybFIyOMrcmU,7074
|
|
3
|
-
nanopycodeagent-0.3.0.dist-info/METADATA,sha256=BJqRCUQaABx8q8-MKeMKKkBqUgx-CS6KGvvguADFcPY,3028
|
|
4
|
-
nanopycodeagent-0.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
5
|
-
nanopycodeagent-0.3.0.dist-info/entry_points.txt,sha256=4gU7YNzQaf2RKlOdfebOWYNYIUk2LDoyhu3G4Vgole8,57
|
|
6
|
-
nanopycodeagent-0.3.0.dist-info/licenses/LICENSE,sha256=g24TppoiLdeF_1_-Y7H2H_eOavKseWA5utuK83Vq1sA,1067
|
|
7
|
-
nanopycodeagent-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|