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/__init__.py +47 -0
- pi_agent/_events.py +108 -0
- pi_agent/_launch.py +203 -0
- pi_agent/_runs.py +317 -0
- pi_agent/_transport.py +360 -0
- pi_agent/_usage.py +88 -0
- pi_agent/client.py +796 -0
- pi_agent/errors.py +87 -0
- pi_agent/py.typed +0 -0
- pi_agent/sync.py +684 -0
- pi_agent/types.py +960 -0
- pi_agent_python_sdk-0.1.0.dist-info/METADATA +231 -0
- pi_agent_python_sdk-0.1.0.dist-info/RECORD +15 -0
- pi_agent_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- pi_agent_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
pi_agent/sync.py
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
"""Synchronous access through one persistent event loop per client.
|
|
2
|
+
|
|
3
|
+
RPC behavior lives in AsyncPiClient. This module only bridges threads and
|
|
4
|
+
context managers; it never reads pipes or implements command serialization.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import concurrent.futures
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
from collections.abc import Callable, Coroutine, Mapping, Sequence
|
|
14
|
+
from types import TracebackType
|
|
15
|
+
from typing import Any, Generic, Self, TypeVar
|
|
16
|
+
|
|
17
|
+
from ._events import EventSubscription
|
|
18
|
+
from ._runs import RunStream
|
|
19
|
+
from .client import DEFAULT_TIMEOUT, IN_SYNC_UI, AsyncPiClient, Timeout, UIHandler
|
|
20
|
+
from .errors import PiProcessError
|
|
21
|
+
from .types import (
|
|
22
|
+
AcceptanceReceipt,
|
|
23
|
+
AgentMessage,
|
|
24
|
+
BashResult,
|
|
25
|
+
CompactionResult,
|
|
26
|
+
EntriesResult,
|
|
27
|
+
Event,
|
|
28
|
+
ForkMessage,
|
|
29
|
+
ForkResult,
|
|
30
|
+
ImageContent,
|
|
31
|
+
Limits,
|
|
32
|
+
Model,
|
|
33
|
+
ModelCycleResult,
|
|
34
|
+
QueueMode,
|
|
35
|
+
QueueState,
|
|
36
|
+
RunResult,
|
|
37
|
+
SessionChangeResult,
|
|
38
|
+
SessionInfo,
|
|
39
|
+
SessionState,
|
|
40
|
+
SessionStats,
|
|
41
|
+
SlashCommand,
|
|
42
|
+
ThinkingLevel,
|
|
43
|
+
TreeResult,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
T = TypeVar("T")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Call(Generic[T]):
|
|
50
|
+
"""Track task completion separately from a caller abandoning its result."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, factory: Callable[[], Coroutine[Any, Any, T]]) -> None:
|
|
53
|
+
self.factory = factory
|
|
54
|
+
self.result: concurrent.futures.Future[T] = concurrent.futures.Future()
|
|
55
|
+
self.finished = threading.Event()
|
|
56
|
+
self.task: asyncio.Task[T] | None = None
|
|
57
|
+
self.cancelled = False
|
|
58
|
+
|
|
59
|
+
def start(self) -> None:
|
|
60
|
+
async def invoke() -> T:
|
|
61
|
+
return await self.factory()
|
|
62
|
+
|
|
63
|
+
self.task = asyncio.create_task(invoke(), name="pi-sync-call")
|
|
64
|
+
self.task.add_done_callback(self._done)
|
|
65
|
+
if self.cancelled:
|
|
66
|
+
self.task.cancel()
|
|
67
|
+
|
|
68
|
+
def cancel(self) -> None:
|
|
69
|
+
self.cancelled = True
|
|
70
|
+
if self.task is not None:
|
|
71
|
+
self.task.cancel()
|
|
72
|
+
|
|
73
|
+
def _done(self, task: asyncio.Task[T]) -> None:
|
|
74
|
+
try:
|
|
75
|
+
self.result.set_result(task.result())
|
|
76
|
+
except BaseException as exc:
|
|
77
|
+
self.result.set_exception(exc)
|
|
78
|
+
finally:
|
|
79
|
+
self.finished.set()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PiClient:
|
|
83
|
+
"""A blocking facade over AsyncPiClient with the same RPC semantics.
|
|
84
|
+
|
|
85
|
+
Construction has no thread or subprocess side effects. Enter a context or
|
|
86
|
+
call start(), and always close(). Calls from multiple threads are routed
|
|
87
|
+
to one loop; conversation ownership remains enforced by the async core.
|
|
88
|
+
Use AsyncPiClient from asynchronous applications.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
*,
|
|
94
|
+
executable: str | os.PathLike[str] | Sequence[str] = "pi",
|
|
95
|
+
cwd: str | os.PathLike[str] | None = None,
|
|
96
|
+
provider: str | None = None,
|
|
97
|
+
model: str | None = None,
|
|
98
|
+
env: Mapping[str, str | None] | None = None,
|
|
99
|
+
inherit_env: bool = True,
|
|
100
|
+
session: str | None = None,
|
|
101
|
+
session_dir: str | None = None,
|
|
102
|
+
session_id: str | None = None,
|
|
103
|
+
continue_session: bool = False,
|
|
104
|
+
no_session: bool = False,
|
|
105
|
+
fork_session: str | None = None,
|
|
106
|
+
extra_args: Sequence[str] = (),
|
|
107
|
+
ui_handler: UIHandler | None = None,
|
|
108
|
+
limits: Limits | None = None,
|
|
109
|
+
strict_version: bool = False,
|
|
110
|
+
allow_unknown_version: bool = False,
|
|
111
|
+
) -> None:
|
|
112
|
+
self._client = AsyncPiClient(
|
|
113
|
+
executable=executable,
|
|
114
|
+
cwd=cwd,
|
|
115
|
+
provider=provider,
|
|
116
|
+
model=model,
|
|
117
|
+
env=env,
|
|
118
|
+
inherit_env=inherit_env,
|
|
119
|
+
session=session,
|
|
120
|
+
session_dir=session_dir,
|
|
121
|
+
session_id=session_id,
|
|
122
|
+
continue_session=continue_session,
|
|
123
|
+
no_session=no_session,
|
|
124
|
+
fork_session=fork_session,
|
|
125
|
+
extra_args=extra_args,
|
|
126
|
+
ui_handler=ui_handler,
|
|
127
|
+
limits=limits,
|
|
128
|
+
strict_version=strict_version,
|
|
129
|
+
allow_unknown_version=allow_unknown_version,
|
|
130
|
+
)
|
|
131
|
+
self._lock = threading.Lock()
|
|
132
|
+
self._ready = threading.Event()
|
|
133
|
+
self._closed_event = threading.Event()
|
|
134
|
+
self._thread_done = threading.Event()
|
|
135
|
+
self._thread: threading.Thread | None = None
|
|
136
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
137
|
+
self._loop_error: BaseException | None = None
|
|
138
|
+
self._closing = False
|
|
139
|
+
self._closed = False
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def limits(self) -> Limits:
|
|
143
|
+
return self._client.limits
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def running(self) -> bool:
|
|
147
|
+
return self._client.running
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def busy(self) -> bool:
|
|
151
|
+
return self._client.busy
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def session(self) -> SessionInfo:
|
|
155
|
+
return self._client.session
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def pi_version(self) -> str | None:
|
|
159
|
+
return self._client.pi_version
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def compatibility(self) -> str:
|
|
163
|
+
return self._client.compatibility
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def stderr_tail(self) -> str:
|
|
167
|
+
return self._client.stderr_tail
|
|
168
|
+
|
|
169
|
+
def _check_caller(self) -> None:
|
|
170
|
+
if IN_SYNC_UI.get() or threading.current_thread() is self._thread:
|
|
171
|
+
raise RuntimeError("Blocking PiClient calls are not allowed from UI callbacks")
|
|
172
|
+
|
|
173
|
+
def _loop_main(self) -> None:
|
|
174
|
+
loop: asyncio.AbstractEventLoop | None = None
|
|
175
|
+
try:
|
|
176
|
+
loop = asyncio.new_event_loop()
|
|
177
|
+
asyncio.set_event_loop(loop)
|
|
178
|
+
except BaseException as exc:
|
|
179
|
+
self._loop_error = exc
|
|
180
|
+
if loop is not None:
|
|
181
|
+
loop.close()
|
|
182
|
+
# The starting thread must wake even when no loop could be created.
|
|
183
|
+
self._ready.set()
|
|
184
|
+
self._thread_done.set()
|
|
185
|
+
return
|
|
186
|
+
self._loop = loop
|
|
187
|
+
self._ready.set()
|
|
188
|
+
try:
|
|
189
|
+
loop.run_forever()
|
|
190
|
+
finally:
|
|
191
|
+
|
|
192
|
+
async def finish() -> None:
|
|
193
|
+
remaining = [
|
|
194
|
+
task for task in asyncio.all_tasks() if task is not asyncio.current_task()
|
|
195
|
+
]
|
|
196
|
+
for task in remaining:
|
|
197
|
+
task.cancel()
|
|
198
|
+
if remaining:
|
|
199
|
+
await asyncio.gather(*remaining, return_exceptions=True)
|
|
200
|
+
await loop.shutdown_asyncgens()
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
loop.run_until_complete(finish())
|
|
204
|
+
finally:
|
|
205
|
+
# A user callback may be blocked in a worker thread. Python cannot
|
|
206
|
+
# forcibly stop it; closing the loop does not wait for such callbacks.
|
|
207
|
+
loop.close()
|
|
208
|
+
self._thread_done.set()
|
|
209
|
+
|
|
210
|
+
def _call(
|
|
211
|
+
self,
|
|
212
|
+
factory: Callable[[], Coroutine[Any, Any, T]],
|
|
213
|
+
*,
|
|
214
|
+
closing: bool = False,
|
|
215
|
+
cancel_on_interrupt: bool = True,
|
|
216
|
+
) -> T:
|
|
217
|
+
self._check_caller()
|
|
218
|
+
call = _Call(factory)
|
|
219
|
+
with self._lock:
|
|
220
|
+
loop = self._loop
|
|
221
|
+
if (
|
|
222
|
+
loop is None
|
|
223
|
+
or self._thread_done.is_set()
|
|
224
|
+
or self._closed
|
|
225
|
+
or (self._closing and not closing)
|
|
226
|
+
):
|
|
227
|
+
raise PiProcessError("Pi is not running; use the client context or start() first")
|
|
228
|
+
loop.call_soon_threadsafe(call.start)
|
|
229
|
+
try:
|
|
230
|
+
return call.result.result()
|
|
231
|
+
except KeyboardInterrupt:
|
|
232
|
+
if cancel_on_interrupt:
|
|
233
|
+
loop.call_soon_threadsafe(call.cancel)
|
|
234
|
+
# Future.cancel() marks a bridge cancelled before async finally blocks
|
|
235
|
+
# finish. Only the task's done callback proves cleanup has completed.
|
|
236
|
+
while not call.finished.is_set():
|
|
237
|
+
try:
|
|
238
|
+
call.finished.wait()
|
|
239
|
+
except KeyboardInterrupt:
|
|
240
|
+
continue
|
|
241
|
+
raise
|
|
242
|
+
|
|
243
|
+
def start(self) -> None:
|
|
244
|
+
"""Start the persistent loop and wait for Pi's version and readiness checks."""
|
|
245
|
+
self._check_caller()
|
|
246
|
+
with self._lock:
|
|
247
|
+
if self._thread is not None or self._closed or self._closing:
|
|
248
|
+
raise PiProcessError("Clients are single-use; create a new client to restart Pi")
|
|
249
|
+
thread = threading.Thread(target=self._loop_main, name="pi-client-loop", daemon=True)
|
|
250
|
+
try:
|
|
251
|
+
thread.start()
|
|
252
|
+
except BaseException:
|
|
253
|
+
self._closed = True
|
|
254
|
+
self._closed_event.set()
|
|
255
|
+
raise
|
|
256
|
+
self._thread = thread
|
|
257
|
+
try:
|
|
258
|
+
self._ready.wait()
|
|
259
|
+
if self._loop_error is not None:
|
|
260
|
+
raise PiProcessError("Could not initialize the Pi event loop") from self._loop_error
|
|
261
|
+
self._call(self._client.start)
|
|
262
|
+
except BaseException:
|
|
263
|
+
self.close()
|
|
264
|
+
raise
|
|
265
|
+
|
|
266
|
+
def close(self) -> None:
|
|
267
|
+
"""Reap Pi, finish outstanding calls, and stop and join the background loop."""
|
|
268
|
+
self._check_caller()
|
|
269
|
+
with self._lock:
|
|
270
|
+
if self._closed:
|
|
271
|
+
return
|
|
272
|
+
other_closer = self._closing
|
|
273
|
+
self._closing = True
|
|
274
|
+
thread = self._thread
|
|
275
|
+
if other_closer:
|
|
276
|
+
self._closed_event.wait()
|
|
277
|
+
return
|
|
278
|
+
try:
|
|
279
|
+
if thread is not None:
|
|
280
|
+
self._ready.wait()
|
|
281
|
+
try:
|
|
282
|
+
# Interrupting shutdown must not cancel process reaping.
|
|
283
|
+
if self._loop is not None and not self._thread_done.is_set():
|
|
284
|
+
self._call(self._client.aclose, closing=True, cancel_on_interrupt=False)
|
|
285
|
+
finally:
|
|
286
|
+
if self._loop is not None and not self._loop.is_closed():
|
|
287
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
288
|
+
interrupted = False
|
|
289
|
+
while not self._thread_done.is_set():
|
|
290
|
+
try:
|
|
291
|
+
self._thread_done.wait()
|
|
292
|
+
except KeyboardInterrupt:
|
|
293
|
+
interrupted = True
|
|
294
|
+
thread.join()
|
|
295
|
+
if interrupted:
|
|
296
|
+
raise KeyboardInterrupt
|
|
297
|
+
finally:
|
|
298
|
+
with self._lock:
|
|
299
|
+
self._closed = True
|
|
300
|
+
self._closed_event.set()
|
|
301
|
+
|
|
302
|
+
def _close_context(self, close: Callable[[], Coroutine[Any, Any, None]]) -> None:
|
|
303
|
+
"""Close a nested context, or join client shutdown already doing its cleanup."""
|
|
304
|
+
try:
|
|
305
|
+
self._call(close, cancel_on_interrupt=False)
|
|
306
|
+
except PiProcessError:
|
|
307
|
+
with self._lock:
|
|
308
|
+
shutting_down = self._closing or self._closed
|
|
309
|
+
if not shutting_down:
|
|
310
|
+
raise
|
|
311
|
+
self.close()
|
|
312
|
+
|
|
313
|
+
def __enter__(self) -> Self:
|
|
314
|
+
self.start()
|
|
315
|
+
return self
|
|
316
|
+
|
|
317
|
+
def __exit__(
|
|
318
|
+
self,
|
|
319
|
+
exc_type: type[BaseException] | None,
|
|
320
|
+
exc: BaseException | None,
|
|
321
|
+
traceback: TracebackType | None,
|
|
322
|
+
) -> None:
|
|
323
|
+
self.close()
|
|
324
|
+
|
|
325
|
+
def request(
|
|
326
|
+
self, command_type: str, *, timeout: Timeout = DEFAULT_TIMEOUT, **fields: Any
|
|
327
|
+
) -> dict[str, Any]:
|
|
328
|
+
"""Return a checked raw response; omission and timeout rules match the async client."""
|
|
329
|
+
return self._call(lambda: self._client.request(command_type, timeout=timeout, **fields))
|
|
330
|
+
|
|
331
|
+
def prompt(
|
|
332
|
+
self,
|
|
333
|
+
message: str,
|
|
334
|
+
*,
|
|
335
|
+
images: list[ImageContent] | None = None,
|
|
336
|
+
streaming_behavior: str | None = None,
|
|
337
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
338
|
+
) -> AcceptanceReceipt:
|
|
339
|
+
"""Wait for acceptance only; handled commands may never start an agent run."""
|
|
340
|
+
return self._call(
|
|
341
|
+
lambda: self._client.prompt(
|
|
342
|
+
message, images=images, streaming_behavior=streaming_behavior, timeout=timeout
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def steer(
|
|
347
|
+
self,
|
|
348
|
+
message: str,
|
|
349
|
+
*,
|
|
350
|
+
images: list[ImageContent] | None = None,
|
|
351
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Queue input for Pi's next steering opportunity; acknowledgement is not consumption."""
|
|
354
|
+
self._call(lambda: self._client.steer(message, images=images, timeout=timeout))
|
|
355
|
+
|
|
356
|
+
def follow_up(
|
|
357
|
+
self,
|
|
358
|
+
message: str,
|
|
359
|
+
*,
|
|
360
|
+
images: list[ImageContent] | None = None,
|
|
361
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
362
|
+
) -> None:
|
|
363
|
+
"""Queue input after the current response; acknowledgement is not consumption."""
|
|
364
|
+
self._call(lambda: self._client.follow_up(message, images=images, timeout=timeout))
|
|
365
|
+
|
|
366
|
+
def abort(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
|
|
367
|
+
"""Abort current work using Pi semantics, without implicitly clearing queued input."""
|
|
368
|
+
self._call(lambda: self._client.abort(timeout=timeout))
|
|
369
|
+
|
|
370
|
+
def clear_queue(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> QueueState:
|
|
371
|
+
"""Clear queued input and return the removed steering and follow-up text."""
|
|
372
|
+
return self._call(lambda: self._client.clear_queue(timeout=timeout))
|
|
373
|
+
|
|
374
|
+
def new_session(
|
|
375
|
+
self, *, parent_session: str | None = None, timeout: Timeout = DEFAULT_TIMEOUT
|
|
376
|
+
) -> SessionChangeResult:
|
|
377
|
+
"""Start a new session; inspect cancelled for an extension veto."""
|
|
378
|
+
return self._call(
|
|
379
|
+
lambda: self._client.new_session(parent_session=parent_session, timeout=timeout)
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def get_state(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> SessionState:
|
|
383
|
+
"""Read authoritative Pi state and refresh the cached session identity."""
|
|
384
|
+
return self._call(lambda: self._client.get_state(timeout=timeout))
|
|
385
|
+
|
|
386
|
+
def set_model(
|
|
387
|
+
self, provider: str, model_id: str, *, timeout: Timeout = DEFAULT_TIMEOUT
|
|
388
|
+
) -> Model:
|
|
389
|
+
"""Select a provider/model and return its full metadata."""
|
|
390
|
+
return self._call(lambda: self._client.set_model(provider, model_id, timeout=timeout))
|
|
391
|
+
|
|
392
|
+
def cycle_model(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> ModelCycleResult | None:
|
|
393
|
+
"""Cycle models and return model, thinking level, and scope, or None."""
|
|
394
|
+
return self._call(lambda: self._client.cycle_model(timeout=timeout))
|
|
395
|
+
|
|
396
|
+
def get_available_models(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> list[Model]:
|
|
397
|
+
"""List the models Pi makes available with their provider metadata."""
|
|
398
|
+
return self._call(lambda: self._client.get_available_models(timeout=timeout))
|
|
399
|
+
|
|
400
|
+
def set_thinking_level(
|
|
401
|
+
self, level: ThinkingLevel, *, timeout: Timeout = DEFAULT_TIMEOUT
|
|
402
|
+
) -> None:
|
|
403
|
+
"""Request a thinking level for the current model."""
|
|
404
|
+
self._call(lambda: self._client.set_thinking_level(level, timeout=timeout))
|
|
405
|
+
|
|
406
|
+
def cycle_thinking_level(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> ThinkingLevel | None:
|
|
407
|
+
"""Cycle the current thinking level, or return None when unavailable."""
|
|
408
|
+
return self._call(lambda: self._client.cycle_thinking_level(timeout=timeout))
|
|
409
|
+
|
|
410
|
+
def get_available_thinking_levels(
|
|
411
|
+
self, *, timeout: Timeout = DEFAULT_TIMEOUT
|
|
412
|
+
) -> list[ThinkingLevel]:
|
|
413
|
+
"""List thinking levels available for the current model."""
|
|
414
|
+
return self._call(lambda: self._client.get_available_thinking_levels(timeout=timeout))
|
|
415
|
+
|
|
416
|
+
def set_steering_mode(self, mode: QueueMode, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
|
|
417
|
+
"""Choose all queued steering messages or one at a time."""
|
|
418
|
+
self._call(lambda: self._client.set_steering_mode(mode, timeout=timeout))
|
|
419
|
+
|
|
420
|
+
def set_follow_up_mode(self, mode: QueueMode, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
|
|
421
|
+
"""Choose all queued follow-up messages or one at a time."""
|
|
422
|
+
self._call(lambda: self._client.set_follow_up_mode(mode, timeout=timeout))
|
|
423
|
+
|
|
424
|
+
def compact(
|
|
425
|
+
self, *, custom_instructions: str | None = None, timeout: Timeout = DEFAULT_TIMEOUT
|
|
426
|
+
) -> CompactionResult:
|
|
427
|
+
"""Compact the conversation and return Pi's summary and token information."""
|
|
428
|
+
return self._call(
|
|
429
|
+
lambda: self._client.compact(custom_instructions=custom_instructions, timeout=timeout)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
def set_auto_compaction(self, enabled: bool, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
|
|
433
|
+
"""Enable or disable Pi's automatic context compaction."""
|
|
434
|
+
self._call(lambda: self._client.set_auto_compaction(enabled, timeout=timeout))
|
|
435
|
+
|
|
436
|
+
def set_auto_retry(self, enabled: bool, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
|
|
437
|
+
"""Enable or disable Pi's automatic retry policy."""
|
|
438
|
+
self._call(lambda: self._client.set_auto_retry(enabled, timeout=timeout))
|
|
439
|
+
|
|
440
|
+
def abort_retry(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
|
|
441
|
+
"""Abort Pi's active retry sequence."""
|
|
442
|
+
self._call(lambda: self._client.abort_retry(timeout=timeout))
|
|
443
|
+
|
|
444
|
+
def bash(
|
|
445
|
+
self,
|
|
446
|
+
command: str,
|
|
447
|
+
*,
|
|
448
|
+
exclude_from_context: bool | None = None,
|
|
449
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
450
|
+
) -> BashResult:
|
|
451
|
+
"""Run Pi's bash command; events() exposes output deltas while it executes."""
|
|
452
|
+
return self._call(
|
|
453
|
+
lambda: self._client.bash(
|
|
454
|
+
command, exclude_from_context=exclude_from_context, timeout=timeout
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def abort_bash(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
|
|
459
|
+
"""Abort Pi's active bash execution."""
|
|
460
|
+
self._call(lambda: self._client.abort_bash(timeout=timeout))
|
|
461
|
+
|
|
462
|
+
def get_session_stats(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> SessionStats:
|
|
463
|
+
"""Return Pi's current session message counts, token totals, and cost."""
|
|
464
|
+
return self._call(lambda: self._client.get_session_stats(timeout=timeout))
|
|
465
|
+
|
|
466
|
+
def export_html(
|
|
467
|
+
self, *, output_path: str | None = None, timeout: Timeout = DEFAULT_TIMEOUT
|
|
468
|
+
) -> str:
|
|
469
|
+
"""Export the current session and return the path written by Pi."""
|
|
470
|
+
return self._call(
|
|
471
|
+
lambda: self._client.export_html(output_path=output_path, timeout=timeout)
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def switch_session(
|
|
475
|
+
self, session_path: str, *, timeout: Timeout = DEFAULT_TIMEOUT
|
|
476
|
+
) -> SessionChangeResult:
|
|
477
|
+
"""Switch to a session path; inspect cancelled for an extension veto."""
|
|
478
|
+
return self._call(lambda: self._client.switch_session(session_path, timeout=timeout))
|
|
479
|
+
|
|
480
|
+
def fork(self, entry_id: str, *, timeout: Timeout = DEFAULT_TIMEOUT) -> ForkResult:
|
|
481
|
+
"""Fork at an eligible entry; a veto can omit the returned editable text."""
|
|
482
|
+
return self._call(lambda: self._client.fork(entry_id, timeout=timeout))
|
|
483
|
+
|
|
484
|
+
def clone(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> SessionChangeResult:
|
|
485
|
+
"""Clone the current session; inspect cancelled for an extension veto."""
|
|
486
|
+
return self._call(lambda: self._client.clone(timeout=timeout))
|
|
487
|
+
|
|
488
|
+
def get_fork_messages(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> list[ForkMessage]:
|
|
489
|
+
"""List eligible message entry IDs and text for choosing a fork point."""
|
|
490
|
+
return self._call(lambda: self._client.get_fork_messages(timeout=timeout))
|
|
491
|
+
|
|
492
|
+
def get_entries(
|
|
493
|
+
self, *, since: str | None = None, timeout: Timeout = DEFAULT_TIMEOUT
|
|
494
|
+
) -> EntriesResult:
|
|
495
|
+
"""Return saved entries after an optional entry ID and the current leaf ID."""
|
|
496
|
+
return self._call(lambda: self._client.get_entries(since=since, timeout=timeout))
|
|
497
|
+
|
|
498
|
+
def get_tree(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> TreeResult:
|
|
499
|
+
"""Return the recursive session tree and nullable selected leaf ID."""
|
|
500
|
+
return self._call(lambda: self._client.get_tree(timeout=timeout))
|
|
501
|
+
|
|
502
|
+
def get_last_assistant_text(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> str | None:
|
|
503
|
+
"""Query session history for usable assistant text, or None when absent."""
|
|
504
|
+
return self._call(lambda: self._client.get_last_assistant_text(timeout=timeout))
|
|
505
|
+
|
|
506
|
+
def set_session_name(self, name: str, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
|
|
507
|
+
"""Set the session name and refresh cached identity from Pi."""
|
|
508
|
+
self._call(lambda: self._client.set_session_name(name, timeout=timeout))
|
|
509
|
+
|
|
510
|
+
def get_messages(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> list[AgentMessage]:
|
|
511
|
+
"""Return Pi's current conversation messages."""
|
|
512
|
+
return self._call(lambda: self._client.get_messages(timeout=timeout))
|
|
513
|
+
|
|
514
|
+
def get_commands(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> list[SlashCommand]:
|
|
515
|
+
"""List extension, prompt-template, and skill commands with source information."""
|
|
516
|
+
return self._call(lambda: self._client.get_commands(timeout=timeout))
|
|
517
|
+
|
|
518
|
+
def events(self) -> SyncEventSubscription:
|
|
519
|
+
"""Enter before submitting work, then iterate future events on the calling thread."""
|
|
520
|
+
return SyncEventSubscription(self)
|
|
521
|
+
|
|
522
|
+
def stream(
|
|
523
|
+
self,
|
|
524
|
+
message: str,
|
|
525
|
+
*,
|
|
526
|
+
images: list[ImageContent] | None = None,
|
|
527
|
+
timeout: float | None = None,
|
|
528
|
+
command_timeout: Timeout = DEFAULT_TIMEOUT,
|
|
529
|
+
) -> SyncRunStream:
|
|
530
|
+
"""Enter a context and iterate events; leaving early aborts owned work."""
|
|
531
|
+
return SyncRunStream(self, message, images, timeout, command_timeout)
|
|
532
|
+
|
|
533
|
+
def run(
|
|
534
|
+
self,
|
|
535
|
+
message: str,
|
|
536
|
+
*,
|
|
537
|
+
images: list[ImageContent] | None = None,
|
|
538
|
+
timeout: float | None = None,
|
|
539
|
+
command_timeout: Timeout = DEFAULT_TIMEOUT,
|
|
540
|
+
) -> RunResult:
|
|
541
|
+
"""Wait through retries and queued continuations until agent_settled."""
|
|
542
|
+
return self._call(
|
|
543
|
+
lambda: self._client.run(
|
|
544
|
+
message, images=images, timeout=timeout, command_timeout=command_timeout
|
|
545
|
+
)
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
class SyncEventSubscription:
|
|
550
|
+
"""A blocking context and iterator over a bounded async subscription."""
|
|
551
|
+
|
|
552
|
+
def __init__(self, client: PiClient) -> None:
|
|
553
|
+
self._client = client
|
|
554
|
+
self._subscription: EventSubscription | None = None
|
|
555
|
+
self._entered = False
|
|
556
|
+
self._closed = False
|
|
557
|
+
|
|
558
|
+
def __enter__(self) -> Self:
|
|
559
|
+
async def enter() -> None:
|
|
560
|
+
if self._entered or self._closed:
|
|
561
|
+
raise RuntimeError("Event subscriptions are single-use")
|
|
562
|
+
self._entered = True
|
|
563
|
+
self._subscription = self._client._client.events()
|
|
564
|
+
await self._subscription.__aenter__()
|
|
565
|
+
|
|
566
|
+
self._client._call(enter)
|
|
567
|
+
return self
|
|
568
|
+
|
|
569
|
+
def __exit__(
|
|
570
|
+
self,
|
|
571
|
+
exc_type: type[BaseException] | None,
|
|
572
|
+
exc: BaseException | None,
|
|
573
|
+
traceback: TracebackType | None,
|
|
574
|
+
) -> None:
|
|
575
|
+
self.close()
|
|
576
|
+
|
|
577
|
+
def __iter__(self) -> Self:
|
|
578
|
+
return self
|
|
579
|
+
|
|
580
|
+
def __next__(self) -> Event:
|
|
581
|
+
async def next_event() -> Event:
|
|
582
|
+
if self._subscription is None:
|
|
583
|
+
raise RuntimeError("Enter the event subscription context before iterating")
|
|
584
|
+
return await self._subscription.__anext__()
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
return self._client._call(next_event)
|
|
588
|
+
except StopAsyncIteration:
|
|
589
|
+
raise StopIteration from None
|
|
590
|
+
|
|
591
|
+
def close(self) -> None:
|
|
592
|
+
self._closed = True
|
|
593
|
+
if self._subscription is not None:
|
|
594
|
+
self._client._close_context(self._subscription.aclose)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
class SyncRunStream:
|
|
598
|
+
"""An owned run; iterate once, then result(), or use result() alone to drain."""
|
|
599
|
+
|
|
600
|
+
def __init__(
|
|
601
|
+
self,
|
|
602
|
+
client: PiClient,
|
|
603
|
+
message: str,
|
|
604
|
+
images: list[ImageContent] | None,
|
|
605
|
+
timeout: float | None,
|
|
606
|
+
command_timeout: Timeout,
|
|
607
|
+
) -> None:
|
|
608
|
+
self._client = client
|
|
609
|
+
self._message = message
|
|
610
|
+
self._images = images
|
|
611
|
+
self._timeout = timeout
|
|
612
|
+
self._command_timeout = command_timeout
|
|
613
|
+
self._stream: RunStream | None = None
|
|
614
|
+
self._entered = False
|
|
615
|
+
self._closed = False
|
|
616
|
+
|
|
617
|
+
def __enter__(self) -> Self:
|
|
618
|
+
async def enter() -> None:
|
|
619
|
+
if self._entered or self._closed:
|
|
620
|
+
raise RuntimeError("Run streams are single-use")
|
|
621
|
+
self._entered = True
|
|
622
|
+
self._stream = self._client._client.stream(
|
|
623
|
+
self._message,
|
|
624
|
+
images=self._images,
|
|
625
|
+
timeout=self._timeout,
|
|
626
|
+
command_timeout=self._command_timeout,
|
|
627
|
+
)
|
|
628
|
+
await self._stream.__aenter__()
|
|
629
|
+
|
|
630
|
+
self._client._call(enter)
|
|
631
|
+
return self
|
|
632
|
+
|
|
633
|
+
def __exit__(
|
|
634
|
+
self,
|
|
635
|
+
exc_type: type[BaseException] | None,
|
|
636
|
+
exc: BaseException | None,
|
|
637
|
+
traceback: TracebackType | None,
|
|
638
|
+
) -> None:
|
|
639
|
+
self.close()
|
|
640
|
+
|
|
641
|
+
def __iter__(self) -> Self:
|
|
642
|
+
# Iterator wrappers may call iter() repeatedly before requesting an item.
|
|
643
|
+
# The async next/result methods enforce actual competing consumption.
|
|
644
|
+
return self
|
|
645
|
+
|
|
646
|
+
def _call_owned(self, operation: Callable[[], Coroutine[Any, Any, T]]) -> T:
|
|
647
|
+
async def invoke() -> T:
|
|
648
|
+
try:
|
|
649
|
+
return await operation()
|
|
650
|
+
except asyncio.CancelledError:
|
|
651
|
+
if self._stream is not None:
|
|
652
|
+
await self._stream.aclose()
|
|
653
|
+
raise
|
|
654
|
+
|
|
655
|
+
try:
|
|
656
|
+
return self._client._call(invoke)
|
|
657
|
+
except KeyboardInterrupt:
|
|
658
|
+
# Cancellation before invoke() starts cannot run its exception handler.
|
|
659
|
+
self.close()
|
|
660
|
+
raise
|
|
661
|
+
|
|
662
|
+
def __next__(self) -> Event:
|
|
663
|
+
async def next_event() -> Event:
|
|
664
|
+
if self._stream is None:
|
|
665
|
+
raise RuntimeError("Enter the stream context before iterating")
|
|
666
|
+
return await self._stream.__anext__()
|
|
667
|
+
|
|
668
|
+
try:
|
|
669
|
+
return self._call_owned(next_event)
|
|
670
|
+
except StopAsyncIteration:
|
|
671
|
+
raise StopIteration from None
|
|
672
|
+
|
|
673
|
+
def result(self) -> RunResult:
|
|
674
|
+
async def result() -> RunResult:
|
|
675
|
+
if self._stream is None:
|
|
676
|
+
raise RuntimeError("Enter the stream context before requesting its result")
|
|
677
|
+
return await self._stream.result()
|
|
678
|
+
|
|
679
|
+
return self._call_owned(result)
|
|
680
|
+
|
|
681
|
+
def close(self) -> None:
|
|
682
|
+
self._closed = True
|
|
683
|
+
if self._stream is not None:
|
|
684
|
+
self._client._close_context(self._stream.aclose)
|