pi-agent-python-sdk 0.1.0__py3-none-any.whl

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.
pi_agent/_transport.py ADDED
@@ -0,0 +1,360 @@
1
+ """Owned subprocess I/O. Only this module reads Pi's stdout or writes stdin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from collections.abc import Callable, Sequence
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from .errors import PiCommandError, PiProcessError, PiProtocolError, PiTimeoutError
12
+ from .types import Limits
13
+
14
+
15
+ async def _wait_for_exit(process: asyncio.subprocess.Process) -> None:
16
+ """Observe child exit without waiting for inherited pipes to reach EOF."""
17
+ while process.returncode is None: # noqa: ASYNC110 - asyncio exposes no pipe-independent exit event
18
+ await asyncio.sleep(0.01)
19
+
20
+
21
+ def _close_process_pipes(process: asyncio.subprocess.Process) -> None:
22
+ """Release this process's pipe handles after the owned child has exited."""
23
+ assert process.returncode is not None
24
+ # StreamReader has no public close(). Isolate the asyncio implementation
25
+ # seam needed when a descendant retains its own copies of the pipes. The
26
+ # owned child has exited, so close() cannot signal it or any descendants.
27
+ process._transport.close() # type: ignore[attr-defined]
28
+
29
+
30
+ @dataclass
31
+ class _Pending:
32
+ command: str
33
+ future: asyncio.Future[dict[str, Any]]
34
+
35
+
36
+ class Transport:
37
+ """Route checked responses and unsolicited events from one owned child."""
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ limits: Limits,
43
+ on_event: Callable[[dict[str, Any]], None],
44
+ on_failure: Callable[[Exception], None],
45
+ ) -> None:
46
+ self._limits = limits
47
+ self._on_event = on_event
48
+ self._on_failure = on_failure
49
+ self._process: asyncio.subprocess.Process | None = None
50
+ self._spawning: asyncio.Task[asyncio.subprocess.Process] | None = None
51
+ self._pending: dict[str, _Pending] = {}
52
+ self._next_id = 0
53
+ self._write_lock = asyncio.Lock()
54
+ self._reader: asyncio.Task[None] | None = None
55
+ self._stderr_reader: asyncio.Task[None] | None = None
56
+ self._exit_monitor: asyncio.Task[None] | None = None
57
+ self._closing: asyncio.Task[None] | None = None
58
+ self._failure: Exception | None = None
59
+ self._stderr = bytearray()
60
+ self._stdout_buffer = bytearray()
61
+
62
+ @property
63
+ def running(self) -> bool:
64
+ return (
65
+ self._process is not None and self._process.returncode is None and self._failure is None
66
+ )
67
+
68
+ @property
69
+ def stderr_tail(self) -> str:
70
+ """Opt-in bounded diagnostic text; never included in exceptions."""
71
+ return self._stderr.decode("utf-8", errors="replace")
72
+
73
+ async def start(
74
+ self,
75
+ argv: Sequence[str],
76
+ cwd: str | None = None,
77
+ env: dict[str, str] | None = None,
78
+ ) -> None:
79
+ if self._spawning is not None or self._failure is not None:
80
+ raise PiProcessError("Transport cannot be started twice")
81
+ if not argv:
82
+ raise ValueError("argv cannot be empty")
83
+ try:
84
+ self._spawning = asyncio.create_task(
85
+ asyncio.create_subprocess_exec(
86
+ *argv,
87
+ stdin=asyncio.subprocess.PIPE,
88
+ stdout=asyncio.subprocess.PIPE,
89
+ stderr=asyncio.subprocess.PIPE,
90
+ cwd=cwd,
91
+ env=env,
92
+ ),
93
+ name="pi-spawn",
94
+ )
95
+ # Keep ownership of a child created just as the caller is cancelled.
96
+ self._process = await asyncio.shield(self._spawning)
97
+ except asyncio.CancelledError:
98
+ await self.aclose()
99
+ raise
100
+ except (OSError, ValueError) as exc:
101
+ error = PiProcessError("Could not start Pi subprocess")
102
+ self._fail(error)
103
+ raise error from exc
104
+ if self._failure is not None:
105
+ await self.aclose()
106
+ raise self._failure
107
+ self._reader = asyncio.create_task(self._read_stdout(), name="pi-stdout")
108
+ self._stderr_reader = asyncio.create_task(self._read_stderr(), name="pi-stderr")
109
+ self._exit_monitor = asyncio.create_task(self._monitor_exit(), name="pi-exit")
110
+
111
+ async def request(
112
+ self,
113
+ command: str,
114
+ fields: dict[str, Any] | None = None,
115
+ *,
116
+ timeout: float | None = 30, # noqa: ASYNC109 - per-response API, distinct from write bound
117
+ ) -> dict[str, Any]:
118
+ if not isinstance(command, str) or not command:
119
+ raise ValueError("command must be a nonempty string")
120
+ if fields is not None and {"id", "type"}.intersection(fields):
121
+ raise ValueError("Request fields cannot override id or type")
122
+ self._check_running()
123
+ self._next_id += 1
124
+ request_id = str(self._next_id)
125
+ future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
126
+ # A fast child can respond while drain() is still suspended.
127
+ self._pending[request_id] = _Pending(command, future)
128
+ try:
129
+ await self._write({"type": command, "id": request_id, **(fields or {})})
130
+ try:
131
+ # Keep cancellation on this task: wait_for can lose cancellation
132
+ # when its separate waiter completes at the same time on Python 3.11.
133
+ async with asyncio.timeout(timeout):
134
+ return await asyncio.shield(future)
135
+ except TimeoutError as exc:
136
+ raise PiTimeoutError(
137
+ "Pi response timed out; the operation may still be running",
138
+ command=command,
139
+ request_id=request_id,
140
+ uncertain=True,
141
+ ) from exc
142
+ finally:
143
+ self._pending.pop(request_id, None)
144
+ if not future.done():
145
+ future.cancel()
146
+ elif not future.cancelled():
147
+ # A failed write can race a reader failure that rejected the future.
148
+ future.exception()
149
+
150
+ async def send_ui(self, record: dict[str, Any]) -> None:
151
+ """Send a dialog reply with its original ID, without awaiting an ack."""
152
+ if record.get("type") != "extension_ui_response" or not isinstance(record.get("id"), str):
153
+ raise ValueError("UI replies require type extension_ui_response and a string id")
154
+ await self._write(record)
155
+
156
+ def _check_running(self) -> None:
157
+ if self._failure is not None:
158
+ raise self._failure
159
+ if not self.running:
160
+ raise PiProcessError("Pi subprocess is not running")
161
+
162
+ async def _write(self, record: dict[str, Any]) -> None:
163
+ data = json.dumps(
164
+ record, ensure_ascii=False, allow_nan=False, separators=(",", ":")
165
+ ).encode("utf-8")
166
+ if len(data) > self._limits.max_record_bytes:
167
+ raise ValueError("Outbound JSON record exceeds max_record_bytes")
168
+ wrote = False
169
+ try:
170
+ # Bound lock acquisition too: a wedged pipe must not queue writers forever.
171
+ async with asyncio.timeout(self._limits.command_timeout):
172
+ async with self._write_lock:
173
+ self._check_running()
174
+ assert self._process is not None and self._process.stdin is not None
175
+ wrote = True
176
+ self._process.stdin.write(data + b"\n")
177
+ await self._process.stdin.drain()
178
+ except TimeoutError as exc:
179
+ error = PiTimeoutError("Pi stdin write timed out", uncertain=wrote)
180
+ if wrote:
181
+ self._fail(error)
182
+ raise error from exc
183
+ except asyncio.CancelledError:
184
+ if wrote:
185
+ self._fail(PiProcessError("Pi stdin write was interrupted"))
186
+ raise
187
+ except (BrokenPipeError, ConnectionError, OSError) as exc:
188
+ process_error = PiProcessError("Pi stdin closed while writing")
189
+ self._fail(process_error)
190
+ raise process_error from exc
191
+
192
+ async def _read_stdout(self) -> None:
193
+ assert self._process is not None and self._process.stdout is not None
194
+ buffer = self._stdout_buffer
195
+ try:
196
+ while chunk := await self._process.stdout.read(65536):
197
+ buffer.extend(chunk)
198
+ offset = 0
199
+ while (end := buffer.find(b"\n", offset)) >= 0:
200
+ self._record(bytes(buffer[offset:end]))
201
+ offset = end + 1
202
+ if offset:
203
+ del buffer[:offset]
204
+ if len(buffer) > self._limits.max_record_bytes:
205
+ raise PiProtocolError("Pi JSON record exceeds max_record_bytes")
206
+ if buffer:
207
+ self._record(bytes(buffer))
208
+ buffer.clear()
209
+ # EOF itself is terminal even if an extension left the process alive.
210
+ self._fail(PiProcessError("Pi stdout closed", returncode=self._process.returncode))
211
+ except asyncio.CancelledError:
212
+ raise
213
+ except Exception as exc:
214
+ self._fail(exc)
215
+
216
+ async def _monitor_exit(self) -> None:
217
+ assert self._process is not None
218
+ await _wait_for_exit(self._process)
219
+ # Let already-ready stdout callbacks run before declaring requests lost.
220
+ # A descendant may retain the pipe, so EOF cannot be our exit signal.
221
+ await asyncio.sleep(0)
222
+ if self._failure is None:
223
+ try:
224
+ if self._stdout_buffer:
225
+ self._record(bytes(self._stdout_buffer))
226
+ self._stdout_buffer.clear()
227
+ except Exception as exc:
228
+ self._fail(exc)
229
+ return
230
+ self._fail(PiProcessError("Pi subprocess exited", returncode=self._process.returncode))
231
+
232
+ def _record(self, data: bytes) -> None:
233
+ if data.endswith(b"\r"):
234
+ data = data[:-1]
235
+ if len(data) > self._limits.max_record_bytes:
236
+ raise PiProtocolError("Pi JSON record exceeds max_record_bytes")
237
+ if not data:
238
+ return
239
+ try:
240
+ record = json.loads(data.decode("utf-8"), parse_constant=self._invalid_constant)
241
+ except (ValueError, UnicodeError, RecursionError) as exc:
242
+ raise PiProtocolError("Pi emitted invalid UTF-8 JSON") from exc
243
+ if not isinstance(record, dict) or not isinstance(record.get("type"), str):
244
+ raise PiProtocolError("Pi record requires an object with a string type")
245
+ if record["type"] != "response":
246
+ self._on_event(record)
247
+ return
248
+ request_id = record.get("id")
249
+ if (
250
+ not isinstance(record.get("command"), str)
251
+ or not isinstance(record.get("success"), bool)
252
+ or (request_id is not None and not isinstance(request_id, str))
253
+ or (record.get("success") is False and not isinstance(record.get("error"), str))
254
+ ):
255
+ raise PiProtocolError("Malformed Pi response envelope")
256
+ if request_id is None:
257
+ raise PiProtocolError("Pi emitted an uncorrelated response")
258
+ pending = self._pending.get(request_id)
259
+ if pending is None or pending.future.done():
260
+ # Late and duplicate replies are not events and cannot match future IDs.
261
+ return
262
+ if pending.command != record["command"]:
263
+ raise PiProtocolError("Pi response command does not match its request")
264
+ if record["success"]:
265
+ pending.future.set_result(record)
266
+ else:
267
+ pending.future.set_exception(
268
+ PiCommandError(pending.command, record["error"], request_id=request_id)
269
+ )
270
+
271
+ @staticmethod
272
+ def _invalid_constant(value: str) -> None:
273
+ raise ValueError(f"Non-JSON numeric constant: {value}")
274
+
275
+ async def _read_stderr(self) -> None:
276
+ assert self._process is not None and self._process.stderr is not None
277
+ try:
278
+ while chunk := await self._process.stderr.read(65536):
279
+ if self._limits.stderr_tail_bytes:
280
+ self._stderr.extend(chunk)
281
+ del self._stderr[: -self._limits.stderr_tail_bytes]
282
+ except asyncio.CancelledError:
283
+ raise
284
+ except Exception:
285
+ self._fail(PiProcessError("Could not drain Pi stderr"))
286
+
287
+ def _fail(self, error: Exception) -> None:
288
+ if self._failure is not None:
289
+ return
290
+ self._failure = error
291
+ for pending in self._pending.values():
292
+ if not pending.future.done():
293
+ pending.future.set_exception(error)
294
+ # Start teardown before callbacks so their failure cannot orphan the child.
295
+ self._closing = asyncio.create_task(self._finish_close(), name="pi-close")
296
+ self._on_failure(error)
297
+
298
+ async def aclose(self) -> None:
299
+ """Close stdin, then terminate/kill as necessary and reap the child."""
300
+ self._fail(PiProcessError("Pi client closed"))
301
+ assert self._closing is not None
302
+ await asyncio.shield(self._closing)
303
+
304
+ async def _finish_close(self) -> None:
305
+ discard: asyncio.Task[None] | None = None
306
+ try:
307
+ if self._spawning is not None:
308
+ try:
309
+ self._process = await self._spawning
310
+ except (OSError, ValueError):
311
+ return
312
+ process = self._process
313
+ if process is not None:
314
+ # Once terminal, discard stdout but keep draining it. A full pipe can
315
+ # prevent asyncio's process.wait() finishing even after the child dies.
316
+ if self._reader is not None:
317
+ self._reader.cancel()
318
+ await asyncio.gather(self._reader, return_exceptions=True)
319
+ if process.stdout is not None:
320
+ discard = asyncio.create_task(self._discard(process.stdout))
321
+ if self._stderr_reader is None:
322
+ self._stderr_reader = asyncio.create_task(self._read_stderr())
323
+ if process.stdin is not None:
324
+ process.stdin.close()
325
+ try:
326
+ await asyncio.wait_for(_wait_for_exit(process), self._limits.cleanup_timeout)
327
+ except TimeoutError:
328
+ if process.returncode is None:
329
+ try:
330
+ process.terminate()
331
+ except ProcessLookupError:
332
+ pass
333
+ try:
334
+ await asyncio.wait_for(
335
+ _wait_for_exit(process), self._limits.cleanup_timeout
336
+ )
337
+ except TimeoutError:
338
+ if process.returncode is None:
339
+ try:
340
+ process.kill()
341
+ except ProcessLookupError:
342
+ pass
343
+ await _wait_for_exit(process)
344
+ _close_process_pipes(process)
345
+ await process.wait()
346
+ finally:
347
+ tasks = [
348
+ task
349
+ for task in (self._reader, self._stderr_reader, self._exit_monitor, discard)
350
+ if task is not None
351
+ ]
352
+ for task in tasks:
353
+ if not task.done():
354
+ task.cancel()
355
+ await asyncio.gather(*tasks, return_exceptions=True)
356
+
357
+ @staticmethod
358
+ async def _discard(stream: asyncio.StreamReader) -> None:
359
+ while await stream.read(65536):
360
+ pass
pi_agent/_usage.py ADDED
@@ -0,0 +1,88 @@
1
+ """Incremental assistant accounting for core run results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ from .types import UsageSummary
9
+
10
+ _TOKENS = {
11
+ "input_tokens": "input",
12
+ "output_tokens": "output",
13
+ "cache_read_tokens": "cacheRead",
14
+ "cache_write_tokens": "cacheWrite",
15
+ "cache_write_1h_tokens": "cacheWrite1h",
16
+ "reasoning_tokens": "reasoning",
17
+ "total_tokens": "totalTokens",
18
+ }
19
+
20
+
21
+ class UsageAccumulator:
22
+ """Keep only totals; a missing or invalid measurement stays unknown."""
23
+
24
+ def __init__(self) -> None:
25
+ self._totals: dict[str, int | None] = dict.fromkeys(_TOKENS, 0)
26
+ self._cost: float | None = 0.0
27
+ self._count = 0
28
+ self._observed = False
29
+
30
+ def add(self, message: dict[str, Any]) -> None:
31
+ if message.get("role") != "assistant":
32
+ return
33
+ self._count += 1
34
+ raw = message.get("usage")
35
+ self._observed |= isinstance(raw, dict)
36
+ usage = raw if isinstance(raw, dict) else {}
37
+ for field, wire in _TOKENS.items():
38
+ value = usage.get(wire)
39
+ count = (
40
+ value
41
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 0
42
+ else None
43
+ )
44
+ if field == "reasoning_tokens" and count is not None:
45
+ output = usage.get("output")
46
+ content = message.get("content")
47
+ thinking = isinstance(content, list) and any(
48
+ isinstance(block, dict)
49
+ and block.get("type") == "thinking"
50
+ and block.get("thinking")
51
+ for block in content
52
+ )
53
+ valid = (
54
+ isinstance(output, int)
55
+ and not isinstance(output, bool)
56
+ and count <= output
57
+ and not (count == 0 and thinking)
58
+ )
59
+ if not valid:
60
+ count = None
61
+ previous = self._totals[field]
62
+ self._totals[field] = (
63
+ previous + count if previous is not None and count is not None else None
64
+ )
65
+ cost = usage.get("cost")
66
+ amount = cost.get("total") if isinstance(cost, dict) else None
67
+ if isinstance(amount, (int, float)) and not isinstance(amount, bool):
68
+ try:
69
+ amount = float(amount)
70
+ except OverflowError:
71
+ amount = None
72
+ if (
73
+ self._cost is not None
74
+ and isinstance(amount, (int, float))
75
+ and not isinstance(amount, bool)
76
+ and math.isfinite(amount)
77
+ and amount >= 0
78
+ ):
79
+ self._cost += amount
80
+ if not math.isfinite(self._cost):
81
+ self._cost = None
82
+ else:
83
+ self._cost = None
84
+
85
+ def snapshot(self) -> UsageSummary | None:
86
+ if not self._observed:
87
+ return None
88
+ return UsageSummary(**self._totals, cost=self._cost, assistant_messages=self._count)