pi-agent-python-sdk 0.1.0__py3-none-any.whl → 0.2.1__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/__init__.py CHANGED
@@ -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
pi_agent/_events.py CHANGED
@@ -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,46 @@ 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
+ # Drain the ready queue in one crossing so bridge latency does not leave
121
+ # a growing backlog. No await below: count and bytes stay within the
122
+ # existing queue budgets, including the first record just removed.
123
+ while self._records:
124
+ record = self._next_nowait()
125
+ assert record is not None
126
+ records.append(record)
127
+ return records
128
+
129
+ def _next_nowait(self) -> T | None:
130
+ """Read on the owning loop, or after that loop has completely stopped."""
131
+ if self._records:
132
+ event, size = self._records.popleft()
133
+ self._bytes -= size
134
+ return event
135
+ if self._error is not None:
136
+ raise self._error
137
+ if self._closed:
138
+ raise StopAsyncIteration
139
+ return None
140
+
141
+
142
+ class EventSubscription(_Subscription[Event]):
143
+ """Bounded future events; enter before startup to include startup events."""
144
+
145
+ def __init__(
146
+ self,
147
+ limits: Limits,
148
+ register: Callable[[EventSubscription], None],
149
+ unregister: Callable[[EventSubscription], None],
150
+ ) -> None:
151
+ super().__init__(limits, lambda: register(self), lambda: unregister(self))
pi_agent/_launch.py CHANGED
@@ -13,7 +13,7 @@ from ._transport import _close_process_pipes, _wait_for_exit
13
13
  from .errors import PiProcessError, PiVersionError
14
14
 
15
15
  MINIMUM_PI_VERSION = "0.85.1"
16
- TESTED_PI_VERSION = "0.85.1"
16
+ TESTED_PI_VERSION = "0.86.0"
17
17
 
18
18
  # The client owns these options. In particular --print consumes an initial prompt.
19
19
  _RESERVED = {
@@ -0,0 +1,95 @@
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
+ end_reason: Literal["stopped", "closed", "overflow", "process_end"] | None = None
40
+
41
+
42
+ class ProcessObservation(_Subscription[ProcessOutput]):
43
+ """Bounded output iterator. Check status after draining to detect incomplete output."""
44
+
45
+ def __init__(
46
+ self,
47
+ limits: Limits,
48
+ register: Callable[[ProcessObservation], None],
49
+ unregister: Callable[[ProcessObservation], None],
50
+ sources: frozenset[OutputSource],
51
+ ) -> None:
52
+ super().__init__(limits, lambda: register(self), lambda: unregister(self))
53
+ self._status = ObservationStatus()
54
+ self._sources = sources
55
+
56
+ @property
57
+ def status(self) -> ObservationStatus:
58
+ return self._status
59
+
60
+ async def stop(self) -> None:
61
+ """Unsubscribe without discarding queued output or closing the client.
62
+
63
+ Drain the iterator afterwards, then inspect status. Repeated stops retain
64
+ the first termination status, including overflow or process failure.
65
+
66
+ Raises:
67
+ RuntimeError: If the observation context has not been entered.
68
+ """
69
+ if not self._entered:
70
+ raise RuntimeError("Enter the observation context before stopping")
71
+ if not self._closed:
72
+ self._status = replace(self._status, end_reason="stopped")
73
+ self._finish()
74
+
75
+ def _finish(self, error: Exception | None = None) -> None:
76
+ if not self._closed and self._status.ended_at_ns is None:
77
+ overflow = isinstance(error, PiSubscriptionOverflow)
78
+ self._status = replace(
79
+ self._status,
80
+ ended_at_ns=time.time_ns(),
81
+ lost=self._status.lost or overflow,
82
+ error=error or self._status.error,
83
+ end_reason="overflow" if overflow else self._status.end_reason or "closed",
84
+ )
85
+ super()._finish(error)
86
+
87
+ def _discard(self, *, buffered: bool = False) -> None:
88
+ if self._records or buffered:
89
+ self._status = replace(
90
+ self._status,
91
+ complete=False,
92
+ lost=True,
93
+ end_reason="overflow" if self._status.end_reason == "overflow" else "closed",
94
+ )
95
+ super()._discard()
pi_agent/_runs.py CHANGED
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- import math
7
6
  import time
8
7
  from types import TracebackType
9
8
  from typing import TYPE_CHECKING, Any, Self
@@ -20,14 +19,14 @@ from .errors import (
20
19
  PiRunStartTimeout,
21
20
  PiTimeoutError,
22
21
  )
23
- from .types import Event, ImageContent, RunResult
22
+ from .types import Event, ImageContent, RunResult, _validate_timeout
24
23
 
25
24
  if TYPE_CHECKING:
26
25
  from .client import AsyncPiClient, Timeout
27
26
 
28
27
 
29
28
  class RunStream:
30
- """A single-use owned run. Enter, then iterate or await result() to drain.
29
+ """A single-use owned run. Enter on acceptance or start, then consume events.
31
30
 
32
31
  Breaking iteration must be followed by leaving the context so queued work
33
32
  is cleared and Pi is aborted. An accepted command without a start event
@@ -43,10 +42,8 @@ class RunStream:
43
42
  timeout: float | None,
44
43
  command_timeout: Timeout,
45
44
  ) -> None:
