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/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Python access to an existing Pi agent runtime.
|
|
2
|
+
|
|
3
|
+
Importing this package never launches Pi or changes its configuration.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .client import AsyncPiClient
|
|
7
|
+
from .errors import (
|
|
8
|
+
PiBusyError,
|
|
9
|
+
PiCommandError,
|
|
10
|
+
PiError,
|
|
11
|
+
PiProcessError,
|
|
12
|
+
PiProtocolError,
|
|
13
|
+
PiResultOverflow,
|
|
14
|
+
PiRunError,
|
|
15
|
+
PiRunOwnershipError,
|
|
16
|
+
PiRunStartTimeout,
|
|
17
|
+
PiSubscriptionOverflow,
|
|
18
|
+
PiTimeoutError,
|
|
19
|
+
PiUIHandlerError,
|
|
20
|
+
PiVersionError,
|
|
21
|
+
)
|
|
22
|
+
from .sync import PiClient
|
|
23
|
+
from .types import Event, ImageContent, Limits, RunResult, SessionInfo, UsageSummary
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AsyncPiClient",
|
|
27
|
+
"Event",
|
|
28
|
+
"ImageContent",
|
|
29
|
+
"Limits",
|
|
30
|
+
"PiBusyError",
|
|
31
|
+
"PiCommandError",
|
|
32
|
+
"PiClient",
|
|
33
|
+
"PiError",
|
|
34
|
+
"PiProcessError",
|
|
35
|
+
"PiProtocolError",
|
|
36
|
+
"PiResultOverflow",
|
|
37
|
+
"PiRunError",
|
|
38
|
+
"PiRunOwnershipError",
|
|
39
|
+
"PiRunStartTimeout",
|
|
40
|
+
"PiSubscriptionOverflow",
|
|
41
|
+
"PiTimeoutError",
|
|
42
|
+
"PiUIHandlerError",
|
|
43
|
+
"PiVersionError",
|
|
44
|
+
"RunResult",
|
|
45
|
+
"SessionInfo",
|
|
46
|
+
"UsageSummary",
|
|
47
|
+
]
|
pi_agent/_events.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Bounded future-only event subscriptions; response routing never waits here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections import deque
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Self
|
|
10
|
+
|
|
11
|
+
from .errors import PiSubscriptionOverflow
|
|
12
|
+
from .types import Event, Limits
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EventSubscription:
|
|
16
|
+
"""Enter before submitting work to observe its events without a history buffer.
|
|
17
|
+
|
|
18
|
+
A slow consumer receives PiSubscriptionOverflow rather than silently losing
|
|
19
|
+
events. Closing the context unregisters immediately and releases its buffer.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
limits: Limits,
|
|
25
|
+
register: Callable[[EventSubscription], None],
|
|
26
|
+
unregister: Callable[[EventSubscription], None],
|
|
27
|
+
) -> None:
|
|
28
|
+
self._limits = limits
|
|
29
|
+
self._register = register
|
|
30
|
+
self._unregister = unregister
|
|
31
|
+
self._records: deque[tuple[Event, int]] = deque()
|
|
32
|
+
self._bytes = 0
|
|
33
|
+
self._ready = asyncio.Event()
|
|
34
|
+
self._entered = False
|
|
35
|
+
self._closed = False
|
|
36
|
+
self._error: Exception | None = None
|
|
37
|
+
self._reading = False
|
|
38
|
+
|
|
39
|
+
async def __aenter__(self) -> Self:
|
|
40
|
+
if self._entered or self._closed:
|
|
41
|
+
raise RuntimeError("Event subscriptions are single-use")
|
|
42
|
+
self._register(self)
|
|
43
|
+
self._entered = True
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
async def __aexit__(
|
|
47
|
+
self,
|
|
48
|
+
exc_type: type[BaseException] | None,
|
|
49
|
+
exc: BaseException | None,
|
|
50
|
+
traceback: TracebackType | None,
|
|
51
|
+
) -> None:
|
|
52
|
+
await self.aclose()
|
|
53
|
+
|
|
54
|
+
async def aclose(self) -> None:
|
|
55
|
+
"""Unsubscribe and release queued payloads; safe to call repeatedly."""
|
|
56
|
+
self._finish()
|
|
57
|
+
self._records.clear()
|
|
58
|
+
self._bytes = 0
|
|
59
|
+
|
|
60
|
+
def _finish(self, error: Exception | None = None) -> None:
|
|
61
|
+
if self._closed:
|
|
62
|
+
return
|
|
63
|
+
self._closed = True
|
|
64
|
+
self._error = error
|
|
65
|
+
self._unregister(self)
|
|
66
|
+
if error is not None:
|
|
67
|
+
self._records.clear()
|
|
68
|
+
self._bytes = 0
|
|
69
|
+
self._ready.set()
|
|
70
|
+
|
|
71
|
+
def _put(self, event: Event, size: int) -> Exception | None:
|
|
72
|
+
if self._closed:
|
|
73
|
+
return self._error
|
|
74
|
+
if (
|
|
75
|
+
len(self._records) >= self._limits.event_queue_size
|
|
76
|
+
or self._bytes + size > self._limits.event_queue_bytes
|
|
77
|
+
):
|
|
78
|
+
error = PiSubscriptionOverflow("Event consumer exceeded its configured buffer limit")
|
|
79
|
+
self._finish(error)
|
|
80
|
+
return error
|
|
81
|
+
self._records.append((event, size))
|
|
82
|
+
self._bytes += size
|
|
83
|
+
self._ready.set()
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def __aiter__(self) -> Self:
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
async def __anext__(self) -> Event:
|
|
90
|
+
if not self._entered:
|
|
91
|
+
raise RuntimeError("Enter the event subscription context before iterating")
|
|
92
|
+
if self._reading:
|
|
93
|
+
raise RuntimeError("Only one reader may iterate an event subscription")
|
|
94
|
+
self._reading = True
|
|
95
|
+
try:
|
|
96
|
+
while True:
|
|
97
|
+
if self._error is not None:
|
|
98
|
+
raise self._error
|
|
99
|
+
if self._records:
|
|
100
|
+
event, size = self._records.popleft()
|
|
101
|
+
self._bytes -= size
|
|
102
|
+
return event
|
|
103
|
+
if self._closed:
|
|
104
|
+
raise StopAsyncIteration
|
|
105
|
+
self._ready.clear()
|
|
106
|
+
await self._ready.wait()
|
|
107
|
+
finally:
|
|
108
|
+
self._reading = False
|
pi_agent/_launch.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""CLI construction and offline compatibility checks for the owned process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ._transport import _close_process_pipes, _wait_for_exit
|
|
13
|
+
from .errors import PiProcessError, PiVersionError
|
|
14
|
+
|
|
15
|
+
MINIMUM_PI_VERSION = "0.85.1"
|
|
16
|
+
TESTED_PI_VERSION = "0.85.1"
|
|
17
|
+
|
|
18
|
+
# The client owns these options. In particular --print consumes an initial prompt.
|
|
19
|
+
_RESERVED = {
|
|
20
|
+
"--mode",
|
|
21
|
+
"--print",
|
|
22
|
+
"-p",
|
|
23
|
+
"--help",
|
|
24
|
+
"-h",
|
|
25
|
+
"--version",
|
|
26
|
+
"-v",
|
|
27
|
+
"--export",
|
|
28
|
+
"--list-models",
|
|
29
|
+
"--resume",
|
|
30
|
+
"-r",
|
|
31
|
+
"--continue",
|
|
32
|
+
"-c",
|
|
33
|
+
"--session",
|
|
34
|
+
"--session-dir",
|
|
35
|
+
"--session-id",
|
|
36
|
+
"--fork",
|
|
37
|
+
"--no-session",
|
|
38
|
+
"--provider",
|
|
39
|
+
"--model",
|
|
40
|
+
}
|
|
41
|
+
_VALUES = {
|
|
42
|
+
"--api-key",
|
|
43
|
+
"--system-prompt",
|
|
44
|
+
"--append-system-prompt",
|
|
45
|
+
"--name",
|
|
46
|
+
"-n",
|
|
47
|
+
"--models",
|
|
48
|
+
"--tools",
|
|
49
|
+
"-t",
|
|
50
|
+
"--exclude-tools",
|
|
51
|
+
"-xt",
|
|
52
|
+
"--thinking",
|
|
53
|
+
"--extension",
|
|
54
|
+
"-e",
|
|
55
|
+
"--skill",
|
|
56
|
+
"--prompt-template",
|
|
57
|
+
"--theme",
|
|
58
|
+
"--use-theme",
|
|
59
|
+
"--tui-mode",
|
|
60
|
+
}
|
|
61
|
+
_FLAGS = {
|
|
62
|
+
"--no-tools",
|
|
63
|
+
"-nt",
|
|
64
|
+
"--no-builtin-tools",
|
|
65
|
+
"-nbt",
|
|
66
|
+
"--no-extensions",
|
|
67
|
+
"-ne",
|
|
68
|
+
"--no-skills",
|
|
69
|
+
"-ns",
|
|
70
|
+
"--no-prompt-templates",
|
|
71
|
+
"-np",
|
|
72
|
+
"--no-themes",
|
|
73
|
+
"--no-context-files",
|
|
74
|
+
"-nc",
|
|
75
|
+
"--verbose",
|
|
76
|
+
"--approve",
|
|
77
|
+
"-a",
|
|
78
|
+
"--no-approve",
|
|
79
|
+
"-na",
|
|
80
|
+
"--offline",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_extra_args(args: Sequence[str]) -> None:
|
|
85
|
+
"""Reject positional input and conflicting flags, retaining extension flags."""
|
|
86
|
+
index = 0
|
|
87
|
+
while index < len(args):
|
|
88
|
+
arg = args[index]
|
|
89
|
+
name = arg.split("=", 1)[0]
|
|
90
|
+
if name in _RESERVED or arg == "--":
|
|
91
|
+
raise ValueError(f"Pi option {name} is managed by the client or incompatible with RPC")
|
|
92
|
+
if name in _VALUES:
|
|
93
|
+
if "=" in arg or index + 1 == len(args):
|
|
94
|
+
raise ValueError(f"Pi option {name} requires a separate value")
|
|
95
|
+
if name in {"--use-theme", "--tui-mode"} and args[index + 1].startswith("-"):
|
|
96
|
+
raise ValueError(f"Pi option {name} requires a value before another option")
|
|
97
|
+
index += 2
|
|
98
|
+
elif name in _FLAGS:
|
|
99
|
+
if "=" in arg:
|
|
100
|
+
raise ValueError(f"Pi option {name} takes no value")
|
|
101
|
+
index += 1
|
|
102
|
+
elif arg.startswith("--"):
|
|
103
|
+
# Pi accepts extension flags as --name=value or --name [value].
|
|
104
|
+
index += 1
|
|
105
|
+
if "=" not in arg and index < len(args) and not args[index].startswith(("-", "@")):
|
|
106
|
+
index += 1
|
|
107
|
+
else:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"extra_args cannot contain startup prompts, attachments, or unknown short flags"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def executable_argv(
|
|
114
|
+
executable: str | os.PathLike[str] | Sequence[str], env: Mapping[str, str]
|
|
115
|
+
) -> list[str]:
|
|
116
|
+
"""Resolve a binary or explicit argv; npm's Windows shim runs through Node."""
|
|
117
|
+
if isinstance(executable, (str, os.PathLike)):
|
|
118
|
+
argv = [os.fspath(executable)]
|
|
119
|
+
else:
|
|
120
|
+
argv = list(executable)
|
|
121
|
+
if not argv or not all(isinstance(item, str) and item for item in argv):
|
|
122
|
+
raise ValueError("executable must be a path or a nonempty sequence of arguments")
|
|
123
|
+
resolved = shutil.which(argv[0], path=env.get("PATH"))
|
|
124
|
+
if resolved is None:
|
|
125
|
+
raise PiProcessError("Pi executable was not found; install Pi or specify executable")
|
|
126
|
+
if os.name == "nt" and Path(resolved).suffix.lower() in {".cmd", ".bat"}:
|
|
127
|
+
# Avoid shell=True and cmd quoting. Only the known npm Pi shim layout is resolved.
|
|
128
|
+
root = Path(resolved).parent
|
|
129
|
+
cli = root / "node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js"
|
|
130
|
+
node = root / "node.exe"
|
|
131
|
+
node_path = str(node) if node.is_file() else shutil.which("node", path=env.get("PATH"))
|
|
132
|
+
if not cli.is_file() or node_path is None:
|
|
133
|
+
raise PiProcessError(
|
|
134
|
+
"Cannot resolve Pi npm shim; pass executable=[node_path, pi_cli_path]"
|
|
135
|
+
)
|
|
136
|
+
return [node_path, str(cli), *argv[1:]]
|
|
137
|
+
return [resolved, *argv[1:]]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def check_version(
|
|
141
|
+
argv: Sequence[str],
|
|
142
|
+
*,
|
|
143
|
+
cwd: str | None,
|
|
144
|
+
env: Mapping[str, str],
|
|
145
|
+
timeout: float,
|
|
146
|
+
allow_unknown: bool,
|
|
147
|
+
strict: bool,
|
|
148
|
+
) -> tuple[str | None, str]:
|
|
149
|
+
"""Read --version without a network request or loading user configuration."""
|
|
150
|
+
process: asyncio.subprocess.Process | None = None
|
|
151
|
+
try:
|
|
152
|
+
async with asyncio.timeout(timeout):
|
|
153
|
+
process = await asyncio.create_subprocess_exec(
|
|
154
|
+
*argv,
|
|
155
|
+
"--version",
|
|
156
|
+
cwd=cwd,
|
|
157
|
+
env=dict(env),
|
|
158
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
159
|
+
stdout=asyncio.subprocess.PIPE,
|
|
160
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
161
|
+
)
|
|
162
|
+
assert process.stdout is not None
|
|
163
|
+
output = bytearray()
|
|
164
|
+
while chunk := await process.stdout.read(4097 - len(output)):
|
|
165
|
+
output.extend(chunk)
|
|
166
|
+
if len(output) > 4096:
|
|
167
|
+
raise PiVersionError("Pi --version returned too much output")
|
|
168
|
+
code = await process.wait()
|
|
169
|
+
match = re.fullmatch(rb"\s*(\d+)\.(\d+)\.(\d+)\s*", output)
|
|
170
|
+
if code != 0 or match is None:
|
|
171
|
+
if allow_unknown:
|
|
172
|
+
return None, "unknown"
|
|
173
|
+
raise PiVersionError(
|
|
174
|
+
"Cannot determine Pi version; custom wrappers may use "
|
|
175
|
+
"allow_unknown_version=True"
|
|
176
|
+
)
|
|
177
|
+
version = tuple(int(part) for part in match.groups())
|
|
178
|
+
version_text = ".".join(str(part) for part in version)
|
|
179
|
+
if version < tuple(map(int, MINIMUM_PI_VERSION.split("."))):
|
|
180
|
+
raise PiVersionError(
|
|
181
|
+
f"Pi {version_text} is older than minimum {MINIMUM_PI_VERSION}"
|
|
182
|
+
)
|
|
183
|
+
if version_text != TESTED_PI_VERSION and strict:
|
|
184
|
+
raise PiVersionError(
|
|
185
|
+
f"Pi {version_text} is untested; recorded tested version is {TESTED_PI_VERSION}"
|
|
186
|
+
)
|
|
187
|
+
return version_text, "tested" if version_text == TESTED_PI_VERSION else "untested"
|
|
188
|
+
except TimeoutError as exc:
|
|
189
|
+
raise PiVersionError("Timed out checking Pi version") from exc
|
|
190
|
+
except OSError as exc:
|
|
191
|
+
raise PiProcessError("Could not launch Pi for version checking") from exc
|
|
192
|
+
finally:
|
|
193
|
+
if process is not None:
|
|
194
|
+
if process.returncode is None:
|
|
195
|
+
try:
|
|
196
|
+
process.kill()
|
|
197
|
+
except ProcessLookupError:
|
|
198
|
+
pass
|
|
199
|
+
# Process.wait() may depend on full or inherited pipes closing.
|
|
200
|
+
# Wait for the owned child's exit first, then release those pipes.
|
|
201
|
+
await _wait_for_exit(process)
|
|
202
|
+
_close_process_pipes(process)
|
|
203
|
+
await process.wait()
|
pi_agent/_runs.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""One owned conversation run, including settlement and cancellation cleanup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
10
|
+
|
|
11
|
+
from ._events import EventSubscription
|
|
12
|
+
from ._usage import UsageAccumulator
|
|
13
|
+
from .errors import (
|
|
14
|
+
PiBusyError,
|
|
15
|
+
PiCommandError,
|
|
16
|
+
PiProtocolError,
|
|
17
|
+
PiResultOverflow,
|
|
18
|
+
PiRunError,
|
|
19
|
+
PiRunOwnershipError,
|
|
20
|
+
PiRunStartTimeout,
|
|
21
|
+
PiTimeoutError,
|
|
22
|
+
)
|
|
23
|
+
from .types import Event, ImageContent, RunResult
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from .client import AsyncPiClient, Timeout
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RunStream:
|
|
30
|
+
"""A single-use owned run. Enter, then iterate or await result() to drain.
|
|
31
|
+
|
|
32
|
+
Breaking iteration must be followed by leaving the context so queued work
|
|
33
|
+
is cleared and Pi is aborted. An accepted command without a start event
|
|
34
|
+
has an uncertain disposition; its start deadline closes Pi.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
client: AsyncPiClient,
|
|
40
|
+
message: str,
|
|
41
|
+
*,
|
|
42
|
+
images: list[ImageContent] | None,
|
|
43
|
+
timeout: float | None,
|
|
44
|
+
command_timeout: Timeout,
|
|
45
|
+
) -> None:
|
|
46
|
+
if timeout is not None and (
|
|
47
|
+
isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0
|
|
48
|
+
):
|
|
49
|
+
raise ValueError("run timeout must be a positive finite number or None")
|
|
50
|
+
self._client = client
|
|
51
|
+
self._message = message
|
|
52
|
+
self._images = images
|
|
53
|
+
self._timeout = timeout
|
|
54
|
+
self._command_timeout = command_timeout
|
|
55
|
+
self._events = EventSubscription(
|
|
56
|
+
client.limits, lambda subscription: None, lambda subscription: None
|
|
57
|
+
)
|
|
58
|
+
self._started = asyncio.Event()
|
|
59
|
+
self._settled = asyncio.Event()
|
|
60
|
+
self._task: asyncio.Task[RunResult] | None = None
|
|
61
|
+
self._accepted: asyncio.Future[None] | None = None
|
|
62
|
+
self._error: Exception | None = None
|
|
63
|
+
self._messages: list[dict[str, Any]] = []
|
|
64
|
+
self._message_bytes = 0
|
|
65
|
+
self._usage = UsageAccumulator()
|
|
66
|
+
self._submitted = False
|
|
67
|
+
self._finishing = False
|
|
68
|
+
self._closed = False
|
|
69
|
+
self._iterating = False
|
|
70
|
+
self._draining = False
|
|
71
|
+
self._iteration_done = False
|
|
72
|
+
self._begin = 0.0
|
|
73
|
+
self._end = 0.0
|
|
74
|
+
|
|
75
|
+
async def __aenter__(self) -> Self:
|
|
76
|
+
self._client._check_loop()
|
|
77
|
+
if self._task is not None or self._closed:
|
|
78
|
+
raise RuntimeError("Run streams are single-use")
|
|
79
|
+
if self._client.busy:
|
|
80
|
+
raise PiBusyError("Another run owns this Pi conversation")
|
|
81
|
+
if not self._client.running:
|
|
82
|
+
raise RuntimeError("Start Pi before opening a run stream")
|
|
83
|
+
if self._client._unowned_submission:
|
|
84
|
+
raise PiRunOwnershipError(
|
|
85
|
+
"This client submitted low-level conversation work; use a fresh client for "
|
|
86
|
+
"run()/stream() because delayed session events cannot be attributed safely"
|
|
87
|
+
)
|
|
88
|
+
self._client._owner = self
|
|
89
|
+
self._accepted = asyncio.get_running_loop().create_future()
|
|
90
|
+
await self._events.__aenter__()
|
|
91
|
+
self._task = asyncio.create_task(self._drive(), name="pi-run")
|
|
92
|
+
self._task.add_done_callback(self._consume_task_exception)
|
|
93
|
+
try:
|
|
94
|
+
await asyncio.shield(self._accepted)
|
|
95
|
+
except BaseException:
|
|
96
|
+
await self.aclose()
|
|
97
|
+
raise
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _consume_task_exception(task: asyncio.Task[RunResult]) -> None:
|
|
102
|
+
if not task.cancelled():
|
|
103
|
+
task.exception()
|
|
104
|
+
|
|
105
|
+
async def __aexit__(
|
|
106
|
+
self,
|
|
107
|
+
exc_type: type[BaseException] | None,
|
|
108
|
+
exc: BaseException | None,
|
|
109
|
+
traceback: TracebackType | None,
|
|
110
|
+
) -> None:
|
|
111
|
+
await self.aclose()
|
|
112
|
+
|
|
113
|
+
async def aclose(self) -> None:
|
|
114
|
+
"""Finish cancellation cleanup before releasing the owned conversation."""
|
|
115
|
+
self._closed = True
|
|
116
|
+
if self._task is not None and not self._task.done():
|
|
117
|
+
self._task.cancel()
|
|
118
|
+
if self._task is not None:
|
|
119
|
+
try:
|
|
120
|
+
await asyncio.shield(self._task)
|
|
121
|
+
except (Exception, asyncio.CancelledError):
|
|
122
|
+
pass
|
|
123
|
+
if self._accepted is not None and self._accepted.done() and not self._accepted.cancelled():
|
|
124
|
+
self._accepted.exception()
|
|
125
|
+
await self._events.aclose()
|
|
126
|
+
|
|
127
|
+
def _fail(self, error: Exception) -> None:
|
|
128
|
+
if self._error is not None or self._finishing:
|
|
129
|
+
return
|
|
130
|
+
self._error = error
|
|
131
|
+
self._events._finish(error)
|
|
132
|
+
if self._task is not None and not self._task.done():
|
|
133
|
+
self._task.cancel()
|
|
134
|
+
|
|
135
|
+
def _on_event(self, event: Event, size: int) -> None:
|
|
136
|
+
if not self._submitted or self._settled.is_set() or self._error is not None:
|
|
137
|
+
return
|
|
138
|
+
if event.type == "agent_start":
|
|
139
|
+
self._started.set()
|
|
140
|
+
elif event.type == "agent_settled" and self._started.is_set():
|
|
141
|
+
self._end = time.monotonic()
|
|
142
|
+
self._settled.set()
|
|
143
|
+
elif event.type == "message_end" and self._started.is_set():
|
|
144
|
+
message = event.raw.get("message")
|
|
145
|
+
if not isinstance(message, dict) or not isinstance(message.get("role"), str):
|
|
146
|
+
self._fail(PiProtocolError("message_end requires a message with a role"))
|
|
147
|
+
return
|
|
148
|
+
if message.get("role") == "assistant":
|
|
149
|
+
if not isinstance(message.get("content"), list) or not isinstance(
|
|
150
|
+
message.get("stopReason"), str
|
|
151
|
+
):
|
|
152
|
+
self._fail(
|
|
153
|
+
PiProtocolError("Final assistant message requires content and stopReason")
|
|
154
|
+
)
|
|
155
|
+
return
|
|
156
|
+
if (
|
|
157
|
+
len(self._messages) >= self._client.limits.result_message_count
|
|
158
|
+
or self._message_bytes + size > self._client.limits.result_message_bytes
|
|
159
|
+
):
|
|
160
|
+
self._fail(PiResultOverflow("Run exceeded its retained message count/byte limit"))
|
|
161
|
+
return
|
|
162
|
+
self._message_bytes += size
|
|
163
|
+
self._messages.append(message)
|
|
164
|
+
self._usage.add(message)
|
|
165
|
+
error = self._events._put(event, size)
|
|
166
|
+
if error is not None:
|
|
167
|
+
self._fail(error)
|
|
168
|
+
|
|
169
|
+
async def _drive(self) -> RunResult:
|
|
170
|
+
assert self._accepted is not None
|
|
171
|
+
failure: BaseException | None = None
|
|
172
|
+
try:
|
|
173
|
+
async with asyncio.timeout(self._timeout):
|
|
174
|
+
state = await self._client.get_state()
|
|
175
|
+
if state["isStreaming"] or state["isCompacting"] or state["pendingMessageCount"]:
|
|
176
|
+
raise PiBusyError("Pi already has active or queued low-level work")
|
|
177
|
+
fields: dict[str, Any] = {"message": self._message}
|
|
178
|
+
if self._images is not None:
|
|
179
|
+
fields["images"] = self._images
|
|
180
|
+
self._submitted = True
|
|
181
|
+
self._begin = time.monotonic()
|
|
182
|
+
await self._client._request(
|
|
183
|
+
"prompt", fields, timeout=self._command_timeout, owner=self
|
|
184
|
+
)
|
|
185
|
+
self._accepted.set_result(None)
|
|
186
|
+
try:
|
|
187
|
+
async with asyncio.timeout(self._client.limits.run_start_timeout):
|
|
188
|
+
await self._started.wait()
|
|
189
|
+
except TimeoutError as exc:
|
|
190
|
+
raise PiRunStartTimeout(
|
|
191
|
+
"Prompt accepted but no run was observed; "
|
|
192
|
+
"Pi is closed to prevent delayed work",
|
|
193
|
+
command="prompt",
|
|
194
|
+
uncertain=True,
|
|
195
|
+
) from exc
|
|
196
|
+
await self._settled.wait()
|
|
197
|
+
if self._error is not None:
|
|
198
|
+
raise self._error
|
|
199
|
+
# Extensions can switch sessions internally; do not reuse startup identity.
|
|
200
|
+
await self._client.get_state()
|
|
201
|
+
result = self._result()
|
|
202
|
+
if result.stop_reason in {"error", "aborted"}:
|
|
203
|
+
raise PiRunError(f"Pi run ended with {result.stop_reason}", result)
|
|
204
|
+
return result
|
|
205
|
+
except BaseException as exc:
|
|
206
|
+
self._finishing = True
|
|
207
|
+
failure = exc
|
|
208
|
+
if isinstance(exc, asyncio.CancelledError) and self._error is not None:
|
|
209
|
+
failure = self._error
|
|
210
|
+
elif isinstance(exc, TimeoutError) and not isinstance(exc, PiTimeoutError):
|
|
211
|
+
failure = PiTimeoutError(
|
|
212
|
+
"Pi run deadline elapsed", command="prompt", uncertain=self._submitted
|
|
213
|
+
)
|
|
214
|
+
accepted = self._accepted.done() and not self._accepted.cancelled()
|
|
215
|
+
if (
|
|
216
|
+
self._submitted
|
|
217
|
+
and not isinstance(failure, PiCommandError)
|
|
218
|
+
and (not self._settled.is_set() or not accepted)
|
|
219
|
+
):
|
|
220
|
+
# abort() cannot cancel an extension's pending input/UI preflight.
|
|
221
|
+
# Keep delayed work from escaping a failed owned operation.
|
|
222
|
+
if not accepted or not self._started.is_set():
|
|
223
|
+
await self._client.aclose()
|
|
224
|
+
else:
|
|
225
|
+
await self._cleanup()
|
|
226
|
+
if failure is exc:
|
|
227
|
+
raise
|
|
228
|
+
raise failure from exc
|
|
229
|
+
finally:
|
|
230
|
+
if not self._accepted.done():
|
|
231
|
+
if isinstance(failure, asyncio.CancelledError):
|
|
232
|
+
self._accepted.cancel()
|
|
233
|
+
elif failure is not None:
|
|
234
|
+
self._accepted.set_exception(failure)
|
|
235
|
+
# Final model errors are delivered by result(), after their events are consumed.
|
|
236
|
+
event_error = (
|
|
237
|
+
failure
|
|
238
|
+
if isinstance(failure, Exception) and not isinstance(failure, PiRunError)
|
|
239
|
+
else None
|
|
240
|
+
)
|
|
241
|
+
self._events._finish(event_error)
|
|
242
|
+
if self._client._owner is self:
|
|
243
|
+
self._client._owner = None
|
|
244
|
+
|
|
245
|
+
async def _cleanup(self) -> None:
|
|
246
|
+
async def stop() -> None:
|
|
247
|
+
if not self._client.running:
|
|
248
|
+
await self._client.aclose()
|
|
249
|
+
return
|
|
250
|
+
try:
|
|
251
|
+
async with asyncio.timeout(self._client.limits.cleanup_timeout):
|
|
252
|
+
await self._client._request("clear_queue", owner=self)
|
|
253
|
+
await self._client._request("abort", owner=self)
|
|
254
|
+
except Exception:
|
|
255
|
+
await self._client.aclose()
|
|
256
|
+
|
|
257
|
+
# Shield cleanup from the caller's cancellation, retaining ownership until it completes.
|
|
258
|
+
task = asyncio.create_task(stop(), name="pi-run-cleanup")
|
|
259
|
+
try:
|
|
260
|
+
await asyncio.shield(task)
|
|
261
|
+
except asyncio.CancelledError:
|
|
262
|
+
await task
|
|
263
|
+
|
|
264
|
+
def _result(self) -> RunResult:
|
|
265
|
+
assistants = [message for message in self._messages if message["role"] == "assistant"]
|
|
266
|
+
last = assistants[-1] if assistants else None
|
|
267
|
+
content = last["content"] if last is not None else []
|
|
268
|
+
text_parts = []
|
|
269
|
+
for block in content:
|
|
270
|
+
if not isinstance(block, dict):
|
|
271
|
+
raise PiProtocolError("Assistant content blocks must be objects")
|
|
272
|
+
if block.get("type") == "text":
|
|
273
|
+
if not isinstance(block.get("text"), str):
|
|
274
|
+
raise PiProtocolError("Assistant text blocks require string text")
|
|
275
|
+
text_parts.append(block["text"])
|
|
276
|
+
return RunResult(
|
|
277
|
+
text="".join(text_parts),
|
|
278
|
+
messages=list(self._messages),
|
|
279
|
+
stop_reason=last["stopReason"] if last is not None else None,
|
|
280
|
+
session=self._client.session,
|
|
281
|
+
elapsed_seconds=max(0.0, self._end - self._begin),
|
|
282
|
+
usage=self._usage.snapshot(),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def __aiter__(self) -> Self:
|
|
286
|
+
if self._draining:
|
|
287
|
+
raise PiBusyError("Cannot iterate while result() drains the stream")
|
|
288
|
+
if self._iterating:
|
|
289
|
+
raise PiBusyError("A stream permits one event iterator")
|
|
290
|
+
self._iterating = True
|
|
291
|
+
return self
|
|
292
|
+
|
|
293
|
+
async def __anext__(self) -> Event:
|
|
294
|
+
if self._draining:
|
|
295
|
+
raise PiBusyError("Cannot iterate while result() drains the stream")
|
|
296
|
+
self._iterating = True
|
|
297
|
+
try:
|
|
298
|
+
return await self._events.__anext__()
|
|
299
|
+
except StopAsyncIteration:
|
|
300
|
+
self._iteration_done = True
|
|
301
|
+
raise
|
|
302
|
+
|
|
303
|
+
async def result(self) -> RunResult:
|
|
304
|
+
"""Drain without retaining events, or return the result after iteration ends."""
|
|
305
|
+
if self._task is None:
|
|
306
|
+
raise RuntimeError("Enter the stream context before awaiting its result")
|
|
307
|
+
if self._draining or (self._iterating and not self._iteration_done):
|
|
308
|
+
raise PiBusyError("Finish iteration before calling result()")
|
|
309
|
+
self._draining = True
|
|
310
|
+
try:
|
|
311
|
+
if not self._iteration_done:
|
|
312
|
+
async for _ in self._events:
|
|
313
|
+
pass
|
|
314
|
+
self._iteration_done = True
|
|
315
|
+
return await asyncio.shield(self._task)
|
|
316
|
+
finally:
|
|
317
|
+
self._draining = False
|