nanoPyCodeAgent 0.2.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.
Files changed (45) hide show
  1. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/.agents/skills/release/SKILL.md +8 -2
  2. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/.agents/skills/release/scripts/verify-release.sh +6 -1
  3. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/PKG-INFO +1 -1
  4. nanopycodeagent-0.4.0/docs/changelogs/0.3.x.md +22 -0
  5. nanopycodeagent-0.4.0/docs/changelogs/0.4.x.md +34 -0
  6. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/docs/dev_notes/README.md +5 -0
  7. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/docs/dev_notes/en/0.2.x.md +1 -1
  8. nanopycodeagent-0.4.0/docs/dev_notes/en/0.3.x.md +49 -0
  9. nanopycodeagent-0.4.0/docs/dev_notes/en/0.4.x.md +68 -0
  10. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/docs/dev_notes/zh-CN/0.2.x.md +1 -1
  11. nanopycodeagent-0.4.0/docs/dev_notes/zh-CN/0.3.x.md +49 -0
  12. nanopycodeagent-0.4.0/docs/dev_notes/zh-CN/0.4.x.md +49 -0
  13. nanopycodeagent-0.4.0/src/nanopycodeagent/agent.py +116 -0
  14. nanopycodeagent-0.4.0/src/nanopycodeagent/bash_tool.py +77 -0
  15. nanopycodeagent-0.4.0/src/nanopycodeagent/settings.py +35 -0
  16. nanopycodeagent-0.4.0/src/nanopycodeagent/terminal.py +29 -0
  17. nanopycodeagent-0.4.0/tests/conftest.py +31 -0
  18. nanopycodeagent-0.4.0/tests/helpers.py +107 -0
  19. nanopycodeagent-0.4.0/tests/test_agent.py +193 -0
  20. nanopycodeagent-0.4.0/tests/test_bash_tool.py +62 -0
  21. nanopycodeagent-0.4.0/tests/test_settings.py +102 -0
  22. nanopycodeagent-0.4.0/tests/test_terminal.py +39 -0
  23. nanopycodeagent-0.2.0/src/nanopycodeagent/agent.py +0 -174
  24. nanopycodeagent-0.2.0/tests/test_agent.py +0 -420
  25. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/.agents/skills/land-pr/SKILL.md +0 -0
  26. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/.github/workflows/release.yml +0 -0
  27. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/.gitignore +0 -0
  28. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/.python-version +0 -0
  29. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/AGENTS.md +0 -0
  30. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/CLAUDE.md +0 -0
  31. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/LICENSE +0 -0
  32. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/README.md +0 -0
  33. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/README.zh-CN.md +0 -0
  34. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/docs/RELEASING.md +0 -0
  35. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/docs/changelogs/0.1.x.md +0 -0
  36. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/docs/changelogs/0.2.x.md +0 -0
  37. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/docs/changelogs/README.md +0 -0
  38. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/docs/dev_notes/en/0.1.x.md +0 -0
  39. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/docs/dev_notes/zh-CN/0.1.x.md +0 -0
  40. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/docs/superpowers/plans/2026-06-21-release-skills.md +0 -0
  41. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/docs/superpowers/specs/2026-06-21-release-skill-design.md +0 -0
  42. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/docs/superpowers/specs/2026-06-29-config-file-support-design.md +0 -0
  43. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/pyproject.toml +0 -0
  44. {nanopycodeagent-0.2.0 → nanopycodeagent-0.4.0}/src/nanopycodeagent/__init__.py +0 -0
  45. {nanopycodeagent-0.2.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
- Touch no other file — dev notes and README were synced per-PR by `land-pr`.
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
@@ -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.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,22 @@
1
+ # Changelog — 0.3.x
2
+
3
+ All notable changes in the **0.3.x** release series are documented here.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.3.0] - 2026-07-10
8
+
9
+ ### Changed
10
+ - Agent replies now stream to the terminal as they are generated, instead of appearing all at once after the full response is ready.
11
+
12
+ <!--
13
+ When cutting a release, copy the relevant items from [Unreleased] into a new
14
+ version section above it, e.g.:
15
+
16
+ ## [0.3.0] - YYYY-MM-DD
17
+
18
+ ### Added
19
+ ### Changed
20
+ ### Fixed
21
+ ### Removed
22
+ -->
@@ -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.06.29
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,49 @@
1
+ # Development Notes — 0.3.x
2
+
3
+ > Generated from the Chinese source [`../zh-CN/0.3.x.md`](../zh-CN/0.3.x.md). Do not edit by hand.
4
+
5
+ ## 0.3.0 - 2026.07.10
6
+
7
+ Without a TUI, testing was confusing whenever the output was long: with no streaming, it was easy to wonder whether the API call had failed. So this version switches to streaming output.
8
+
9
+ I hesitated over whether switching to streaming would introduce unnecessary complexity when tool calling lands in the next version. I had Claude write a demo to find out — it doesn't look like it will: to handle tool calls you have to wait for `stream.get_final_message()` anyway. Streaming output is purely a display-layer concern, designed for user experience; the logic layer still does its processing after `get_final_message()`.
10
+
11
+ I also learned what `stream.text_stream` is — having a code agent around is really convenient, no time wasted hunting through docs.
12
+
13
+
14
+ > Layer 1: what travels over the network is an event stream (SSE)
15
+ >
16
+ > The API's streaming response is not plain text but a sequence of structured events:
17
+ >
18
+ > ```text
19
+ > message_start → content_block_start → content_block_delta × N
20
+ > → content_block_stop → message_delta → message_stop
21
+ > ```
22
+ >
23
+ > Text, thinking, and tool-call arguments are all mixed into content_block_delta events, distinguished by delta.type (text_delta / input_json_delta / thinking_delta).
24
+ >
25
+ > Layer 2: the MessageStream returned by messages.stream() is a wrapper around this event stream
26
+ >
27
+ > It forwards events to you while quietly accumulating state internally — which is why get_final_message() can return the complete message at the end.
28
+ >
29
+ > Layer 3: text_stream is filter + map syntactic sugar over this event stream
30
+ >
31
+ > These two snippets are equivalent:
32
+ >
33
+ > ```python
34
+ > # Convenient form
35
+ > for text in stream.text_stream:
36
+ > print(text, end="", flush=True)
37
+ >
38
+ > # What it actually means, expanded
39
+ > for event in stream:
40
+ > if event.type == "content_block_delta" and event.delta.type == "text_delta":
41
+ > print(event.delta.text, end="", flush=True)
42
+ > ```
43
+ >
44
+ > It yields only the string contents of text_delta events; the other events (block boundaries, tool arguments, thinking deltas) are silently skipped — but not lost: they still flow through the internal accumulator, so after exhausting text_stream, get_final_message() still contains every tool_use block.
45
+ >
46
+ > Two key points:
47
+ >
48
+ > - It is a lazy generator: iterating it is what actually drives the network reads — no iteration, no download.
49
+ > - `for event in stream` and `for text in stream.text_stream` consume the same underlying stream, so only one of them can be the main loop; the difference is one of perspective: "all events" vs "text only".
@@ -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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  > 本文件为**手写中文源文件**(source of truth);英文版 [`../en/0.2.x.md`](../en/0.2.x.md) 由其生成。
4
4
 
