pi-agent-python-sdk 0.1.0__tar.gz → 0.2.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 (20) hide show
  1. pi_agent_python_sdk-0.2.0/CHANGELOG.md +66 -0
  2. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/PKG-INFO +46 -28
  3. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/README.md +43 -25
  4. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/pyproject.toml +3 -3
  5. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/src/pi_agent/__init__.py +3 -0
  6. pi_agent_python_sdk-0.2.0/src/pi_agent/_collections.py +59 -0
  7. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/src/pi_agent/_events.py +58 -17
  8. pi_agent_python_sdk-0.2.0/src/pi_agent/_observation.py +73 -0
  9. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/src/pi_agent/_runs.py +49 -24
  10. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/src/pi_agent/_transport.py +107 -25
  11. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/src/pi_agent/client.py +273 -46
  12. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/src/pi_agent/errors.py +1 -1
  13. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/src/pi_agent/sync.py +222 -37
  14. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/src/pi_agent/types.py +21 -16
  15. pi_agent_python_sdk-0.1.0/CHANGELOG.md +0 -28
  16. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/.gitignore +0 -0
  17. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/LICENSE +0 -0
  18. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/src/pi_agent/_launch.py +0 -0
  19. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/src/pi_agent/_usage.py +0 -0
  20. {pi_agent_python_sdk-0.1.0 → pi_agent_python_sdk-0.2.0}/src/pi_agent/py.typed +0 -0
@@ -0,0 +1,66 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ## 0.2.0
6
+
7
+ ### Breaking changes
8
+
9
+ - `prompt()` returns `None` after checked acknowledgement. Use `request("prompt", ...)`
10
+ for the response envelope and ID; `AcceptanceReceipt` is removed.
11
+ - `cycle_thinking_level()` and `export_html()` return their complete result
12
+ dictionaries, preserving unknown fields. Read `result["level"]` or `result["path"]`.
13
+ - Session mutations invalidate cached identity without an implicit state query.
14
+ Call `get_state()` explicitly when updated identity is needed.
15
+
16
+ ### Added
17
+
18
+ - TypeScript-style listeners and settlement helpers: `on_event()`,
19
+ `collect_events()`, `wait_for_idle()`, and `prompt_and_wait()` through both facades.
20
+ - Opt-in observation of original stderr/stdout bytes and all parsed RPC objects,
21
+ with bounded queues and explicit completion/loss status.
22
+ - Subscriptions before startup, including extension events and startup failures.
23
+ - Maintained TypeScript command/event comparison for source checkouts and installed
24
+ wheels across supported Python and operating-system versions.
25
+
26
+ ### Fixed
27
+
28
+ - Stream events before prompt acknowledgement while still requiring successful
29
+ acknowledgement and settlement before completing a result.
30
+ - Preserve UI callback causes; dialog expiry cancels only the dialog, and late
31
+ handler answers cannot override it.
32
+ - Deliver buffered event prefixes before terminal failures; preserve overflow
33
+ errors during concurrent blocking-context cleanup.
34
+ - Yield during finite output bursts and batch blocking reads; result-only runs
35
+ no longer buffer discarded progress. Slow consumers still fail explicitly.
36
+ - Preserve JSON-escaped lone surrogates on outbound commands and UI replies.
37
+
38
+ - Share deadline validation across runs, commands, collectors, and limits; invalid
39
+ types and integers too large for timers consistently raise `ValueError`.
40
+ - Document the retained run/stream conveniences and the reasons for their
41
+ internal ownership, session, and cleanup rules.
42
+
43
+ ## 0.1.0
44
+
45
+ - Introduce the `pi-agent-python-sdk` distribution with the `pi_agent` Python
46
+ import. Users of earlier source checkouts must update their imports.
47
+ - Keep the standalone SDK scoped to core RPC with zero runtime dependencies;
48
+ document loading caller-owned extensions through native Pi arguments.
49
+ - Prevent owned runs from claiming delayed events after low-level submissions.
50
+ - Bound retained run messages independently of event queues.
51
+ - Preserve unknown usage measurements, concatenate final text blocks directly,
52
+ and measure run latency from submission to settlement.
53
+ - Reject malformed known text deltas while preserving unknown event variants.
54
+ - Add `AsyncPiClient` and `PiClient` with explicit methods for all 33 Pi 0.85.1 RPC commands.
55
+ - Add typed wire payloads, future-only event subscriptions, extension dialogs,
56
+ streamed text, and settled results with session identity and observed usage.
57
+ - Preserve Pi configuration while owning process readiness, bounded I/O,
58
+ checked failures, cancellation cleanup, and offline compatibility checks.
59
+ - Add deterministic subprocess tests and isolated real-Pi integration using a
60
+ local faux provider, including retries, compaction, tools, and saved sessions.
61
+ - Add nine runnable examples and guides for the API, errors, compatibility,
62
+ dependency updates, and releases.
63
+ - Add Python/platform CI, Dependabot, daily latest-Pi checks, reviewed source
64
+ fingerprints, distribution inspection, and Trusted Publishing workflows.
65
+ - Verify published wheel and source hashes, and smoke-test index installations
66
+ before promoting the same artifacts from TestPyPI to PyPI.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pi-agent-python-sdk
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Typed async and synchronous Python clients for Pi's agent RPC runtime
5
5
  Project-URL: Homepage, https://github.com/cheenulabs/pi-agent-python-sdk
