nanoPyCodeAgent 0.2.0__tar.gz → 0.3.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.2.0 → nanopycodeagent-0.3.0}/PKG-INFO +1 -1
- nanopycodeagent-0.3.0/docs/changelogs/0.3.x.md +22 -0
- nanopycodeagent-0.3.0/docs/dev_notes/en/0.3.x.md +49 -0
- nanopycodeagent-0.3.0/docs/dev_notes/zh-CN/0.3.x.md +49 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/src/nanopycodeagent/agent.py +13 -6
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/tests/test_agent.py +99 -7
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/.agents/skills/land-pr/SKILL.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/.agents/skills/release/SKILL.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/.agents/skills/release/scripts/verify-release.sh +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/.github/workflows/release.yml +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/.gitignore +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/.python-version +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/AGENTS.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/CLAUDE.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/LICENSE +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/README.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/README.zh-CN.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/RELEASING.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/changelogs/0.1.x.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/changelogs/0.2.x.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/changelogs/README.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/dev_notes/README.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/dev_notes/en/0.1.x.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/dev_notes/en/0.2.x.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/dev_notes/zh-CN/0.1.x.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/dev_notes/zh-CN/0.2.x.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/superpowers/plans/2026-06-21-release-skills.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/superpowers/specs/2026-06-21-release-skill-design.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/superpowers/specs/2026-06-29-config-file-support-design.md +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/pyproject.toml +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/src/nanopycodeagent/__init__.py +0 -0
- {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nanoPyCodeAgent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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,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,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 消费的是同一条流,只能选一个当主循环,是“看全部事件”还是“只看文本”的视角区别。
|
|
@@ -9,6 +9,7 @@ import os
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
11
|
import anthropic
|
|
12
|
+
import httpx
|
|
12
13
|
from anthropic.types import MessageParam
|
|
13
14
|
|
|
14
15
|
# The model used when ANTHROPIC_MODEL is set in neither the environment nor the
|
|
@@ -144,17 +145,20 @@ def run() -> None:
|
|
|
144
145
|
|
|
145
146
|
try:
|
|
146
147
|
print("\nAgent> ", end="", flush=True)
|
|
147
|
-
|
|
148
|
+
# Stream the reply so text shows up as it is generated, then grab
|
|
149
|
+
# the accumulated message for the conversation history.
|
|
150
|
+
with client.messages.stream(
|
|
148
151
|
model=model,
|
|
149
152
|
max_tokens=MAX_TOKENS,
|
|
150
153
|
system=SYSTEM_PROMPT,
|
|
151
154
|
messages=messages,
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
+
) as stream:
|
|
156
|
+
for text in stream.text_stream:
|
|
157
|
+
print(text, end="", flush=True)
|
|
158
|
+
message = stream.get_final_message()
|
|
155
159
|
print()
|
|
156
160
|
except KeyboardInterrupt:
|
|
157
|
-
# Ctrl-C while
|
|
161
|
+
# Ctrl-C while streaming the reply cancels cleanly, mirroring the
|
|
158
162
|
# graceful quit offered at the input prompt.
|
|
159
163
|
print()
|
|
160
164
|
break
|
|
@@ -163,7 +167,10 @@ def run() -> None:
|
|
|
163
167
|
"\nAuthentication failed. Check that ANTHROPIC_API_KEY is set correctly."
|
|
164
168
|
)
|
|
165
169
|
break
|
|
166
|
-
except anthropic.APIError as exc:
|
|
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.
|
|
167
174
|
print(f"\nRequest failed: {exc}")
|
|
168
175
|
messages.pop() # drop the unanswered user turn so history stays valid
|
|
169
176
|
continue
|
|
@@ -49,24 +49,61 @@ def _write_settings(path, env):
|
|
|
49
49
|
path.write_text(json.dumps({"env": env}), encoding="utf-8")
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
class _FakeStream:
|
|
53
|
+
"""Mimics the SDK's ``MessageStream`` context manager for scripted replies.
|
|
54
|
+
|
|
55
|
+
``mid_stream_error`` simulates a failure while the reply is streaming: the
|
|
56
|
+
text chunks are yielded first, then the exception is raised from within the
|
|
57
|
+
``text_stream`` iteration — after partial output has already been printed.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, content, mid_stream_error=None):
|
|
61
|
+
self._content = content
|
|
62
|
+
self._mid_stream_error = mid_stream_error
|
|
63
|
+
|
|
64
|
+
def __enter__(self):
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def __exit__(self, *exc_info):
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def text_stream(self):
|
|
72
|
+
def _gen():
|
|
73
|
+
# Yield each text block's text as a single chunk.
|
|
74
|
+
for b in self._content:
|
|
75
|
+
if b.type == "text":
|
|
76
|
+
yield b.text
|
|
77
|
+
if self._mid_stream_error is not None:
|
|
78
|
+
raise self._mid_stream_error
|
|
79
|
+
|
|
80
|
+
return _gen()
|
|
81
|
+
|
|
82
|
+
def get_final_message(self):
|
|
83
|
+
return SimpleNamespace(content=self._content)
|
|
84
|
+
|
|
85
|
+
|
|
52
86
|
class _FakeMessages:
|
|
53
|
-
"""Records each ``
|
|
87
|
+
"""Records each ``stream()`` call and replays a scripted response or raises."""
|
|
54
88
|
|
|
55
89
|
def __init__(self, script):
|
|
56
|
-
# Each script entry is either a list of content blocks to
|
|
57
|
-
# message's ``content``,
|
|
58
|
-
# error, or ``KeyboardInterrupt`` to simulate Ctrl-C
|
|
90
|
+
# Each script entry is either a list of content blocks to stream as the
|
|
91
|
+
# message's ``content``, a ``BaseException`` instance to raise at request
|
|
92
|
+
# time (an API error, or ``KeyboardInterrupt`` to simulate Ctrl-C while
|
|
93
|
+
# connecting), or a ``_FakeStream`` — e.g. one that fails mid-stream.
|
|
59
94
|
self._script = list(script)
|
|
60
95
|
self.calls = [] # snapshot of the ``messages`` list at each call
|
|
61
|
-
self.kwargs = [] # full kwargs passed to each
|
|
96
|
+
self.kwargs = [] # full kwargs passed to each stream() call
|
|
62
97
|
|
|
63
|
-
def
|
|
98
|
+
def stream(self, **kwargs):
|
|
64
99
|
self.calls.append(list(kwargs["messages"])) # freeze history at call time
|
|
65
100
|
self.kwargs.append(kwargs)
|
|
66
101
|
item = self._script.pop(0)
|
|
67
102
|
if isinstance(item, BaseException):
|
|
68
103
|
raise item
|
|
69
|
-
|
|
104
|
+
if isinstance(item, _FakeStream):
|
|
105
|
+
return item
|
|
106
|
+
return _FakeStream(item)
|
|
70
107
|
|
|
71
108
|
|
|
72
109
|
class _FakeClient:
|
|
@@ -418,3 +455,58 @@ def test_keyboard_interrupt_during_request_exits_gracefully(monkeypatch, capsys)
|
|
|
418
455
|
out = capsys.readouterr().out
|
|
419
456
|
assert "Bye!" in out
|
|
420
457
|
assert len(messages.calls) == 1 # broke right after the interrupted call
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_keyboard_interrupt_mid_stream_exits_gracefully(monkeypatch, capsys):
|
|
461
|
+
# Ctrl-C after part of the reply has already streamed quits cleanly.
|
|
462
|
+
stream = _FakeStream([_text_block("partial")], mid_stream_error=KeyboardInterrupt())
|
|
463
|
+
messages = _FakeMessages([stream])
|
|
464
|
+
client = _FakeClient(messages)
|
|
465
|
+
# The second input is provided but must never be read (the loop breaks).
|
|
466
|
+
_patch(monkeypatch, client=client, inputs=["hi", "should be ignored"])
|
|
467
|
+
|
|
468
|
+
agent.run()
|
|
469
|
+
|
|
470
|
+
out = capsys.readouterr().out
|
|
471
|
+
assert "partial" in out # the streamed text made it out before the interrupt
|
|
472
|
+
assert "Bye!" in out
|
|
473
|
+
assert len(messages.calls) == 1
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def test_api_error_mid_stream_drops_turn_and_continues(monkeypatch, capsys):
|
|
477
|
+
# An SDK error surfacing mid-stream (e.g. an SSE error event) is handled
|
|
478
|
+
# the same as one raised at request time: drop the turn and keep going.
|
|
479
|
+
err = anthropic.APIConnectionError(request=_request())
|
|
480
|
+
stream = _FakeStream([_text_block("partial")], mid_stream_error=err)
|
|
481
|
+
reply = [_text_block("ok now")]
|
|
482
|
+
messages = _FakeMessages([stream, reply])
|
|
483
|
+
client = _FakeClient(messages)
|
|
484
|
+
_patch(monkeypatch, client=client, inputs=["fails", "hello", "/exit"])
|
|
485
|
+
|
|
486
|
+
agent.run()
|
|
487
|
+
|
|
488
|
+
out = capsys.readouterr().out
|
|
489
|
+
assert "Request failed:" in out
|
|
490
|
+
assert len(messages.calls) == 2
|
|
491
|
+
# The failed turn was popped, so the next call's history is clean.
|
|
492
|
+
assert messages.calls[1] == [{"role": "user", "content": "hello"}]
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def test_network_error_mid_stream_drops_turn_and_continues(monkeypatch, capsys):
|
|
496
|
+
# A network failure mid-stream raises a raw httpx error (the SDK only wraps
|
|
497
|
+
# transport errors around the initial send, not around SSE iteration); the
|
|
498
|
+
# loop must survive it instead of crashing with a traceback.
|
|
499
|
+
err = httpx.ReadError("connection lost mid-stream")
|
|
500
|
+
stream = _FakeStream([_text_block("partial")], mid_stream_error=err)
|
|
501
|
+
reply = [_text_block("ok now")]
|
|
502
|
+
messages = _FakeMessages([stream, reply])
|
|
503
|
+
client = _FakeClient(messages)
|
|
504
|
+
_patch(monkeypatch, client=client, inputs=["fails", "hello", "/exit"])
|
|
505
|
+
|
|
506
|
+
agent.run()
|
|
507
|
+
|
|
508
|
+
out = capsys.readouterr().out
|
|
509
|
+
assert "Request failed:" in out
|
|
510
|
+
assert len(messages.calls) == 2
|
|
511
|
+
# The failed turn was popped, so the next call's history is clean.
|
|
512
|
+
assert messages.calls[1] == [{"role": "user", "content": "hello"}]
|
|
File without changes
|
|
File without changes
|
{nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/.agents/skills/release/scripts/verify-release.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/superpowers/plans/2026-06-21-release-skills.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|