nanoPyCodeAgent 0.2.0__py3-none-any.whl → 0.4.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
@@ -1,106 +1,59 @@
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. Type ``/exit`` to quit.
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.
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 pathlib import Path
10
15
 
11
16
  import anthropic
12
- from anthropic.types import MessageParam
17
+ from anthropic.types import MessageParam, ToolResultBlockParam, ToolUseBlock
13
18
 
14
- # The model used when ANTHROPIC_MODEL is set in neither the environment nor the
15
- # config file.
19
+ from .bash_tool import BASH_TOOL, run_bash
20
+ from .settings import load_settings_env
21
+ from .terminal import print_tool
22
+
23
+ # The model used when ANTHROPIC_MODEL is set in neither the environment nor
24
+ # the config file.
16
25
  DEFAULT_MODEL = "claude-sonnet-4-6"
17
26
  MAX_TOKENS = 8192
18
- SYSTEM_PROMPT = "You are nanoPyCodeAgent, a concise and helpful coding assistant."
19
-
20
-
21
- # User-level config file. Its ``env`` mapping supplies ANTHROPIC_* values for
22
- # keys that are not already set in the environment (environment variables win).
23
- def _default_settings_path() -> Path | None:
24
- """Resolve the user-level config path, or ``None`` if home is unknown.
25
-
26
- ``Path.home()`` raises ``RuntimeError`` when the home directory cannot be
27
- determined (e.g. ``$HOME`` unset and no passwd entry, common in minimal
28
- containers). Guarding it here keeps ``import nanopycodeagent`` — which runs
29
- eagerly behind the console script — from crashing at import; a ``None`` path
30
- simply means "no user config file".
31
- """
32
- try:
33
- return Path.home() / ".nanoPyCodeAgent" / "settings.json"
34
- except RuntimeError:
35
- return None
27
+ SYSTEM_PROMPT = (
28
+ "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."
31
+ )
32
+
33
+
34
+ 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)
39
+ print_tool(output)
40
+ return {
41
+ "type": "tool_result",
42
+ "tool_use_id": block.id,
43
+ "content": output,
44
+ "is_error": is_error,
45
+ }
36
46
 
37
47
 
38
- SETTINGS_PATH = _default_settings_path()
39
-
40
-
41
- def load_settings_env(path: Path | None = None) -> None:
42
- """Apply the ``env`` mapping from the config file into ``os.environ``.
43
-
44
- ``path`` defaults to the module-level ``SETTINGS_PATH`` (resolved at call
45
- time, so it stays overridable). Only ``ANTHROPIC_*`` keys that are not
46
- already present are set, so environment variables take precedence over the
47
- config file and unrelated variables are never injected. Behaviour by case:
48
+ def run() -> None:
49
+ """Start the read → ask → answer loop until the user types ``/exit``.
48
50
 
