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/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()
|