pi-agent-python-sdk 0.1.0__py3-none-any.whl → 0.2.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/__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,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()
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,13 @@ 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)
168
174
 
169
175
  async def _drive(self) -> RunResult:
170
- assert self._accepted is not None
176
+ assert self._ready is not None
171
177
  failure: BaseException | None = None
172
178
  try:
173
179
  async with asyncio.timeout(self._timeout):
@@ -182,7 +188,9 @@ class RunStream:
182
188
  await self._client._request(
183
189
  "prompt", fields, timeout=self._command_timeout, owner=self
184
190
  )
185
- self._accepted.set_result(None)
191
+ self._accepted = True
192
+ if not self._ready.done():
193
+ self._ready.set_result(None)
186
194
  try:
187
195
  async with asyncio.timeout(self._client.limits.run_start_timeout):
188
196
  await self._started.wait()
@@ -211,27 +219,34 @@ class RunStream:
211
219
  failure = PiTimeoutError(
212
220
  "Pi run deadline elapsed", command="prompt", uncertain=self._submitted
213
221
  )
214
- accepted = self._accepted.done() and not self._accepted.cancelled()
222
+ if self._client._ui_error is not None and failure is self._client._ui_error:
223
+ # The owner now reports this failure. Do not report it again as
224
+ # a failure of an otherwise successful cleanup command.
225
+ self._client._ui_error = None
215
226
  if (
216
227
  self._submitted
217
- and not isinstance(failure, PiCommandError)
218
- and (not self._settled.is_set() or not accepted)
228
+ and (not isinstance(failure, PiCommandError) or self._started.is_set())
229
+ and (not self._settled.is_set() or not self._accepted)
219
230
  ):
220
231
  # abort() cannot cancel an extension's pending input/UI preflight.
221
232
  # Keep delayed work from escaping a failed owned operation.
222
- if not accepted or not self._started.is_set():
233
+ if not self._accepted or not self._started.is_set():
223
234
  await self._client.aclose()
224
235
  else:
225
236
  await self._cleanup()
226
237
  if failure is exc:
227
238
  raise
239
+ if self._error is not None and failure is self._error:
240
+ # Cancellation only woke the driver; retain the recorded
241
+ # error's original cause (for example the UI callback error).
242
+ raise failure from failure.__cause__
228
243
  raise failure from exc
229
244
  finally:
230
- if not self._accepted.done():
245
+ if not self._ready.done():
231
246
  if isinstance(failure, asyncio.CancelledError):
232
- self._accepted.cancel()
247
+ self._ready.cancel()
233
248
  elif failure is not None:
234
- self._accepted.set_exception(failure)
249
+ self._ready.set_exception(failure)
235
250
  # Final model errors are delivered by result(), after their events are consumed.
236
251
  event_error = (
237
252
  failure
@@ -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: