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.
Files changed (32) hide show
  1. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/PKG-INFO +1 -1
  2. nanopycodeagent-0.3.0/docs/changelogs/0.3.x.md +22 -0
  3. nanopycodeagent-0.3.0/docs/dev_notes/en/0.3.x.md +49 -0
  4. nanopycodeagent-0.3.0/docs/dev_notes/zh-CN/0.3.x.md +49 -0
  5. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/src/nanopycodeagent/agent.py +13 -6
  6. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/tests/test_agent.py +99 -7
  7. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/.agents/skills/land-pr/SKILL.md +0 -0
  8. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/.agents/skills/release/SKILL.md +0 -0
  9. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/.agents/skills/release/scripts/verify-release.sh +0 -0
  10. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/.github/workflows/release.yml +0 -0
  11. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/.gitignore +0 -0
  12. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/.python-version +0 -0
  13. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/AGENTS.md +0 -0
  14. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/CLAUDE.md +0 -0
  15. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/LICENSE +0 -0
  16. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/README.md +0 -0
  17. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/README.zh-CN.md +0 -0
  18. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/RELEASING.md +0 -0
  19. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/changelogs/0.1.x.md +0 -0
  20. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/changelogs/0.2.x.md +0 -0
  21. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/changelogs/README.md +0 -0
  22. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/dev_notes/README.md +0 -0
  23. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/dev_notes/en/0.1.x.md +0 -0
  24. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/dev_notes/en/0.2.x.md +0 -0
  25. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/dev_notes/zh-CN/0.1.x.md +0 -0
  26. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/dev_notes/zh-CN/0.2.x.md +0 -0
  27. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/superpowers/plans/2026-06-21-release-skills.md +0 -0
  28. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/superpowers/specs/2026-06-21-release-skill-design.md +0 -0
  29. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/docs/superpowers/specs/2026-06-29-config-file-support-design.md +0 -0
  30. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/pyproject.toml +0 -0
  31. {nanopycodeagent-0.2.0 → nanopycodeagent-0.3.0}/src/nanopycodeagent/__init__.py +0 -0
  32. {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.2.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
- message = client.messages.create(
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
- text = "".join(b.text for b in message.content if b.type == "text")
154
- print(text, end="", flush=True)
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 waiting on the reply cancels cleanly, mirroring the
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 ``create()`` call and replays a scripted response or raises."""
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 return as the
57
- # message's ``content``, or a ``BaseException`` instance to raise (an API
58
- # error, or ``KeyboardInterrupt`` to simulate Ctrl-C mid-request).
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 create() call
96
+ self.kwargs = [] # full kwargs passed to each stream() call
62
97
 
63
- def create(self, **kwargs):
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
- return SimpleNamespace(content=item)
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