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/client.py ADDED
@@ -0,0 +1,796 @@
1
+ """Async lifecycle, explicit RPC methods, and extension interaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextvars
7
+ import inspect
8
+ import json
9
+ import math
10
+ import os
11
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
12
+ from enum import Enum
13
+ from types import TracebackType
14
+ from typing import TYPE_CHECKING, Any, Self, cast
15
+
16
+ from ._events import EventSubscription
17
+ from ._launch import check_version, executable_argv, validate_extra_args
18
+ from ._transport import Transport
19
+ from .errors import PiBusyError, PiProcessError, PiProtocolError, PiTimeoutError, PiUIHandlerError
20
+ from .types import (
21
+ AcceptanceReceipt,
22
+ AgentMessage,
23
+ BashResult,
24
+ CompactionResult,
25
+ EntriesResult,
26
+ Event,
27
+ ExtensionUIRequest,
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
+ if TYPE_CHECKING:
47
+ from ._runs import RunStream
48
+
49
+ UIValue = str | bool | None
50
+ UIHandler = Callable[[ExtensionUIRequest], UIValue | Awaitable[UIValue]]
51
+ IN_SYNC_UI: contextvars.ContextVar[bool] = contextvars.ContextVar("pi_sync_ui", default=False)
52
+
53
+
54
+ class _DefaultTimeout(Enum):
55
+ DEFAULT = "configured command timeout"
56
+
57
+
58
+ DEFAULT_TIMEOUT = _DefaultTimeout.DEFAULT
59
+ Timeout = float | None | _DefaultTimeout
60
+ _LONG_COMMANDS = {
61
+ "bash",
62
+ "compact",
63
+ "new_session",
64
+ "switch_session",
65
+ "fork",
66
+ "clone",
67
+ "export_html",
68
+ }
69
+ _SESSION_COMMANDS = {"new_session", "switch_session", "fork", "clone", "set_session_name"}
70
+ _DURING_RUN = {
71
+ "steer",
72
+ "follow_up",
73
+ "abort",
74
+ "clear_queue",
75
+ "abort_retry",
76
+ "abort_bash",
77
+ "get_state",
78
+ "get_available_models",
79
+ "get_available_thinking_levels",
80
+ "get_session_stats",
81
+ "get_fork_messages",
82
+ "get_entries",
83
+ "get_tree",
84
+ "get_last_assistant_text",
85
+ "get_messages",
86
+ "get_commands",
87
+ }
88
+ _DIALOGS = {"select", "confirm", "input", "editor"}
89
+
90
+
91
+ class AsyncPiClient:
92
+ """Own a Pi RPC process while preserving its normal configuration.
93
+
94
+ Use ``async with`` or call start()/aclose(). One high-level run may own the
95
+ conversation; steering, follow-ups, abort and reads remain available.
96
+ ``env`` overrides inherited variables; None removes one. Set inherit_env
97
+ false for an entirely explicit child environment. No network checks or
98
+ credential reads are performed by the client.
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ *,
104
+ executable: str | os.PathLike[str] | Sequence[str] = "pi",
105
+ cwd: str | os.PathLike[str] | None = None,
106
+ provider: str | None = None,
107
+ model: str | None = None,
108
+ env: Mapping[str, str | None] | None = None,
109
+ inherit_env: bool = True,
110
+ session: str | None = None,
111
+ session_dir: str | None = None,
112
+ session_id: str | None = None,
113
+ continue_session: bool = False,
114
+ no_session: bool = False,
115
+ fork_session: str | None = None,
116
+ extra_args: Sequence[str] = (),
117
+ ui_handler: UIHandler | None = None,
118
+ limits: Limits | None = None,
119
+ strict_version: bool = False,
120
+ allow_unknown_version: bool = False,
121
+ ) -> None:
122
+ if sum((session is not None, continue_session, fork_session is not None)) > 1:
123
+ raise ValueError("Choose only one of session, continue_session, or fork_session")
124
+ if no_session and (session or continue_session or fork_session or session_dir):
125
+ raise ValueError("no_session conflicts with persisted session options")
126
+ if session_id and (session or continue_session):
127
+ raise ValueError("session_id conflicts with opening an existing session")
128
+ validate_extra_args(extra_args)
129
+ self._executable = executable
130
+ self._cwd = os.fspath(cwd) if cwd is not None else None
131
+ self._env_overrides = dict(env or {})
132
+ self._inherit_env = inherit_env
133
+ self._args = ["--mode", "rpc"]
134
+ for flag, value in (
135
+ ("--provider", provider),
136
+ ("--model", model),
137
+ ("--session", session),
138
+ ("--session-dir", session_dir),
139
+ ("--session-id", session_id),
140
+ ("--fork", fork_session),
141
+ ):
142
+ if value is not None:
143
+ self._args.extend((flag, value))
144
+ if no_session:
145
+ self._args.append("--no-session")
146
+ if continue_session:
147
+ self._args.append("--continue")
148
+ self._args.extend(extra_args)
149
+ self.limits = limits or Limits()
150
+ self._ui_handler = ui_handler
151
+ self._strict_version = strict_version
152
+ self._allow_unknown_version = allow_unknown_version
153
+ self._transport: Transport | None = None
154
+ self._subscriptions: set[EventSubscription] = set()
155
+ self._ui_tasks: set[asyncio.Task[None]] = set()
156
+ self._ui_bytes = 0
157
+ self._ui_error: PiUIHandlerError | None = None
158
+ self._owner: RunStream | None = None
159
+ self._unowned_submission = False
160
+ self._session = SessionInfo()
161
+ self._loop: asyncio.AbstractEventLoop | None = None
162
+ self._closed = False
163
+ self._starting = False
164
+ self._startup_task: asyncio.Task[Any] | None = None
165
+ self._startup_done = asyncio.Event()
166
+ self._close_task: asyncio.Task[None] | None = None
167
+ self._close_initiator: asyncio.Task[Any] | None = None
168
+ self.pi_version: str | None = None
169
+ self.compatibility: str = "unchecked"
170
+
171
+ @property
172
+ def running(self) -> bool:
173
+ return self._transport is not None and self._transport.running and not self._starting
174
+
175
+ @property
176
+ def busy(self) -> bool:
177
+ """Whether a run()/stream() owns the conversation, not Pi's global idle state."""
178
+ return self._owner is not None
179
+
180
+ @property
181
+ def session(self) -> SessionInfo:
182
+ return self._session
183
+
184
+ @property
185
+ def stderr_tail(self) -> str:
186
+ """Explicit diagnostic access; retention is disabled by default."""
187
+ return self._transport.stderr_tail if self._transport is not None else ""
188
+
189
+ def _check_loop(self) -> None:
190
+ if self._loop is not None and asyncio.get_running_loop() is not self._loop:
191
+ raise RuntimeError("Use the client only from the event loop that started it")
192
+
193
+ async def __aenter__(self) -> Self:
194
+ await self.start()
195
+ return self
196
+
197
+ async def __aexit__(
198
+ self,
199
+ exc_type: type[BaseException] | None,
200
+ exc: BaseException | None,
201
+ traceback: TracebackType | None,
202
+ ) -> None:
203
+ await self.aclose()
204
+
205
+ async def start(self) -> None:
206
+ """Check the selected version and wait for a real get_state response."""
207
+ if self._closed or self._starting or self._transport is not None:
208
+ raise PiProcessError("Clients are single-use; create a new client to restart Pi")
209
+ self._loop = asyncio.get_running_loop()
210
+ self._starting = True
211
+ self._startup_task = asyncio.current_task()
212
+ self._startup_done.clear()
213
+ child_env = dict(os.environ) if self._inherit_env else {}
214
+ for name, value in self._env_overrides.items():
215
+ if value is None:
216
+ child_env.pop(name, None)
217
+ else:
218
+ child_env[name] = value
219
+ try:
220
+ async with asyncio.timeout(self.limits.startup_timeout):
221
+ argv = executable_argv(self._executable, child_env)
222
+ self.pi_version, self.compatibility = await check_version(
223
+ argv,
224
+ cwd=self._cwd,
225
+ env=child_env,
226
+ timeout=self.limits.startup_timeout,
227
+ allow_unknown=self._allow_unknown_version,
228
+ strict=self._strict_version,
229
+ )
230
+ if self._closed:
231
+ raise PiProcessError("Client was closed during startup")
232
+ self._transport = Transport(
233
+ limits=self.limits,
234
+ on_event=self._on_event,
235
+ on_failure=self._on_failure,
236
+ )
237
+ await self._transport.start([*argv, *self._args], cwd=self._cwd, env=child_env)
238
+ response = await self._transport.request(
239
+ "get_state", timeout=self.limits.startup_timeout
240
+ )
241
+ self._update_session(self._data(response))
242
+ except TimeoutError as exc:
243
+ if not self._closed:
244
+ await self.aclose()
245
+ raise PiTimeoutError("Pi startup timed out", uncertain=False) from exc
246
+ except BaseException:
247
+ if not self._closed:
248
+ await self.aclose()
249
+ raise
250
+ finally:
251
+ self._starting = False
252
+ self._startup_task = None
253
+ self._startup_done.set()
254
+
255
+ async def aclose(self) -> None:
256
+ """Wake all operations and reap the child; repeated calls are safe."""
257
+ self._check_loop()
258
+ if self._close_task is None:
259
+ self._closed = True
260
+ self._close_initiator = asyncio.current_task()
261
+ self._close_task = asyncio.create_task(self._finish_close(), name="pi-client-close")
262
+ await asyncio.shield(self._close_task)
263
+
264
+ async def _finish_close(self) -> None:
265
+ startup = self._startup_task
266
+ if startup is not None and startup is not self._close_initiator and not startup.done():
267
+ startup.cancel()
268
+ # start() may be part of a larger application task whose finally
269
+ # also closes us. Wait for startup cleanup, not that whole task.
270
+ await self._startup_done.wait()
271
+ if self._transport is not None:
272
+ await self._transport.aclose()
273
+ tasks = tuple(task for task in self._ui_tasks if task is not self._close_initiator)
274
+ for task in tasks:
275
+ task.cancel()
276
+ if tasks:
277
+ await asyncio.gather(*tasks, return_exceptions=True)
278
+ self._close_initiator = None
279
+
280
+ def events(self) -> EventSubscription:
281
+ """Subscribe on context entry to future events, including extension UI/errors."""
282
+ return EventSubscription(self.limits, self._register, self._subscriptions.discard)
283
+
284
+ def _register(self, subscription: EventSubscription) -> None:
285
+ self._check_loop()
286
+ if not self.running:
287
+ raise PiProcessError("Start Pi before subscribing to events")
288
+ self._subscriptions.add(subscription)
289
+
290
+ def _on_event(self, raw: dict[str, Any]) -> None:
291
+ event = Event(raw)
292
+ # Validate a recognized text update without restricting future variants.
293
+ _ = event.text_delta
294
+ size = len(json.dumps(raw, ensure_ascii=False).encode("utf-8"))
295
+ for subscription in tuple(self._subscriptions):
296
+ subscription._put(event, size)
297
+ if self._owner is not None:
298
+ self._owner._on_event(event, size)
299
+ if event.type == "session_info_changed":
300
+ if "name" in raw and not isinstance(raw["name"], str):
301
+ raise PiProtocolError("session_info_changed requires a string name when present")
302
+ self._session = SessionInfo(
303
+ self._session.session_id, self._session.session_file, raw.get("name")
304
+ )
305
+ if event.type == "extension_ui_request":
306
+ if not isinstance(raw.get("method"), str) or not isinstance(raw.get("id"), str):
307
+ raise PiProtocolError("Extension UI requests require string id and method")
308
+ if self._ui_handler is None and raw["method"] not in _DIALOGS:
309
+ return
310
+ if (
311
+ len(self._ui_tasks) >= self.limits.event_queue_size
312
+ or self._ui_bytes + size > self.limits.event_queue_bytes
313
+ ):
314
+ assert self._transport is not None
315
+ self._transport._fail(
316
+ PiUIHandlerError(
317
+ "Outstanding extension UI requests exceeded buffer limits; Pi is closed"
318
+ )
319
+ )
320
+ return
321
+ task = asyncio.create_task(self._handle_ui(raw), name="pi-extension-ui")
322
+ self._ui_tasks.add(task)
323
+ self._ui_bytes += size
324
+ task.add_done_callback(lambda done: self._ui_finished(done, size))
325
+
326
+ def _ui_finished(self, task: asyncio.Task[None], size: int) -> None:
327
+ self._ui_tasks.discard(task)
328
+ self._ui_bytes -= size
329
+ if not task.cancelled():
330
+ task.exception()
331
+
332
+ def _on_failure(self, error: Exception) -> None:
333
+ self._session = SessionInfo()
334
+ for task in tuple(self._ui_tasks):
335
+ if task is not asyncio.current_task() and task is not self._close_initiator:
336
+ task.cancel()
337
+ for subscription in tuple(self._subscriptions):
338
+ subscription._finish(error)
339
+ if self._owner is not None:
340
+ self._owner._fail(error)
341
+
342
+ async def _handle_ui(self, raw: dict[str, Any]) -> None:
343
+ method, request_id = raw.get("method"), raw.get("id")
344
+ dialog = isinstance(method, str) and method in _DIALOGS
345
+ try:
346
+ if not isinstance(method, str) or not isinstance(request_id, str):
347
+ raise PiProtocolError("Malformed extension UI request")
348
+ response: dict[str, Any] = {"type": "extension_ui_response", "id": request_id}
349
+ answer: UIValue = None
350
+ if self._ui_handler is not None:
351
+ timeout_ms = raw.get("timeout")
352
+ deadline = timeout_ms / 1000 if isinstance(timeout_ms, (int, float)) else None
353
+ async with asyncio.timeout(deadline):
354
+ if inspect.iscoroutinefunction(self._ui_handler):
355
+ answer = await self._ui_handler(cast(ExtensionUIRequest, raw))
356
+ else:
357
+ answer_or_awaitable = await asyncio.to_thread(self._call_sync_ui, raw)
358
+ answer = (
359
+ await answer_or_awaitable
360
+ if inspect.isawaitable(answer_or_awaitable)
361
+ else answer_or_awaitable
362
+ )
363
+ if dialog:
364
+ if answer is None:
365
+ response["cancelled"] = True
366
+ elif method == "confirm" and isinstance(answer, bool):
367
+ response["confirmed"] = answer
368
+ elif method != "confirm" and isinstance(answer, str):
369
+ response["value"] = answer
370
+ else:
371
+ raise TypeError(
372
+ "UI handler must return bool for confirm, str for other dialogs, or None"
373
+ )
374
+ if self._transport is not None and self._transport.running:
375
+ await self._transport.send_ui(response)
376
+ except asyncio.CancelledError:
377
+ raise
378
+ except Exception as exc:
379
+ if (
380
+ dialog
381
+ and isinstance(request_id, str)
382
+ and self._transport is not None
383
+ and self._transport.running
384
+ ):
385
+ try:
386
+ await self._transport.send_ui(
387
+ {"type": "extension_ui_response", "id": request_id, "cancelled": True}
388
+ )
389
+ except Exception:
390
+ pass
391
+ error = PiUIHandlerError("Extension UI handler failed; inspect __cause__ for details")
392
+ error.__cause__ = exc
393
+ self._ui_error = error
394
+ for subscription in tuple(self._subscriptions):
395
+ subscription._finish(error)
396
+ if self._owner is not None:
397
+ self._owner._fail(error)
398
+
399
+ def _call_sync_ui(self, raw: dict[str, Any]) -> UIValue | Awaitable[UIValue]:
400
+ assert self._ui_handler is not None
401
+ token = IN_SYNC_UI.set(True)
402
+ try:
403
+ return self._ui_handler(cast(ExtensionUIRequest, raw))
404
+ finally:
405
+ IN_SYNC_UI.reset(token)
406
+
407
+ def _update_session(self, data: dict[str, Any]) -> None:
408
+ if not isinstance(data.get("sessionId"), str):
409
+ raise PiProtocolError("get_state did not contain a string sessionId")
410
+ for key in ("isStreaming", "isCompacting"):
411
+ if not isinstance(data.get(key), bool):
412
+ raise PiProtocolError(f"get_state contained invalid {key}")
413
+ count = data.get("pendingMessageCount")
414
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0:
415
+ raise PiProtocolError("get_state contained invalid pendingMessageCount")
416
+ for key in ("sessionFile", "sessionName"):
417
+ if key in data and not isinstance(data[key], str):
418
+ raise PiProtocolError(f"get_state contained invalid {key}")
419
+ self._session = SessionInfo(
420
+ data["sessionId"], data.get("sessionFile"), data.get("sessionName")
421
+ )
422
+
423
+ @staticmethod
424
+ def _data(response: dict[str, Any]) -> dict[str, Any]:
425
+ value = response.get("data")
426
+ if not isinstance(value, dict):
427
+ raise PiProtocolError("Pi response requires object data")
428
+ return value
429
+
430
+ async def request(
431
+ self, command_type: str, *, timeout: Timeout = DEFAULT_TIMEOUT, **fields: Any
432
+ ) -> dict[str, Any]:
433
+ """Send a checked raw command, assigning its ID and enforcing run ownership.
434
+
435
+ A timeout after submission has an uncertain outcome. Commands are never
436
+ replayed. None disables the response deadline; omitted uses configured
437
+ defaults (long commands have no deadline). Cancellation abandons the
438
+ response wait; use abort() to stop low-level submitted work explicitly.
439
+ """
440
+ return await self._request(command_type, fields, timeout=timeout)
441
+
442
+ async def _request(
443
+ self,
444
+ command: str,
445
+ fields: dict[str, Any] | None = None,
446
+ *,
447
+ timeout: Timeout = DEFAULT_TIMEOUT,
448
+ owner: RunStream | None = None,
449
+ ) -> dict[str, Any]:
450
+ self._check_loop()
451
+ if not self.running or self._transport is None:
452
+ raise PiProcessError("Pi is not running; use the client context or start() first")
453
+ if self._owner is not None and owner is not self._owner and command not in _DURING_RUN:
454
+ if not (
455
+ command == "prompt"
456
+ and (fields or {}).get("streamingBehavior") in {"steer", "followUp"}
457
+ ):
458
+ raise PiBusyError("Command conflicts with the active owned run")
459
+ deadline = (
460
+ (None if command in _LONG_COMMANDS else self.limits.command_timeout)
461
+ if timeout is DEFAULT_TIMEOUT
462
+ else timeout
463
+ )
464
+ if deadline is not None and (
465
+ not isinstance(deadline, (int, float))
466
+ or isinstance(deadline, bool)
467
+ or not math.isfinite(deadline)
468
+ or deadline <= 0
469
+ ):
470
+ raise ValueError("timeout must be a positive finite number or None")
471
+ if self._owner is None and command in {"prompt", "steer", "follow_up"}:
472
+ # Set before awaiting: input handlers can start work long after acknowledgement.
473
+ # Even rejection/cancellation cannot prove arbitrary extension preflight is idle.
474
+ self._unowned_submission = True
475
+ try:
476
+ response = await self._transport.request(command, fields, timeout=deadline)
477
+ if self._ui_error is not None:
478
+ error, self._ui_error = self._ui_error, None
479
+ raise error
480
+ if command == "get_state":
481
+ self._update_session(self._data(response))
482
+ elif command in _SESSION_COMMANDS:
483
+ self._session = SessionInfo()
484
+ state = await self._transport.request(
485
+ "get_state", timeout=self.limits.command_timeout
486
+ )
487
+ self._update_session(self._data(state))
488
+ return response
489
+ except (PiTimeoutError, asyncio.CancelledError):
490
+ if command in _SESSION_COMMANDS:
491
+ self._session = SessionInfo()
492
+ raise
493
+
494
+ async def _object(
495
+ self,
496
+ command: str,
497
+ fields: dict[str, Any] | None = None,
498
+ *,
499
+ timeout: Timeout = DEFAULT_TIMEOUT,
500
+ ) -> dict[str, Any]:
501
+ return self._data(await self._request(command, fields, timeout=timeout))
502
+
503
+ async def _list(
504
+ self, command: str, key: str, *, timeout: Timeout = DEFAULT_TIMEOUT
505
+ ) -> list[Any]:
506
+ value = (await self._object(command, timeout=timeout)).get(key)
507
+ if not isinstance(value, list):
508
+ raise PiProtocolError(f"{command} response requires a list in {key}")
509
+ return value
510
+
511
+ async def prompt(
512
+ self,
513
+ message: str,
514
+ *,
515
+ images: list[ImageContent] | None = None,
516
+ streaming_behavior: str | None = None,
517
+ timeout: Timeout = DEFAULT_TIMEOUT,
518
+ ) -> AcceptanceReceipt:
519
+ """Wait for acceptance only; handled commands may never start an agent run."""
520
+ fields: dict[str, Any] = {"message": message}
521
+ if images is not None:
522
+ fields["images"] = images
523
+ if streaming_behavior is not None:
524
+ if streaming_behavior not in {"steer", "followUp"}:
525
+ raise ValueError("streaming_behavior must be steer or followUp")
526
+ fields["streamingBehavior"] = streaming_behavior
527
+ return cast(AcceptanceReceipt, await self._request("prompt", fields, timeout=timeout))
528
+
529
+ async def steer(
530
+ self,
531
+ message: str,
532
+ *,
533
+ images: list[ImageContent] | None = None,
534
+ timeout: Timeout = DEFAULT_TIMEOUT,
535
+ ) -> None:
536
+ """Queue input for the next steering opportunity."""
537
+ await self._request(
538
+ "steer",
539
+ {"message": message, **({"images": images} if images is not None else {})},
540
+ timeout=timeout,
541
+ )
542
+
543
+ async def follow_up(
544
+ self,
545
+ message: str,
546
+ *,
547
+ images: list[ImageContent] | None = None,
548
+ timeout: Timeout = DEFAULT_TIMEOUT,
549
+ ) -> None:
550
+ """Queue input after the current response."""
551
+ await self._request(
552
+ "follow_up",
553
+ {"message": message, **({"images": images} if images is not None else {})},
554
+ timeout=timeout,
555
+ )
556
+
557
+ async def abort(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
558
+ """Abort current work using Pi semantics; queued work is not cleared implicitly."""
559
+ await self._request("abort", timeout=timeout)
560
+
561
+ async def clear_queue(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> QueueState:
562
+ """Clear queued input and return the removed steering and follow-up text."""
563
+ return cast(QueueState, await self._object("clear_queue", timeout=timeout))
564
+
565
+ async def new_session(
566
+ self, *, parent_session: str | None = None, timeout: Timeout = DEFAULT_TIMEOUT
567
+ ) -> SessionChangeResult:
568
+ """Start a new session; inspect cancelled for an extension veto."""
569
+ return cast(
570
+ SessionChangeResult,
571
+ await self._object(
572
+ "new_session",
573
+ {"parentSession": parent_session} if parent_session is not None else {},
574
+ timeout=timeout,
575
+ ),
576
+ )
577
+
578
+ async def get_state(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> SessionState:
579
+ """Read authoritative Pi state and refresh the cached session identity."""
580
+ return cast(SessionState, await self._object("get_state", timeout=timeout))
581
+
582
+ async def set_model(
583
+ self, provider: str, model_id: str, *, timeout: Timeout = DEFAULT_TIMEOUT
584
+ ) -> Model:
585
+ """Select a provider/model and return its full metadata."""
586
+ return cast(
587
+ Model,
588
+ await self._object(
589
+ "set_model", {"provider": provider, "modelId": model_id}, timeout=timeout
590
+ ),
591
+ )
592
+
593
+ async def cycle_model(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> ModelCycleResult | None:
594
+ """Cycle models and return model, thinking level, and scope, or None."""
595
+ response = await self._request("cycle_model", timeout=timeout)
596
+ return (
597
+ None if response.get("data") is None else cast(ModelCycleResult, self._data(response))
598
+ )
599
+
600
+ async def get_available_models(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> list[Model]:
601
+ """List the models Pi makes available with their provider metadata."""
602
+ return await self._list("get_available_models", "models", timeout=timeout)
603
+
604
+ async def set_thinking_level(
605
+ self, level: ThinkingLevel, *, timeout: Timeout = DEFAULT_TIMEOUT
606
+ ) -> None:
607
+ """Request a thinking level for the current model."""
608
+ await self._request("set_thinking_level", {"level": level}, timeout=timeout)
609
+
610
+ async def cycle_thinking_level(
611
+ self, *, timeout: Timeout = DEFAULT_TIMEOUT
612
+ ) -> ThinkingLevel | None:
613
+ """Cycle the current thinking level, or return None when unavailable."""
614
+ response = await self._request("cycle_thinking_level", timeout=timeout)
615
+ if response.get("data") is None:
616
+ return None
617
+ value = self._data(response).get("level")
618
+ if not isinstance(value, str):
619
+ raise PiProtocolError("cycle_thinking_level requires a string level")
620
+ return cast(ThinkingLevel, value)
621
+
622
+ async def get_available_thinking_levels(
623
+ self, *, timeout: Timeout = DEFAULT_TIMEOUT
624
+ ) -> list[ThinkingLevel]:
625
+ """List thinking levels available for the current model."""
626
+ return await self._list("get_available_thinking_levels", "levels", timeout=timeout)
627
+
628
+ async def set_steering_mode(
629
+ self, mode: QueueMode, *, timeout: Timeout = DEFAULT_TIMEOUT
630
+ ) -> None:
631
+ """Choose all queued steering messages or one at a time."""
632
+ await self._request("set_steering_mode", {"mode": mode}, timeout=timeout)
633
+
634
+ async def set_follow_up_mode(
635
+ self, mode: QueueMode, *, timeout: Timeout = DEFAULT_TIMEOUT
636
+ ) -> None:
637
+ """Choose all queued follow-up messages or one at a time."""
638
+ await self._request("set_follow_up_mode", {"mode": mode}, timeout=timeout)
639
+
640
+ async def compact(
641
+ self, *, custom_instructions: str | None = None, timeout: Timeout = DEFAULT_TIMEOUT
642
+ ) -> CompactionResult:
643
+ """Compact the conversation and return Pi's summary and token information."""
644
+ return cast(
645
+ CompactionResult,
646
+ await self._object(
647
+ "compact",
648
+ {"customInstructions": custom_instructions}
649
+ if custom_instructions is not None
650
+ else {},
651
+ timeout=timeout,
652
+ ),
653
+ )
654
+
655
+ async def set_auto_compaction(
656
+ self, enabled: bool, *, timeout: Timeout = DEFAULT_TIMEOUT
657
+ ) -> None:
658
+ """Enable or disable Pi's automatic context compaction."""
659
+ await self._request("set_auto_compaction", {"enabled": enabled}, timeout=timeout)
660
+
661
+ async def set_auto_retry(self, enabled: bool, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
662
+ """Enable or disable Pi's automatic retry policy."""
663
+ await self._request("set_auto_retry", {"enabled": enabled}, timeout=timeout)
664
+
665
+ async def abort_retry(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
666
+ """Abort Pi's active retry sequence."""
667
+ await self._request("abort_retry", timeout=timeout)
668
+
669
+ async def bash(
670
+ self,
671
+ command: str,
672
+ *,
673
+ exclude_from_context: bool | None = None,
674
+ timeout: Timeout = DEFAULT_TIMEOUT,
675
+ ) -> BashResult:
676
+ """Run Pi's bash command; output deltas are available through events()."""
677
+ return cast(
678
+ BashResult,
679
+ await self._object(
680
+ "bash",
681
+ {
682
+ "command": command,
683
+ **(
684
+ {"excludeFromContext": exclude_from_context}
685
+ if exclude_from_context is not None
686
+ else {}
687
+ ),
688
+ },
689
+ timeout=timeout,
690
+ ),
691
+ )
692
+
693
+ async def abort_bash(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
694
+ """Abort Pi's active bash execution."""
695
+ await self._request("abort_bash", timeout=timeout)
696
+
697
+ async def get_session_stats(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> SessionStats:
698
+ """Return Pi's current session message counts, token totals, and cost."""
699
+ return cast(SessionStats, await self._object("get_session_stats", timeout=timeout))
700
+
701
+ async def export_html(
702
+ self, *, output_path: str | None = None, timeout: Timeout = DEFAULT_TIMEOUT
703
+ ) -> str:
704
+ """Export the current session and return the path written by Pi."""
705
+ data = await self._object(
706
+ "export_html",
707
+ {"outputPath": output_path} if output_path is not None else {},
708
+ timeout=timeout,
709
+ )
710
+ if not isinstance(data.get("path"), str):
711
+ raise PiProtocolError("export_html requires a string path")
712
+ return cast(str, data["path"])
713
+
714
+ async def switch_session(
715
+ self, session_path: str, *, timeout: Timeout = DEFAULT_TIMEOUT
716
+ ) -> SessionChangeResult:
717
+ """Switch to a session path; inspect cancelled for an extension veto."""
718
+ return cast(
719
+ SessionChangeResult,
720
+ await self._object("switch_session", {"sessionPath": session_path}, timeout=timeout),
721
+ )
722
+
723
+ async def fork(self, entry_id: str, *, timeout: Timeout = DEFAULT_TIMEOUT) -> ForkResult:
724
+ """Fork at an eligible entry; a veto can omit the returned editable text."""
725
+ return cast(ForkResult, await self._object("fork", {"entryId": entry_id}, timeout=timeout))
726
+
727
+ async def clone(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> SessionChangeResult:
728
+ """Clone the current session; inspect cancelled for an extension veto."""
729
+ return cast(SessionChangeResult, await self._object("clone", timeout=timeout))
730
+
731
+ async def get_fork_messages(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> list[ForkMessage]:
732
+ """List eligible message entry IDs and text for choosing a fork point."""
733
+ return await self._list("get_fork_messages", "messages", timeout=timeout)
734
+
735
+ async def get_entries(
736
+ self, *, since: str | None = None, timeout: Timeout = DEFAULT_TIMEOUT
737
+ ) -> EntriesResult:
738
+ """Return saved entries after an optional entry ID and the current leaf ID."""
739
+ return cast(
740
+ EntriesResult,
741
+ await self._object(
742
+ "get_entries", {"since": since} if since is not None else {}, timeout=timeout
743
+ ),
744
+ )
745
+
746
+ async def get_tree(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> TreeResult:
747
+ """Return the recursive session tree and nullable selected leaf ID."""
748
+ return cast(TreeResult, await self._object("get_tree", timeout=timeout))
749
+
750
+ async def get_last_assistant_text(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> str | None:
751
+ """Query session history for usable assistant text, or None when absent."""
752
+ value = (await self._object("get_last_assistant_text", timeout=timeout)).get("text")
753
+ if value is not None and not isinstance(value, str):
754
+ raise PiProtocolError("get_last_assistant_text returned invalid text")
755
+ return value
756
+
757
+ async def set_session_name(self, name: str, *, timeout: Timeout = DEFAULT_TIMEOUT) -> None:
758
+ """Set the session name and refresh cached identity from Pi."""
759
+ await self._request("set_session_name", {"name": name}, timeout=timeout)
760
+
761
+ async def get_messages(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> list[AgentMessage]:
762
+ """Return Pi's current conversation messages."""
763
+ return await self._list("get_messages", "messages", timeout=timeout)
764
+
765
+ async def get_commands(self, *, timeout: Timeout = DEFAULT_TIMEOUT) -> list[SlashCommand]:
766
+ """List extension, prompt-template, and skill commands with source information."""
767
+ return await self._list("get_commands", "commands", timeout=timeout)
768
+
769
+ def stream(
770
+ self,
771
+ message: str,
772
+ *,
773
+ images: list[ImageContent] | None = None,
774
+ timeout: float | None = None,
775
+ command_timeout: Timeout = DEFAULT_TIMEOUT,
776
+ ) -> RunStream:
777
+ """Own a run in an async context; iterate events or await result() to drain."""
778
+ from ._runs import RunStream
779
+
780
+ return RunStream(
781
+ self, message, images=images, timeout=timeout, command_timeout=command_timeout
782
+ )
783
+
784
+ async def run(
785
+ self,
786
+ message: str,
787
+ *,
788
+ images: list[ImageContent] | None = None,
789
+ timeout: float | None = None,
790
+ command_timeout: Timeout = DEFAULT_TIMEOUT,
791
+ ) -> RunResult:
792
+ """Submit a prompt and wait through retries/continuations until agent_settled."""
793
+ async with self.stream(
794
+ message, images=images, timeout=timeout, command_timeout=command_timeout
795
+ ) as stream:
796
+ return await stream.result()