6
- Project-URL: Documentation, https://github.com/cheenulabs/pi-agent-python-sdk/tree/v0.1.0/docs
6
+ Project-URL: Documentation, https://github.com/cheenulabs/pi-agent-python-sdk/tree/v0.2.0/docs
7
7
  Project-URL: Issues, https://github.com/cheenulabs/pi-agent-python-sdk/issues
8
- Project-URL: Changelog, https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/CHANGELOG.md
8
+ Project-URL: Changelog, https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/CHANGELOG.md
9
9
  Author: cheenulabs
10
10
  License-Expression: MIT
11
11
  License-File: LICENSE
@@ -23,6 +23,7 @@ Description-Content-Type: text/markdown
23
23
 
24
24
  # Pi Agent Python SDK
25
25
 
26
+ [![PyPI](https://img.shields.io/pypi/v/pi-agent-python-sdk?style=flat-square)][pypi]
26
27
  [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-3776AB?style=flat-square)][metadata]
27
28
  [![MIT license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)][license]
28
29
 
@@ -52,10 +53,10 @@ pi --version
52
53
  Run `pi` once to configure your provider and model using Pi's normal setup.
53
54
  The SDK uses that configuration when it starts Pi.
54
55
 
55
- Install the Python package:
56
+ Install the Python package from [PyPI][pypi]:
56
57
 
57
58
  ```sh
58
- python -m pip install pi-agent-python-sdk==0.1.0
59
+ python -m pip install pi-agent-python-sdk==0.2.0
59
60
  ```
60
61
 
61
62
  The distribution is named `pi-agent-python-sdk`; import it as
@@ -73,6 +74,19 @@ The context manager starts and closes Pi. `run()` waits for the conversation to
73
74
  settle, including retries and queued follow-ups. The result includes finalized
74
75
  messages, session identity, elapsed time, and observed assistant usage.
75
76
 
77
+ Continue the conversation with another call on the same client:
78
+
79
+ ```python
80
+ from pi_agent import PiClient
81
+
82
+ with PiClient() as pi:
83
+ print(pi.run("Explain this project's entry points without editing files.").text)
84
+ print(pi.run("Which of those entry points handles configuration?").text)
85
+ ```
86
+
87
+ Pi keeps the conversation context. Each result contains only the messages and
88
+ answer from that call; a call with no assistant output has empty text.
89
+
76
90
  ## What you can do
77
91
 
78
92
  - **Run and stream:** get a final answer or consume text, thinking, and tool events.
@@ -97,8 +111,7 @@ with PiClient() as pi:
97
111
  ```
98
112
 
99
113
  `event.raw` contains the full Pi event, including fields the SDK does not yet
100
- recognize. Keep the stream inside its context: leaving early clears queued input
101
- and aborts the work it owns. See [errors and cancellation][errors] for
114
+ recognize. Keep the stream inside its context: leaving early cleans up unfinished work. See [errors and cancellation][errors] for
102
115
  handling timeouts and partial results.
103
116
 
104
117
  ## Async usage
@@ -164,6 +177,8 @@ Choose the interface that fits the work:
164
177
  | A completed conversation result | `run()` |
165
178
  | Events while a conversation runs | `stream()` |
166
179
  | Prompt acknowledgement and your own event handling | `prompt()` with `events()` |
180
+ | Session events through the next settlement | `prompt_and_wait()` or `collect_events()` |
181
+ | Every event without retaining history | `on_event(callback)` |
167
182
  | A specific Pi operation | `get_state()`, `set_model()`, `fork()`, and other command methods |
168
183
  | A raw command response envelope | `request()` |
169
184
 
@@ -181,10 +196,10 @@ Python arguments use `snake_case`; wire dictionaries retain Pi's `camelCase`
181
196
  fields. `prompt()` acknowledges submission, which may be handled entirely by an
182
197
  extension. It does not wait for a completed answer.
183
198
 
184
- One client owns one conversation at a time; create separate clients for
185
- independent conversations. After a low-level prompt submitted outside an owned
186
- run, use a fresh client for `run()` or `stream()` so events cannot be attributed
187
- to the wrong work. The SDK launches a new process and cannot attach to an existing
199
+ Use one client per conversation and wait for each `run()` or stream to finish
200
+ before starting the next. After using `prompt()` for your own event handling,
201
+ use a fresh client for `run()` or `stream()` so results cannot include delayed
202
+ events from earlier work. The SDK launches a new process and cannot attach to an existing
188
203
  Pi terminal session.
189
204
 
190
205
  See [RPC structure][rpc] for the protocol mapping and module layout, and
@@ -200,6 +215,7 @@ the [command reference][commands] for every method.
200
215
  | [RPC structure][rpc] | How the Python client maps to Pi's protocol |
201
216
  | [Compatibility][compatibility] | Runtime versions and platform validation |
202
217
  | [Examples][examples] | Runnable sync, async, streaming, sessions, steering, and UI examples |
218
+ | [Release notes][release] | Changes included in 0.2.0 |
203
219
 
204
220
  Examples use your configured Pi and may make provider calls. Development checks
205
221
  use an isolated local test provider; see [CONTRIBUTING.md][contributing] for
@@ -212,20 +228,22 @@ Maintainers: [protocol discovery][discovery] ·
212
228
 
213
229
  [MIT][license]
214
230
 
215
- [metadata]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/pyproject.toml
216
- [license]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/LICENSE
217
- [compatibility]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/compatibility.md
218
- [errors]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/errors.md
219
- [stream-example]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/examples/stream.py
220
- [constructor-options]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/api.md#constructor-options
221
- [extensions]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/usage.md#using-your-own-extensions
222
- [ui-example]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/examples/ui.py
223
- [rpc]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/rpc.md
224
- [commands]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/api.md#all-33-rpc-commands
225
- [usage]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/usage.md
226
- [api]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/api.md
227
- [examples]: https://github.com/cheenulabs/pi-agent-python-sdk/tree/v0.1.0/examples
228
- [contributing]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/CONTRIBUTING.md
229
- [discovery]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/discovery.md
230
- [maintenance]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/maintenance.md
231
- [releasing]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/releasing.md
231
+ [pypi]: https://pypi.org/project/pi-agent-python-sdk/
232
+ [release]: https://github.com/cheenulabs/pi-agent-python-sdk/releases/tag/v0.2.0
233
+ [metadata]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/pyproject.toml
234
+ [license]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/LICENSE
235
+ [compatibility]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/compatibility.md
236
+ [errors]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/errors.md
237
+ [stream-example]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/examples/stream.py
238
+ [constructor-options]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/api.md#constructor-options
239
+ [extensions]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/usage.md#using-your-own-extensions
240
+ [ui-example]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/examples/ui.py
241
+ [rpc]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/rpc.md
242
+ [commands]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/api.md#all-33-rpc-commands
243
+ [usage]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/usage.md
244
+ [api]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/api.md
245
+ [examples]: https://github.com/cheenulabs/pi-agent-python-sdk/tree/v0.2.0/examples
246
+ [contributing]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/CONTRIBUTING.md
247
+ [discovery]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/discovery.md
248
+ [maintenance]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/maintenance.md
249
+ [releasing]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/releasing.md
@@ -1,5 +1,6 @@
1
1
  # Pi Agent Python SDK
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/pi-agent-python-sdk?style=flat-square)][pypi]
3
4
  [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-3776AB?style=flat-square)][metadata]
4
5
  [![MIT license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)][license]
5
6
 
@@ -29,10 +30,10 @@ pi --version
29
30
  Run `pi` once to configure your provider and model using Pi's normal setup.
30
31
  The SDK uses that configuration when it starts Pi.
31
32
 
32
- Install the Python package:
33
+ Install the Python package from [PyPI][pypi]:
33
34
 
34
35
  ```sh
35
- python -m pip install pi-agent-python-sdk==0.1.0
36
+ python -m pip install pi-agent-python-sdk==0.2.0
36
37
  ```
37
38
 
38
39
  The distribution is named `pi-agent-python-sdk`; import it as
@@ -50,6 +51,19 @@ The context manager starts and closes Pi. `run()` waits for the conversation to
50
51
  settle, including retries and queued follow-ups. The result includes finalized
51
52
  messages, session identity, elapsed time, and observed assistant usage.
52
53
 
54
+ Continue the conversation with another call on the same client:
55
+
56
+ ```python
57
+ from pi_agent import PiClient
58
+
59
+ with PiClient() as pi:
60
+ print(pi.run("Explain this project's entry points without editing files.").text)
61
+ print(pi.run("Which of those entry points handles configuration?").text)
62
+ ```
63
+
64
+ Pi keeps the conversation context. Each result contains only the messages and
65
+ answer from that call; a call with no assistant output has empty text.
66
+
53
67
  ## What you can do
54
68
 
55
69
  - **Run and stream:** get a final answer or consume text, thinking, and tool events.
@@ -74,8 +88,7 @@ with PiClient() as pi:
74
88
  ```
75
89
 
76
90
  `event.raw` contains the full Pi event, including fields the SDK does not yet
77
- recognize. Keep the stream inside its context: leaving early clears queued input
78
- and aborts the work it owns. See [errors and cancellation][errors] for
91
+ recognize. Keep the stream inside its context: leaving early cleans up unfinished work. See [errors and cancellation][errors] for
79
92
  handling timeouts and partial results.
80
93
 
81
94
  ## Async usage
@@ -141,6 +154,8 @@ Choose the interface that fits the work:
141
154
  | A completed conversation result | `run()` |
142
155
  | Events while a conversation runs | `stream()` |
143
156
  | Prompt acknowledgement and your own event handling | `prompt()` with `events()` |
157
+ | Session events through the next settlement | `prompt_and_wait()` or `collect_events()` |
158
+ | Every event without retaining history | `on_event(callback)` |
144
159
  | A specific Pi operation | `get_state()`, `set_model()`, `fork()`, and other command methods |
145
160
  | A raw command response envelope | `request()` |
146
161
 
@@ -158,10 +173,10 @@ Python arguments use `snake_case`; wire dictionaries retain Pi's `camelCase`
158
173
  fields. `prompt()` acknowledges submission, which may be handled entirely by an
159
174
  extension. It does not wait for a completed answer.
160
175
 
161
- One client owns one conversation at a time; create separate clients for
162
- independent conversations. After a low-level prompt submitted outside an owned
163
- run, use a fresh client for `run()` or `stream()` so events cannot be attributed
164
- to the wrong work. The SDK launches a new process and cannot attach to an existing
176
+ Use one client per conversation and wait for each `run()` or stream to finish
177
+ before starting the next. After using `prompt()` for your own event handling,
178
+ use a fresh client for `run()` or `stream()` so results cannot include delayed
179
+ events from earlier work. The SDK launches a new process and cannot attach to an existing
165
180
  Pi terminal session.
166
181
 
167
182
  See [RPC structure][rpc] for the protocol mapping and module layout, and
@@ -177,6 +192,7 @@ the [command reference][commands] for every method.
177
192
  | [RPC structure][rpc] | How the Python client maps to Pi's protocol |
178
193
  | [Compatibility][compatibility] | Runtime versions and platform validation |
179
194
  | [Examples][examples] | Runnable sync, async, streaming, sessions, steering, and UI examples |
195
+ | [Release notes][release] | Changes included in 0.2.0 |
180
196
 
181
197
  Examples use your configured Pi and may make provider calls. Development checks
182
198
  use an isolated local test provider; see [CONTRIBUTING.md][contributing] for
@@ -189,20 +205,22 @@ Maintainers: [protocol discovery][discovery] ·
189
205
 
190
206
  [MIT][license]
191
207
 
192
- [metadata]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/pyproject.toml
193
- [license]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/LICENSE
194
- [compatibility]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/compatibility.md
195
- [errors]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/errors.md
196
- [stream-example]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/examples/stream.py
197
- [constructor-options]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/api.md#constructor-options
198
- [extensions]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/usage.md#using-your-own-extensions
199
- [ui-example]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/examples/ui.py
200
- [rpc]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/rpc.md
201
- [commands]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/api.md#all-33-rpc-commands
202
- [usage]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/usage.md
203
- [api]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/api.md
204
- [examples]: https://github.com/cheenulabs/pi-agent-python-sdk/tree/v0.1.0/examples
205
- [contributing]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/CONTRIBUTING.md
206
- [discovery]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/discovery.md
207
- [maintenance]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/maintenance.md
208
- [releasing]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/docs/releasing.md
208
+ [pypi]: https://pypi.org/project/pi-agent-python-sdk/
209
+ [release]: https://github.com/cheenulabs/pi-agent-python-sdk/releases/tag/v0.2.0
210
+ [metadata]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/pyproject.toml
211
+ [license]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/LICENSE
212
+ [compatibility]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/compatibility.md
213
+ [errors]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/errors.md
214
+ [stream-example]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/examples/stream.py
215
+ [constructor-options]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/api.md#constructor-options
216
+ [extensions]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/usage.md#using-your-own-extensions
217
+ [ui-example]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/examples/ui.py
218
+ [rpc]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/rpc.md
219
+ [commands]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/api.md#all-33-rpc-commands
220
+ [usage]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/usage.md
221
+ [api]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/api.md
222
+ [examples]: https://github.com/cheenulabs/pi-agent-python-sdk/tree/v0.2.0/examples
223
+ [contributing]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/CONTRIBUTING.md
224
+ [discovery]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/discovery.md
225
+ [maintenance]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/maintenance.md
226
+ [releasing]: https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/docs/releasing.md
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pi-agent-python-sdk"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Typed async and synchronous Python clients for Pi's agent RPC runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -26,9 +26,9 @@ dependencies = []
26
26
 
27
27
  [project.urls]
28
28
  Homepage = "https://github.com/cheenulabs/pi-agent-python-sdk"
29
- Documentation = "https://github.com/cheenulabs/pi-agent-python-sdk/tree/v0.1.0/docs"
29
+ Documentation = "https://github.com/cheenulabs/pi-agent-python-sdk/tree/v0.2.0/docs"
30
30
  Issues = "https://github.com/cheenulabs/pi-agent-python-sdk/issues"
31
- Changelog = "https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.1.0/CHANGELOG.md"
31
+ Changelog = "https://github.com/cheenulabs/pi-agent-python-sdk/blob/v0.2.0/CHANGELOG.md"
32
32
 
33
33
  [dependency-groups]
34
34
  dev = [
@@ -3,6 +3,7 @@
3
3
  Importing this package never launches Pi or changes its configuration.
4
4
  """
5
5
 
6
+ from ._observation import ObservationStatus, ProcessOutput
6
7
  from .client import AsyncPiClient
7
8
  from .errors import (
8
9
  PiBusyError,
@@ -27,6 +28,8 @@ __all__ = [
27
28
  "Event",
28
29
  "ImageContent",
29
30
  "Limits",
31
+ "ObservationStatus",
32
+ "ProcessOutput",
30
33
  "PiBusyError",
31
34
  "PiCommandError",
32
35
  "PiClient",
@@ -0,0 +1,59 @@
1
+ """Bounded observation through the next settlement, without run ownership."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+
8
+ from ._events import EventSubscription
9
+ from .errors import PiProcessError, PiResultOverflow, PiTimeoutError
10
+ from .types import Event, Limits, _validate_timeout
11
+
12
+
13
+ def deadline(timeout: float | None) -> float | None:
14
+ if timeout is None:
15
+ return None
16
+ _validate_timeout(timeout, "Collection timeout")
17
+ return asyncio.get_running_loop().time() + timeout
18
+
19
+
20
+ def start_collection(
21
+ subscription: EventSubscription,
22
+ limits: Limits,
23
+ *,
24
+ timeout: float | None,
25
+ retain: bool,
26
+ ) -> asyncio.Task[list[Event] | None]:
27
+ expires = deadline(timeout)
28
+ # Register now, not when the task first runs: callers may submit immediately.
29
+ subscription._enter()
30
+
31
+ async def consume() -> list[Event] | None:
32
+ events: list[Event] = []
33
+ size = 0
34
+ timer = asyncio.timeout_at(expires)
35
+ try:
36
+ async with timer:
37
+ async for event in subscription:
38
+ if retain:
39
+ size += len(json.dumps(event.raw).encode("utf-8"))
40
+ if (
41
+ len(events) >= limits.collection_event_count
42
+ or size > limits.collection_event_bytes
43
+ ):
44
+ raise PiResultOverflow("Event collection exceeded its count/byte limit")
45
+ events.append(event)
46
+ if event.type == "agent_settled":
47
+ return events if retain else None
48
+ raise PiProcessError("Event collection closed before settlement")
49
+ except TimeoutError as exc:
50
+ if not timer.expired():
51
+ raise
52
+ raise PiTimeoutError("Event collection deadline elapsed", uncertain=False) from exc
53
+ finally:
54
+ await subscription.aclose()
55
+
56
+ task = asyncio.create_task(consume(), name="pi-event-collection")
57
+ # Cancellation before the task's first instruction cannot run its finally.
58
+ task.add_done_callback(lambda _: subscription._discard())
59
+ return task
@@ -6,13 +6,15 @@ import asyncio
6
6
  from collections import deque
7
7
  from collections.abc import Callable
8
8
  from types import TracebackType
9
- from typing import Self
9
+ from typing import Generic, Self, TypeVar
10
10
 
11
11
  from .errors import PiSubscriptionOverflow
12
12
  from .types import Event, Limits
13
13
 
14
+ T = TypeVar("T")
14
15
 
15
- class EventSubscription:
16
+
17
+ class _Subscription(Generic[T]):
16
18
  """Enter before submitting work to observe its events without a history buffer.
17
19
 
18
20
  A slow consumer receives PiSubscriptionOverflow rather than silently losing
@@ -22,13 +24,13 @@ class EventSubscription:
22
24
  def __init__(
23
25
  self,
24
26
  limits: Limits,
25
- register: Callable[[EventSubscription], None],
26
- unregister: Callable[[EventSubscription], None],
27
+ register: Callable[[], None],
28
+ unregister: Callable[[], None],
27
29
  ) -> None:
28
30
  self._limits = limits
29
31
  self._register = register
30
32
  self._unregister = unregister
31
- self._records: deque[tuple[Event, int]] = deque()
33
+ self._records: deque[tuple[T, int]] = deque()
32
34
  self._bytes = 0
33
35
  self._ready = asyncio.Event()
34
36
  self._entered = False
@@ -37,9 +39,12 @@ class EventSubscription:
37
39
  self._reading = False
38
40
 
39
41
  async def __aenter__(self) -> Self:
42
+ return self._enter()
43
+
44
+ def _enter(self) -> Self:
40
45
  if self._entered or self._closed:
41
46
  raise RuntimeError("Event subscriptions are single-use")
42
- self._register(self)
47
+ self._register()
43
48
  self._entered = True
44
49
  return self
45
50
 
@@ -53,22 +58,29 @@ class EventSubscription:
53
58
 
54
59
  async def aclose(self) -> None:
55
60
  """Unsubscribe and release queued payloads; safe to call repeatedly."""
61
+ self._discard()
62
+
63
+ def _discard(self, *, buffered: bool = False) -> None:
64
+ """Also used by the sync facade after closing the subscription."""
56
65
  self._finish()
57
66
  self._records.clear()
58
67
  self._bytes = 0
68
+ self._error = None
59
69
 
60
70
  def _finish(self, error: Exception | None = None) -> None:
61
71
  if self._closed:
62
72
  return
63
73
  self._closed = True
64
74
  self._error = error
65
- self._unregister(self)
66
- if error is not None:
75
+ self._unregister()
76
+ if isinstance(error, PiSubscriptionOverflow):
77
+ # Overflow is loss, not a complete prefix ending at a process failure.
78
+ # Keep it immediate; a healthy terminal queue can still be drained.
67
79
  self._records.clear()
68
80
  self._bytes = 0
69
81
  self._ready.set()
70
82
 
71
- def _put(self, event: Event, size: int) -> Exception | None:
83
+ def _put(self, event: T, size: int) -> Exception | None:
72
84
  if self._closed:
73
85
  return self._error
74
86
  if (
@@ -86,7 +98,7 @@ class EventSubscription:
86
98
  def __aiter__(self) -> Self:
87
99
  return self
88
100
 
89
- async def __anext__(self) -> Event:
101
+ async def __anext__(self) -> T:
90
102
  if not self._entered:
91
103
  raise RuntimeError("Enter the event subscription context before iterating")
92
104
  if self._reading:
@@ -94,15 +106,44 @@ class EventSubscription:
94
106
  self._reading = True
95
107
  try:
96
108
  while True:
97
- if self._error is not None:
98
- raise self._error
99
- if self._records:
100
- event, size = self._records.popleft()
101
- self._bytes -= size
109
+ event = self._next_nowait()
110
+ if event is not None:
102
111
  return event
103
- if self._closed:
104
- raise StopAsyncIteration
105
112
  self._ready.clear()
106
113
  await self._ready.wait()
107
114
  finally:
108
115
  self._reading = False
116
+
117
+ async def _next_batch(self) -> list[T]:
118
+ records = [await self.__anext__()]
119
+ # Transfer only records already buffered; never wait to fill a batch.
120
+ # This retains at most one queue's byte budget in the blocking facade.
121
+ while self._records and len(records) < 64:
122
+ record = self._next_nowait()
123
+ assert record is not None
124
+ records.append(record)
125
+ return records
126
+
127
+ def _next_nowait(self) -> T | None:
128
+ """Read on the owning loop, or after that loop has completely stopped."""
129
+ if self._records:
130
+ event, size = self._records.popleft()
131
+ self._bytes -= size
132
+ return event
133
+ if self._error is not None:
134
+ raise self._error
135
+ if self._closed:
136
+ raise StopAsyncIteration
137
+ return None
138
+
139
+
140
+ class EventSubscription(_Subscription[Event]):
141
+ """Bounded future events; enter before startup to include startup events."""
142
+
143
+ def __init__(
144
+ self,
145
+ limits: Limits,
146
+ register: Callable[[EventSubscription], None],
147
+ unregister: Callable[[EventSubscription], None],
148
+ ) -> None:
149
+ super().__init__(limits, lambda: register(self), lambda: unregister(self))
@@ -0,0 +1,73 @@
1
+ """Opt-in process output delivery; storage and forwarding belong to callers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field, replace
8
+ from typing import Any, Literal
9
+
10
+ from ._events import _Subscription
11
+ from .errors import PiSubscriptionOverflow
12
+ from .types import Limits
13
+
14
+ OutputSource = Literal["stderr", "stdout", "rpc"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ProcessOutput:
19
+ """Original bytes or a parsed object from the RPC child, excluding the version probe."""
20
+
21
+ source: OutputSource
22
+ time_ns: int
23
+ data: bytes | dict[str, Any] = field(repr=False)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ObservationStatus:
28
+ """Delivery coverage, independent of RPC success and consumer storage."""
29
+
30
+ started_at_ns: int | None = None
31
+ ended_at_ns: int | None = None
32
+ from_start: bool = False
33
+ complete: bool = False
34
+ lost: bool = False
35
+ stderr_eof: bool = False
36
+ stdout_eof: bool = False
37
+ rpc_complete: bool = True
38
+ error: Exception | None = field(default=None, repr=False)
39
+
40
+
41
+ class ProcessObservation(_Subscription[ProcessOutput]):
42
+ """Bounded output iterator. Check status after draining to detect incomplete output."""
43
+
44
+ def __init__(
45
+ self,
46
+ limits: Limits,
47
+ register: Callable[[ProcessObservation], None],
48
+ unregister: Callable[[ProcessObservation], None],
49
+ sources: frozenset[OutputSource],
50
+ ) -> None:
51
+ super().__init__(limits, lambda: register(self), lambda: unregister(self))
52
+ self._status = ObservationStatus()
53
+ self._sources = sources
54
+
55
+ @property
56
+ def status(self) -> ObservationStatus:
57
+ return self._status
58
+
59
+ def _finish(self, error: Exception | None = None) -> None:
60
+ if not self._closed and self._status.ended_at_ns is None:
61
+ self._status = ObservationStatus(
62
+ started_at_ns=self._status.started_at_ns,
63
+ ended_at_ns=time.time_ns(),
64
+ from_start=self._status.from_start,
65
+ lost=self._status.lost or isinstance(error, PiSubscriptionOverflow),
66
+ error=error,
67
+ )
68
+ super()._finish(error)
69
+
70
+ def _discard(self, *, buffered: bool = False) -> None:
71
+ if self._records or buffered:
72
+ self._status = replace(self._status, complete=False, lost=True)
73
+ super()._discard()