49
- - Missing file, or a home directory that cannot be resolved: silently
50
- ignored (running without a config file is normal).
51
- - Unreadable / non-UTF-8 file, malformed JSON, non-object top level, or a
52
- non-object ``env``: a warning is printed and the file is otherwise
53
- ignored — a bad config never blocks startup.
54
- - Empty, whitespace-only, or non-string values, and values the OS rejects
55
- (e.g. an embedded NUL): skipped (the documented example ships these keys
56
- as empty-string placeholders).
51
+ A reply may include bash tool calls; they are executed and their results
52
+ fed back to the model until it finishes the turn without tool use.
57
53
  """
58
- if path is None:
59
- path = SETTINGS_PATH
60
- if path is None:
61
- return # home dir unresolvable → behave as if no config file exists
62
- try:
63
- raw = path.read_text(encoding="utf-8")
64
- except FileNotFoundError:
65
- return
66
- except (OSError, UnicodeDecodeError) as exc:
67
- print(f"Warning: could not read config file {path}: {exc}")
68
- return
69
-
70
- try:
71
- data = json.loads(raw)
72
- except json.JSONDecodeError as exc:
73
- print(f"Warning: ignoring malformed config file {path}: {exc}")
74
- return
75
-
76
- if not isinstance(data, dict):
77
- print(f"Warning: ignoring config file {path}: top level must be an object.")
78
- return
79
-
80
- env = data.get("env", {})
81
- if not isinstance(env, dict):
82
- print(f"Warning: ignoring 'env' in config file {path}: it must be an object.")
83
- return
84
-
85
- for key, value in env.items():
86
- # Only honor ANTHROPIC_* keys (the config's documented purpose) so a
87
- # shared settings.json cannot silently inject unrelated variables such
88
- # as HTTPS_PROXY into the process environment.
89
- if not key.startswith("ANTHROPIC_"):
90
- continue
91
- if not (isinstance(value, str) and value.strip()):
92
- continue
93
- try:
94
- os.environ.setdefault(key, value.strip())
95
- except ValueError as exc:
96
- # e.g. an embedded NUL in the value or '=' in the key name.
97
- print(f"Warning: ignoring invalid config entry {key!r}: {exc}")
98
-
99
-
100
- def run() -> None:
101
- """Start the read → ask → answer loop until the user types ``/exit``."""
102
- # Fill any unset ANTHROPIC_* keys from the config file (environment variables
103
- # take precedence), then let the SDK read credentials from os.environ.
54
+ # Fill any unset ANTHROPIC_* keys from the config file (environment
55
+ # variables take precedence), then let the SDK read credentials from
56
+ # os.environ.
104
57
  load_settings_env()
105
58
  client = anthropic.Anthropic()
106
59
  if client.api_key is None and client.auth_token is None:
@@ -113,62 +66,51 @@ def run() -> None:
113
66
  )
114
67
  return
115
68
 
116
- # Resolve the model after load_settings_env() so a config-file ANTHROPIC_MODEL
117
- # is honored. An empty or whitespace-only value falls back to the default.
118
- configured_model = os.environ.get("ANTHROPIC_MODEL", "").strip()
119
- model = configured_model or DEFAULT_MODEL
120
-
121
- messages: list[MessageParam] = []
122
- if configured_model:
123
- print(f"nanoPyCodeAgent — using model {model} (from ANTHROPIC_MODEL).")
124
- else:
125
- print(
126
- f"nanoPyCodeAgent — using default model {model} "
127
- "(set ANTHROPIC_MODEL to override)."
128
- )
69
+ model = os.environ.get("ANTHROPIC_MODEL", "").strip() or DEFAULT_MODEL
70
+ print(f"nanoPyCodeAgent model {model} (set ANTHROPIC_MODEL to override).")
129
71
  print("Type a message to chat, or /exit to quit.")
130
72
 
73
+ messages: list[MessageParam] = []
131
74
  while True:
132
75
  try:
133
76
  user_input = input("\nYou> ").strip()
134
77
  except (EOFError, KeyboardInterrupt):
135
78
  print()
136
79
  break
137
-
138
80
  if not user_input:
139
81
  continue
140
82
  if user_input == "/exit":
141
83
  break
142
84
 
143
85
  messages.append({"role": "user", "content": user_input})
144
-
145
- try:
86
+ # The model may ask to run tools; keep streaming replies and feeding
87
+ # results back until it finishes a reply without tool calls.
88
+ while True:
146
89
  print("\nAgent> ", end="", flush=True)
147
- message = client.messages.create(
90
+ # Stream the reply so text shows up as it is generated, then grab
91
+ # the accumulated message for the conversation history.
92
+ with client.messages.stream(
148
93
  model=model,
149
94
  max_tokens=MAX_TOKENS,
150
95
  system=SYSTEM_PROMPT,
96
+ tools=[BASH_TOOL],
151
97
  messages=messages,
152
- )
153
- text = "".join(b.text for b in message.content if b.type == "text")
154
- print(text, end="", flush=True)
98
+ ) as stream:
99
+ for text in stream.text_stream:
100
+ print(text, end="", flush=True)
101
+ message = stream.get_final_message()
155
102
  print()
156
- except KeyboardInterrupt:
157
- # Ctrl-C while waiting on the reply cancels cleanly, mirroring the
158
- # graceful quit offered at the input prompt.
159
- print()
160
- break
161
- except anthropic.AuthenticationError:
162
- print(
163
- "\nAuthentication failed. Check that ANTHROPIC_API_KEY is set correctly."
164
- )
165
- break
166
- except anthropic.APIError as exc:
167
- print(f"\nRequest failed: {exc}")
168
- messages.pop() # drop the unanswered user turn so history stays valid
169
- continue
170
103
 
171
- # Append the full content blocks so the next turn carries complete context.
172
- messages.append({"role": "assistant", "content": message.content})
104
+ messages.append({"role": "assistant", "content": message.content})
105
+ if message.stop_reason != "tool_use":
106
+ break
107
+ # Every tool_use block needs a matching tool_result in the next
108
+ # user message, or the API rejects the request.
109
+ results = [
110
+ _run_one_tool(block)
111
+ for block in message.content
112
+ if block.type == "tool_use"
113
+ ]
114
+ messages.append({"role": "user", "content": results})
173
115
 
174
116
  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,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.2.0
3
+ Version: 0.4.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,10 @@
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,,
@@ -1,7 +0,0 @@
1
- nanopycodeagent/__init__.py,sha256=pYFCw7WNkZsGXbqNLkXituX4iBy86RJ4LKKAr78XwM8,70
2
- nanopycodeagent/agent.py,sha256=aAY60px0CuXllg3qICFFOz9_iJKIjf2OWWjmo6PDfzk,6628
3
- nanopycodeagent-0.2.0.dist-info/METADATA,sha256=etl0E9_jsVOXrRHhTD7lcODGNzSY1VjJ5t-LKxP5adE,3028
4
- nanopycodeagent-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
5
- nanopycodeagent-0.2.0.dist-info/entry_points.txt,sha256=4gU7YNzQaf2RKlOdfebOWYNYIUk2LDoyhu3G4Vgole8,57
6
- nanopycodeagent-0.2.0.dist-info/licenses/LICENSE,sha256=g24TppoiLdeF_1_-Y7H2H_eOavKseWA5utuK83Vq1sA,1067
7
- nanopycodeagent-0.2.0.dist-info/RECORD,,