46
- if timeout is not None and (
47
- isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0
48
- ):
49
- raise ValueError("run timeout must be a positive finite number or None")
45
+ if timeout is not None:
46
+ _validate_timeout(timeout, "run timeout")
50
47
  self._client = client
51
48
  self._message = message
52
49
  self._images = images
@@ -55,10 +52,12 @@ class RunStream:
55
52
  self._events = EventSubscription(
56
53
  client.limits, lambda subscription: None, lambda subscription: None
57
54
  )
55
+ self._retain_events = True
58
56
  self._started = asyncio.Event()
59
57
  self._settled = asyncio.Event()
60
58
  self._task: asyncio.Task[RunResult] | None = None
61
- self._accepted: asyncio.Future[None] | None = None
59
+ self._ready: asyncio.Future[None] | None = None
60
+ self._accepted = False
62
61
  self._error: Exception | None = None
63
62
  self._messages: list[dict[str, Any]] = []
64
63
  self._message_bytes = 0
@@ -86,12 +85,14 @@ class RunStream:
86
85
  "run()/stream() because delayed session events cannot be attributed safely"
87
86
  )
88
87
  self._client._owner = self
89
- self._accepted = asyncio.get_running_loop().create_future()
88
+ self._ready = asyncio.get_running_loop().create_future()
90
89
  await self._events.__aenter__()
91
90
  self._task = asyncio.create_task(self._drive(), name="pi-run")
92
91
  self._task.add_done_callback(self._consume_task_exception)
93
92
  try:
94
- await asyncio.shield(self._accepted)
93
+ await self._ready
94
+ if self._error is not None:
95
+ raise self._error
95
96
  except BaseException:
96
97
  await self.aclose()
97
98
  raise
@@ -120,8 +121,8 @@ class RunStream:
120
121
  await asyncio.shield(self._task)
121
122
  except (Exception, asyncio.CancelledError):
122
123
  pass
123
- if self._accepted is not None and self._accepted.done() and not self._accepted.cancelled():
124
- self._accepted.exception()
124
+ if self._ready is not None and self._ready.done() and not self._ready.cancelled():
125
+ self._ready.exception()
125
126
  await self._events.aclose()
126
127
 
127
128
  def _fail(self, error: Exception) -> None:
@@ -137,6 +138,10 @@ class RunStream:
137
138
  return
138
139
  if event.type == "agent_start":
139
140
  self._started.set()
141
+ # Extensions may wait for the entire run before acknowledging the
142
+ # prompt. Make consumption possible without declaring acceptance.
143
+ if self._ready is not None and not self._ready.done():
144
+ self._ready.set_result(None)
140
145
  elif event.type == "agent_settled" and self._started.is_set():
141
146
  self._end = time.monotonic()
142
147
  self._settled.set()
@@ -162,12 +167,17 @@ class RunStream:
162
167
  self._message_bytes += size
163
168
  self._messages.append(message)
164
169
  self._usage.add(message)
