nanoPyCodeAgent 0.3.0__tar.gz → 0.4.0__tar.gz
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-0.3.0 → nanopycodeagent-0.4.0}/.agents/skills/release/SKILL.md +8 -2
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/.agents/skills/release/scripts/verify-release.sh +6 -1
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/PKG-INFO +1 -1
- nanopycodeagent-0.4.0/docs/changelogs/0.4.x.md +34 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/README.md +5 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/en/0.2.x.md +1 -1
- nanopycodeagent-0.4.0/docs/dev_notes/en/0.4.x.md +68 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/zh-CN/0.2.x.md +1 -1
- nanopycodeagent-0.4.0/docs/dev_notes/zh-CN/0.4.x.md +49 -0
- nanopycodeagent-0.4.0/src/nanopycodeagent/agent.py +116 -0
- nanopycodeagent-0.4.0/src/nanopycodeagent/bash_tool.py +77 -0
- nanopycodeagent-0.4.0/src/nanopycodeagent/settings.py +35 -0
- nanopycodeagent-0.4.0/src/nanopycodeagent/terminal.py +29 -0
- nanopycodeagent-0.4.0/tests/conftest.py +31 -0
- nanopycodeagent-0.4.0/tests/helpers.py +107 -0
- nanopycodeagent-0.4.0/tests/test_agent.py +193 -0
- nanopycodeagent-0.4.0/tests/test_bash_tool.py +62 -0
- nanopycodeagent-0.4.0/tests/test_settings.py +102 -0
- nanopycodeagent-0.4.0/tests/test_terminal.py +39 -0
- nanopycodeagent-0.3.0/src/nanopycodeagent/agent.py +0 -181
- nanopycodeagent-0.3.0/tests/test_agent.py +0 -512
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/.agents/skills/land-pr/SKILL.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/.github/workflows/release.yml +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/.gitignore +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/.python-version +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/AGENTS.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/CLAUDE.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/LICENSE +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/README.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/README.zh-CN.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/RELEASING.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/changelogs/0.1.x.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/changelogs/0.2.x.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/changelogs/0.3.x.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/changelogs/README.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/en/0.1.x.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/en/0.3.x.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/zh-CN/0.1.x.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/dev_notes/zh-CN/0.3.x.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/superpowers/plans/2026-06-21-release-skills.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/superpowers/specs/2026-06-21-release-skill-design.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/docs/superpowers/specs/2026-06-29-config-file-support-design.md +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/pyproject.toml +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/src/nanopycodeagent/__init__.py +0 -0
- {nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/uv.lock +0 -0
|
@@ -50,12 +50,18 @@ Read the entries under `## [Unreleased]` and propose the next semver `X.Y.Z`:
|
|
|
50
50
|
**0.x caveat:** on `0.y.z` a minor may still contain breaking changes; treat the
|
|
51
51
|
proposal as a suggestion. The maintainer makes the final call at the gate (step 5).
|
|
52
52
|
|
|
53
|
-
### 4. Cut the changelog
|
|
53
|
+
### 4. Cut the changelog and date the dev notes
|
|
54
54
|
In `<series>`:
|
|
55
55
|
- rename `## [Unreleased]` to `## [X.Y.Z] - <today YYYY-MM-DD>`, keeping its entries;
|
|
56
56
|
- add a fresh empty `## [Unreleased]` section above it.
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
In `docs/dev_notes/zh-CN/<series>.md` and `docs/dev_notes/en/<series>.md`,
|
|
59
|
+
replace the `YYYY.MM.DD` placeholder in the `## X.Y.Z - YYYY.MM.DD` heading
|
|
60
|
+
with today's date (dot-separated). Skip if the dev notes have no section for
|
|
61
|
+
this version.
|
|
62
|
+
|
|
63
|
+
Touch no other file — dev notes content and README were synced per-PR by
|
|
64
|
+
`land-pr`.
|
|
59
65
|
|
|
60
66
|
### 5. ⛔ Confirmation gate (mandatory)
|
|
61
67
|
Present to the maintainer: the proposed **version** `X.Y.Z` and the **changelog
|
{nanopycodeagent-0.3.0 → nanopycodeagent-0.4.0}/.agents/skills/release/scripts/verify-release.sh
RENAMED
|
@@ -60,9 +60,14 @@ grep -q '\.tar\.gz$' <<<"$assets" || fail "Release ${tag} missing an sdist (.tar
|
|
|
60
60
|
printf 'OK: GitHub Release %s has wheel + sdist\n' "$tag"
|
|
61
61
|
|
|
62
62
|
# 3. uvx smoke test (official index, with retries) ------------------------
|
|
63
|
+
# --default-index (not --index): --index only *adds* an index, and uv's
|
|
64
|
+
# first-index strategy would stop at a locally configured mirror that has the
|
|
65
|
+
# package but not the new version yet. --refresh: uv's cached simple-index
|
|
66
|
+
# metadata can be stale right after a publish. </dev/null: the entry point
|
|
67
|
+
# reads stdin; EOF makes it exit immediately instead of hanging interactively.
|
|
63
68
|
log "Smoke-testing uvx ${PACKAGE}@${version}"
|
|
64
69
|
deadline=$(( $(date +%s) + POLL_TIMEOUT ))
|
|
65
|
-
until uvx --index "$PYPI_INDEX" --from "${PACKAGE}@${version}" "$PACKAGE" >/dev/null 2>&1; do
|
|
70
|
+
until uvx --refresh --default-index "$PYPI_INDEX" --from "${PACKAGE}@${version}" "$PACKAGE" >/dev/null 2>&1 </dev/null; do
|
|
66
71
|
(( $(date +%s) >= deadline )) && fail "uvx smoke test failed for ${PACKAGE}@${version} after ${POLL_TIMEOUT}s"
|
|
67
72
|
printf 'uvx not ready (index propagation?); retrying in %ss...\n' "$POLL_INTERVAL"; sleep "$POLL_INTERVAL"
|
|
68
73
|
done
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanoPyCodeAgent
|
|
3
|
-
Version: 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,34 @@
|
|
|
1
|
+
# Changelog — 0.4.x
|
|
2
|
+
|
|
3
|
+
All notable changes in the **0.4.x** release series are documented here.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.4.0] - 2026-07-14
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- A `bash` tool with a tool-use loop: the model can now run shell commands and
|
|
11
|
+
read their output. Each command and its result are echoed to the terminal as
|
|
12
|
+
they happen; long output is truncated and hung commands are killed after a
|
|
13
|
+
timeout.
|
|
14
|
+
- Tool commands and output are shaded with a background color so they read
|
|
15
|
+
apart from the agent's own text (skipped when stdout is not a terminal or
|
|
16
|
+
`NO_COLOR` is set).
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- The agent loop now handles only the happy path: anything unexpected — a
|
|
20
|
+
network error, a Ctrl-C mid-turn, a malformed config file — crashes the
|
|
21
|
+
session, and restarting it is the recovery. The previous hardened variant is
|
|
22
|
+
preserved at the `hardened-agent-loop` tag.
|
|
23
|
+
|
|
24
|
+
<!--
|
|
25
|
+
When cutting a release, copy the relevant items from [Unreleased] into a new
|
|
26
|
+
version section above it, e.g.:
|
|
27
|
+
|
|
28
|
+
## [0.4.0] - YYYY-MM-DD
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
### Changed
|
|
32
|
+
### Fixed
|
|
33
|
+
### Removed
|
|
34
|
+
-->
|
|
@@ -11,3 +11,8 @@ Notes are bilingual, split by language:
|
|
|
11
11
|
These are free-form notes — insights, takeaways, and design decisions gathered
|
|
12
12
|
while developing each series, with no fixed structure. User-facing changes live
|
|
13
13
|
in [`../changelogs/`](../changelogs/).
|
|
14
|
+
|
|
15
|
+
The date in a `## X.Y.Z - YYYY.MM.DD` section heading is the **release date**
|
|
16
|
+
of that version (the same date as the git tag and the changelog entry). While
|
|
17
|
+
the version is unreleased, keep the literal `YYYY.MM.DD` placeholder — it is
|
|
18
|
+
filled in by the release process.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Generated from the Chinese source [`../zh-CN/0.2.x.md`](../zh-CN/0.2.x.md). Do not edit by hand.
|
|
4
4
|
|
|
5
|
-
## 0.2.0 - 2026.
|
|
5
|
+
## 0.2.0 - 2026.07.09
|
|
6
6
|
|
|
7
7
|
This release implements a minimally functional chatbot that holds a multi-turn conversation with the LLM through the [Messages](https://platform.claude.com/docs/en/api/python/messages/create) API.
|
|
8
8
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Development Notes — 0.4.x
|
|
2
|
+
|
|
3
|
+
> Generated from the Chinese source [`../zh-CN/0.4.x.md`](../zh-CN/0.4.x.md). Do not edit by hand.
|
|
4
|
+
|
|
5
|
+
## 0.4.0 - 2026.07.14
|
|
6
|
+
|
|
7
|
+
### The bash tool
|
|
8
|
+
|
|
9
|
+
This version adds tool use, starting with a single bash tool. The bash tool can do almost anything — powerful yet simple — and it makes a good base to gradually split other tools out of, which will make it easy to compare having more tools against having just the one bash tool.
|
|
10
|
+
|
|
11
|
+
Also, with no test environment for the other systems, Linux is the only platform that can be fully tested. Mac's bash is probably fairly similar to Linux's?
|
|
12
|
+
|
|
13
|
+
### Background color for tool output
|
|
14
|
+
|
|
15
|
+
I tried giving tool output a background color so it stands apart from the agent's text output, similar to the pi code agent's implementation.
|
|
16
|
+
|
|
17
|
+
Along the way I learned how changing the terminal background color actually works:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
1. Terminal colors are "in-band control": styling is mixed into the data stream
|
|
21
|
+
|
|
22
|
+
Between a program and the terminal there is only one byte stream — no separate
|
|
23
|
+
styling channel. To change how things look, you insert control sequences
|
|
24
|
+
starting with ESC (\x1b) into the stream. The \x1b[<params>m family is called
|
|
25
|
+
SGR (Select Graphic Rendition); it changes the terminal's current "brush"
|
|
26
|
+
state — every character printed afterwards is written into its screen cell
|
|
27
|
+
with the current brush attributes, until the brush changes again. The byte
|
|
28
|
+
stream for each output line is really:
|
|
29
|
+
|
|
30
|
+
\x1b[48;5;236m [bash]$ ls ... \x1b[K \x1b[0m \n
|
|
31
|
+
└─ set bg color ─┘ └── text ──┘ └erase to EOL┘ └reset┘
|
|
32
|
+
|
|
33
|
+
2. 48;5;236: dark gray from the 256-color palette
|
|
34
|
+
|
|
35
|
+
48;5;N means "background color = entry N of the 256-color palette". The
|
|
36
|
+
palette has three segments: 0–15 basic colors, 16–231 the color cube, 232–255
|
|
37
|
+
the grayscale ramp. Entry 236 is a dark gray (about #303030). Picking the
|
|
38
|
+
grayscale ramp over the basic background colors 41–47 is deliberate: basic
|
|
39
|
+
colors get remapped by the user's terminal theme and can be harsh on the eyes,
|
|
40
|
+
while grays stay "just a bit lighter than the base color" under any theme —
|
|
41
|
+
exactly right for visual layering.
|
|
42
|
+
|
|
43
|
+
3. \x1b[K makes the color block fill the whole line
|
|
44
|
+
|
|
45
|
+
Background color only applies to cells where characters were actually
|
|
46
|
+
printed; the blank space at the end of a line keeps the default background,
|
|
47
|
+
so the block hugs the text with a ragged right edge. \x1b[K (Erase in Line)
|
|
48
|
+
erases from the cursor to the end of the line, and mainstream terminals
|
|
49
|
+
implement BCE (Background Color Erase): erased cells are filled with the
|
|
50
|
+
current background color. So emitting \x1b[K before resetting the brush
|
|
51
|
+
amounts to "painting the rest of this line dark gray" — which is why the
|
|
52
|
+
blocks in the screenshot are clean rectangles.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### From production hardening back to the core
|
|
56
|
+
|
|
57
|
+
The implementation had come to feel somewhat complex: the edge-case handling was crowding out the core. Those edge cases matter in a production-grade application, but for now, as long as normal use is unaffected, they hardly seem necessary.
|
|
58
|
+
|
|
59
|
+
With that in mind, I stripped out the production hardening wholesale and kept only the core flow. The design principle moves from "repair history and survive on error" to "crash on the unexpected, restart to recover":
|
|
60
|
+
|
|
61
|
+
- **Agent loop**: the per-moment Ctrl-C history repair, the API-error rollback (`ran_tools` / `turn_start`), patching `tool_result`s for truncated replies, and the per-turn request budget (`MAX_REQUESTS_PER_TURN`) are all deleted; the loop handles only the happy path.
|
|
62
|
+
- **bash tool**: switched to `subprocess.run`; temp-file capture, process-group kill, capped per-stream reads, and NUL protection are all removed. Known trades: a background child (`some_server &`) blocks until the timeout; the timeout kills bash itself, with no guarantee of killing the whole process tree; and text mode translates `\r` in output to `\n`.
|
|
63
|
+
- **terminal**: only this version's new tool-output background shading remains; control-character stripping and the stdout encoding fallback are removed.
|
|
64
|
+
- **settings**: happy path only — a missing file is normal, a corrupt file simply raises.
|
|
65
|
+
|
|
66
|
+
What stays is what genuinely belongs to agent design itself: the tool_use / tool_result pairing protocol, output truncation (protecting the context window), the command timeout, and `is_error` meaning only tool failure (the exit code goes into the result text, so the model can tell a negative answer from a failed execution).
|
|
67
|
+
|
|
68
|
+
The source drops from 544 lines to 262, with agent.py going from 250 to 116 — the main loop now fits on one screen. How each edge case was discovered is recorded in the git history; the hardened version before the trim is preserved under the `hardened-agent-loop` tag.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# 开发笔记 — 0.4.x
|
|
2
|
+
|
|
3
|
+
> 本文件为**手写中文源文件**(source of truth);英文版 [`../en/0.4.x.md`](../en/0.4.x.md) 由其生成。
|
|
4
|
+
|
|
5
|
+
## 0.4.0 - 2026.07.14
|
|
6
|
+
|
|
7
|
+
### bash 工具
|
|
8
|
+
|
|
9
|
+
此版本增加 tool use 功能,首先增加一个 bash 工具,bash 工具几乎可以做任何事,功能强大又简单,可以以此为基础,再逐步拆分出其他工具,方便对比增加其他工具后与单一 bash 工具的区别。
|
|
10
|
+
|
|
11
|
+
另外,由于其他系统无测试环境,能完全测试的只有 Linux。Mac 与 Linux 的 bash 可能比较类似?
|
|
12
|
+
|
|
13
|
+
### 工具输出背景色
|
|
14
|
+
|
|
15
|
+
尝试给工具输出增加背景颜色,这样方便与 agent 的文本输出有区分,类似 pi code agent 的实现。
|
|
16
|
+
|
|
17
|
+
另外,学习了终端改背景色的原理:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
1. 终端颜色是"带内控制":样式混在数据流里
|
|
21
|
+
|
|
22
|
+
程序和终端之间只有一条字节流,没有独立的样式通道。想改变显示效果,就往流里插入以 ESC(\x1b)开头的控制序列。\x1b[<参数>m 这一族叫 SGR(Select Graphic Rendition),它修改终端当前的"画笔"状态——之后打印的每个字符都带着当前画笔属性写进屏幕单元格,直到画笔再次被改。每行输出的字节流实际是:
|
|
23
|
+
|
|
24
|
+
\x1b[48;5;236m [bash]$ ls ... \x1b[K \x1b[0m \n
|
|
25
|
+
└─ 设背景色 ─┘ └── 文字 ──┘ └擦到行尾┘ └重置┘
|
|
26
|
+
|
|
27
|
+
2. 48;5;236:256 色调色板里的深灰
|
|
28
|
+
|
|
29
|
+
48;5;N 表示"背景色 = 256 色调色板第 N 号"。调色板分三段:0–15 基本色、16–231 色立方、232–255 灰阶梯。236 号是深灰(约 #303030)。选灰阶而不选 41–47 基本背景色,是因为基本色会被用户终端主题重映射、容易刺眼;灰阶在各种主题下都只是"比底色浅一点",做层次区分正合适。
|
|
30
|
+
|
|
31
|
+
3. \x1b[K 让色块铺满整行
|
|
32
|
+
|
|
33
|
+
背景色只作用于实际打印了字符的单元格,行尾空白仍是默认背景,色块会紧贴文字、右缘参差。\x1b[K(Erase in Line)从光标擦到行尾,而主流终端实现了 BCE(Background Color Erase):擦除时用当前背景色填充。所以在重置画笔前发一个 \x1b[K,等于"用深灰刷满本行剩余部分"——这就是截图里色块是整齐矩形的原因。
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 从生产级加固回到核心
|
|
37
|
+
|
|
38
|
+
我觉得目前的实现,似乎有些复杂,边界条件的处理反而让核心内容不够明显。这些边界条件在生产级应用时有用,但目前如果不影响正常使用,似乎并无必要。
|
|
39
|
+
|
|
40
|
+
基于这个想法,把生产级加固整体剥离,只保留核心流程。设计原则从「出错时修复历史并存活」改为「意外即崩溃,重启即恢复」:
|
|
41
|
+
|
|
42
|
+
- **agent 循环**:Ctrl-C 分时刻的历史修复、API 错误回滚(`ran_tools` / `turn_start`)、截断回复补 `tool_result`、每轮请求预算(`MAX_REQUESTS_PER_TURN`)全部删除,循环只处理快乐路径。
|
|
43
|
+
- **bash 工具**:改用 `subprocess.run`,临时文件捕获、进程组击杀、分流限量读、NUL 防护全部移除。已知取舍:后台子进程(`some_server &`)会阻塞到超时;超时只杀 bash 本体,不保证杀掉整棵进程树;文本模式会把输出中的 `\r` 转成 `\n`。
|
|
44
|
+
- **terminal**:只保留本版新增的工具输出背景色,控制字符剥离和 stdout 编码兜底移除。
|
|
45
|
+
- **settings**:只保留快乐路径 —— 文件缺失属正常,文件损坏直接抛异常。
|
|
46
|
+
|
|
47
|
+
保留下来的是真正属于 agent 设计本身的部分:tool_use / tool_result 的配对协议、输出截断(保护上下文窗口)、命令超时、`is_error` 只表示工具失败(退出码写进正文,让模型区分「否定答案」和「执行失败」)。
|
|
48
|
+
|
|
49
|
+
源码从 544 行降到 262 行,其中 agent.py 从 250 行降到 116 行,主循环可以一屏读完。这些边界条件的发现过程记录在 git 历史里;精简前的加固版打了 tag `hardened-agent-loop` 留档。
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""A minimal agent loop built on the Anthropic Python SDK.
|
|
2
|
+
|
|
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.
|
|
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.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
import anthropic
|
|
17
|
+
from anthropic.types import MessageParam, ToolResultBlockParam, ToolUseBlock
|
|
18
|
+
|
|
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.
|
|
25
|
+
DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
26
|
+
MAX_TOKENS = 8192
|
|
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
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run() -> None:
|
|
49
|
+
"""Start the read → ask → answer loop until the user types ``/exit``.
|
|
50
|
+
|
|
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.
|
|
53
|
+
"""
|
|
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.
|
|
57
|
+
load_settings_env()
|
|
58
|
+
client = anthropic.Anthropic()
|
|
59
|
+
if client.api_key is None and client.auth_token is None:
|
|
60
|
+
print(
|
|
61
|
+
"No API credentials found. Set the ANTHROPIC_API_KEY environment variable."
|
|
62
|
+
)
|
|
63
|
+
print(
|
|
64
|
+
"If you use a third-party / proxy service, also set ANTHROPIC_BASE_URL "
|
|
65
|
+
"to point at its endpoint."
|
|
66
|
+
)
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
model = os.environ.get("ANTHROPIC_MODEL", "").strip() or DEFAULT_MODEL
|
|
70
|
+
print(f"nanoPyCodeAgent — model {model} (set ANTHROPIC_MODEL to override).")
|
|
71
|
+
print("Type a message to chat, or /exit to quit.")
|
|
72
|
+
|
|
73
|
+
messages: list[MessageParam] = []
|
|
74
|
+
while True:
|
|
75
|
+
try:
|
|
76
|
+
user_input = input("\nYou> ").strip()
|
|
77
|
+
except (EOFError, KeyboardInterrupt):
|
|
78
|
+
print()
|
|
79
|
+
break
|
|
80
|
+
if not user_input:
|
|
81
|
+
continue
|
|
82
|
+
if user_input == "/exit":
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
messages.append({"role": "user", "content": user_input})
|
|
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:
|
|
89
|
+
print("\nAgent> ", end="", flush=True)
|
|
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(
|
|
93
|
+
model=model,
|
|
94
|
+
max_tokens=MAX_TOKENS,
|
|
95
|
+
system=SYSTEM_PROMPT,
|
|
96
|
+
tools=[BASH_TOOL],
|
|
97
|
+
messages=messages,
|
|
98
|
+
) as stream:
|
|
99
|
+
for text in stream.text_stream:
|
|
100
|
+
print(text, end="", flush=True)
|
|
101
|
+
message = stream.get_final_message()
|
|
102
|
+
print()
|
|
103
|
+
|
|
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})
|
|
115
|
+
|
|
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)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Shared fixtures for the test suite."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from nanopycodeagent import settings
|
|
8
|
+
|
|
9
|
+
_MANAGED_ENV = ("ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture(autouse=True)
|
|
13
|
+
def _isolate_config(monkeypatch, tmp_path):
|
|
14
|
+
"""Keep every test off the real config file and ambient ANTHROPIC_* vars.
|
|
15
|
+
|
|
16
|
+
``settings.SETTINGS_PATH`` is redirected into an empty temp dir (so tests
|
|
17
|
+
never read the developer's ~/.nanoPyCodeAgent/settings.json), and the
|
|
18
|
+
managed env vars are cleared up front and restored afterwards —
|
|
19
|
+
``load_settings_env`` writes to ``os.environ`` directly, which monkeypatch
|
|
20
|
+
would not roll back on its own.
|
|
21
|
+
"""
|
|
22
|
+
monkeypatch.setattr(settings, "SETTINGS_PATH", tmp_path / "settings.json")
|
|
23
|
+
saved = {key: os.environ.pop(key, None) for key in _MANAGED_ENV}
|
|
24
|
+
try:
|
|
25
|
+
yield
|
|
26
|
+
finally:
|
|
27
|
+
for key, value in saved.items():
|
|
28
|
+
if value is None:
|
|
29
|
+
os.environ.pop(key, None)
|
|
30
|
+
else:
|
|
31
|
+
os.environ[key] = value
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Shared fakes and helpers for driving ``run()`` without the network.
|
|
2
|
+
|
|
3
|
+
Every Anthropic API access is mocked, so tests never hit the network and never
|
|
4
|
+
spend tokens: ``FakeMessages`` replays scripted replies and records each call,
|
|
5
|
+
and ``patch_client_and_input`` wires a fake client and a scripted ``input()``
|
|
6
|
+
into the loop.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
|
|
12
|
+
import anthropic
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def text_block(text):
|
|
16
|
+
"""A minimal stand-in for an SDK text content block."""
|
|
17
|
+
return SimpleNamespace(type="text", text=text)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def tool_use_block(block_id, command):
|
|
21
|
+
"""A minimal stand-in for an SDK tool_use content block."""
|
|
22
|
+
return SimpleNamespace(
|
|
23
|
+
type="tool_use", id=block_id, name="bash", input={"command": command}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write_settings(path, env):
|
|
28
|
+
"""Write a ``settings.json`` with the given ``env`` mapping."""
|
|
29
|
+
path.write_text(json.dumps({"env": env}), encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FakeStream:
|
|
33
|
+
"""Mimics the SDK's ``MessageStream`` context manager for scripted replies.
|
|
34
|
+
|
|
35
|
+
``stop_reason`` is ``"tool_use"`` when the scripted reply asks to run
|
|
36
|
+
tools.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, content, stop_reason="end_turn"):
|
|
40
|
+
self._content = content
|
|
41
|
+
self._stop_reason = stop_reason
|
|
42
|
+
|
|
43
|
+
def __enter__(self):
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def __exit__(self, *exc_info):
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def text_stream(self):
|
|
51
|
+
def _gen():
|
|
52
|
+
# Yield each text block's text as a single chunk.
|
|
53
|
+
for b in self._content:
|
|
54
|
+
if b.type == "text":
|
|
55
|
+
yield b.text
|
|
56
|
+
|
|
57
|
+
return _gen()
|
|
58
|
+
|
|
59
|
+
def get_final_message(self):
|
|
60
|
+
return SimpleNamespace(content=self._content, stop_reason=self._stop_reason)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FakeMessages:
|
|
64
|
+
"""Records each ``stream()`` call and replays a scripted response."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, script):
|
|
67
|
+
# Each script entry is either a list of content blocks to stream as
|
|
68
|
+
# the message's ``content``, or a ``FakeStream`` — e.g. a tool_use
|
|
69
|
+
# turn.
|
|
70
|
+
self._script = list(script)
|
|
71
|
+
self.calls = [] # snapshot of the ``messages`` list at each call
|
|
72
|
+
self.kwargs = [] # full kwargs passed to each stream() call
|
|
73
|
+
|
|
74
|
+
def stream(self, **kwargs):
|
|
75
|
+
self.calls.append(list(kwargs["messages"])) # freeze history at call time
|
|
76
|
+
self.kwargs.append(kwargs)
|
|
77
|
+
item = self._script.pop(0)
|
|
78
|
+
if isinstance(item, FakeStream):
|
|
79
|
+
return item
|
|
80
|
+
return FakeStream(item)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FakeClient:
|
|
84
|
+
def __init__(self, messages, *, api_key="sk-test", auth_token=None):
|
|
85
|
+
self.messages = messages
|
|
86
|
+
self.api_key = api_key
|
|
87
|
+
self.auth_token = auth_token
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def patch_client_and_input(monkeypatch, *, client, inputs):
|
|
91
|
+
"""Wire up a fake Anthropic client and scripted input().
|
|
92
|
+
|
|
93
|
+
The config file is isolated by the autouse ``_isolate_config`` fixture in
|
|
94
|
+
conftest.py, so ``run()`` sees no config file unless a test writes one to
|
|
95
|
+
``settings.SETTINGS_PATH``.
|
|
96
|
+
"""
|
|
97
|
+
monkeypatch.setattr(anthropic, "Anthropic", lambda *a, **k: client)
|
|
98
|
+
|
|
99
|
+
answers = iter(inputs)
|
|
100
|
+
|
|
101
|
+
def fake_input(prompt=""):
|
|
102
|
+
try:
|
|
103
|
+
return next(answers)
|
|
104
|
+
except StopIteration as exc: # safety net: behaves like Ctrl-D
|
|
105
|
+
raise EOFError from exc
|
|
106
|
+
|
|
107
|
+
monkeypatch.setattr("builtins.input", fake_input)
|