5
- ## 0.2.0 - 2026.06.29
5
+ ## 0.2.0 - 2026.07.09
6
6
 
7
7
  这个版本实现了一个最小功能的 chatbot,使用 [Message](https://platform.claude.com/docs/en/api/python/messages/create) 接口,完成与 LLM 的多轮对话。
8
8
 
@@ -0,0 +1,49 @@
1
+ # 开发笔记 — 0.3.x
2
+
3
+ > 本文件为**手写中文源文件**(source of truth);英文版 [`../en/0.3.x.md`](../en/0.3.x.md) 由其生成。
4
+
5
+ ## 0.3.0 - 2026.07.10
6
+
7
+ 由于没做 TUI,测试时候,如果输出内容长,没有流式输出,就会令人困惑,觉得是不是调用失败了,因此这一版本改为流式输出。
8
+
9
+ 犹豫了一下改流式输出会不会在下一版本引入工具调用时,引起不必要的复杂性。让 claude 尝试写了个 demo,看起来不会,处理工具调用,反正也要调用 `stream.get_final_message()` 后才处理。流式输出,只是显示层,为用户体验而设计的。逻辑层还是需要 `get_final_message()` 后处理。
10
+
11
+ 另外,学习了 `stream.text_stream` 是什么,感觉有 code agent 真是方便,不用花时间找文档了。
12
+
13
+
14
+ > 第一层:网络上传输的是事件流(SSE)
15
+ >
16
+ > API 的流式响应不是纯文本,而是一串结构化事件:
17
+ >
18
+ > ```text
19
+ > message_start → content_block_start → content_block_delta × N
20
+ > → content_block_stop → message_delta → message_stop
21
+ > ```
22
+ >
23
+ > 文本、思考过程、工具调用参数,全都混在 content_block_delta 事件里,靠 delta.type(text_delta / input_json_delta / thinking_delta)区分。
24
+ >
25
+ > 第二层:messages.stream() 返回的 MessageStream 是这个事件流的包装器
26
+ >
27
+ > 它一边把事件转给你,一边在内部悄悄累积状态——这就是为什么最后能 get_final_message() 拿到完整消息。
28
+ >
29
+ > 第三层:text_stream 就是在这个事件流上做了 filter + map 的语法糖
30
+ >
31
+ > 这两段代码等价:
32
+ >
33
+ > ```python
34
+ > # 便捷写法
35
+ > for text in stream.text_stream:
36
+ > print(text, end="", flush=True)
37
+ >
38
+ > # 展开后的实际含义
39
+ > for event in stream:
40
+ > if event.type == "content_block_delta" and event.delta.type == "text_delta":
41
+ > print(event.delta.text, end="", flush=True)
42
+ > ```
43
+ >
44
+ > 它只产出 text_delta 的字符串内容,其余事件(块边界、工具参数、思考增量)被静默跳过——但没有丢,它们照样流经内部累积器,所以迭代完 text_stream 后 get_final_message() 里工具调用块一个不少。
45
+ >
46
+ > 两个要点:
47
+ >
48
+ > - 它是惰性生成器:迭代它才真正驱动网络读取,不迭代就不下载。
49
+ > - for event in stream 和 for text in stream.text_stream 消费的是同一条流,只能选一个当主循环,是“看全部事件”还是“只看文本”的视角区别。
@@ -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