165
- error = self._events._put(event, size)
166
- if error is not None:
167
- self._fail(error)
170
+ if self._retain_events:
171
+ error = self._events._put(event, size)
172
+ if error is not None:
173
+ self._fail(error)
174
+
175
+ def _mark_submitted(self) -> None:
176
+ self._submitted = True
177
+ self._begin = time.monotonic()
168
178
 
169
179
  async def _drive(self) -> RunResult:
170
- assert self._accepted is not None
180
+ assert self._ready is not None
171
181
  failure: BaseException | None = None
172
182
  try:
173
183
  async with asyncio.timeout(self._timeout):
@@ -177,12 +187,12 @@ class RunStream:
177
187
  fields: dict[str, Any] = {"message": self._message}
178
188
  if self._images is not None:
179
189
  fields["images"] = self._images
180
- self._submitted = True
181
- self._begin = time.monotonic()
182
190
  await self._client._request(
183
191
  "prompt", fields, timeout=self._command_timeout, owner=self
184
192
  )
185
- self._accepted.set_result(None)
193
+ self._accepted = True
194
+ if not self._ready.done():
195
+ self._ready.set_result(None)
186
196
  try:
187
197
  async with asyncio.timeout(self._client.limits.run_start_timeout):
188
198
  await self._started.wait()
@@ -211,27 +221,34 @@ class RunStream:
211
221
  failure = PiTimeoutError(
212
222
  "Pi run deadline elapsed", command="prompt", uncertain=self._submitted
213
223
  )
214
- accepted = self._accepted.done() and not self._accepted.cancelled()
224
+ if self._client._ui_error is not None and failure is self._client._ui_error:
225
+ # The owner now reports this failure. Do not report it again as
226
+ # a failure of an otherwise successful cleanup command.
227
+ self._client._ui_error = None
215
228
  if (
216
229
  self._submitted
217
- and not isinstance(failure, PiCommandError)
218
- and (not self._settled.is_set() or not accepted)
230
+ and (not isinstance(failure, PiCommandError) or self._started.is_set())
231
+ and (not self._settled.is_set() or not self._accepted)
219
232
  ):
220
233
  # abort() cannot cancel an extension's pending input/UI preflight.
221
234
  # Keep delayed work from escaping a failed owned operation.
222
- if not accepted or not self._started.is_set():
235
+ if not self._accepted or not self._started.is_set():
223
236
  await self._client.aclose()
224
237
  else:
225
238
  await self._cleanup()
226
239
  if failure is exc:
227
240
  raise
241
+ if self._error is not None and failure is self._error:
242
+ # Cancellation only woke the driver; retain the recorded
243
+ # error's original cause (for example the UI callback error).
244
+ raise failure from failure.__cause__
228
245
  raise failure from exc
229
246
  finally:
230
- if not self._accepted.done():
247
+ if not self._ready.done():
231
248
  if isinstance(failure, asyncio.CancelledError):
232
- self._accepted.cancel()
249
+ self._ready.cancel()
233
250
  elif failure is not None:
234
- self._accepted.set_exception(failure)
251
+ self._ready.set_exception(failure)
235
252
  # Final model errors are delivered by result(), after their events are consumed.
236
253
  event_error = (
237
254
  failure
@@ -285,8 +302,6 @@ class RunStream:
285
302
  def __aiter__(self) -> Self:
286
303
  if self._draining:
287
304
  raise PiBusyError("Cannot iterate while result() drains the stream")
288
- if self._iterating:
289
- raise PiBusyError("A stream permits one event iterator")
290
305
  self._iterating = True
291
306
  return self
292
307
 
@@ -300,6 +315,16 @@ class RunStream:
300
315
  self._iteration_done = True
301
316
  raise
302
317
 
318
+ async def _next_batch(self) -> list[Event]:
319
+ if self._draining:
320
+ raise PiBusyError("Cannot iterate while result() drains the stream")
321
+ self._iterating = True
322
+ try:
323
+ return await self._events._next_batch()
324
+ except StopAsyncIteration:
325
+ self._iteration_done = True
326
+ raise
327
+
303
328
  async def result(self) -> RunResult:
304
329
  """Drain without retaining events, or return the result after iteration ends."""
305
330
  if self._task is None: