courier-encode 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.
- courier_encode-0.1.0.dist-info/METADATA +1158 -0
- courier_encode-0.1.0.dist-info/RECORD +19 -0
- courier_encode-0.1.0.dist-info/WHEEL +4 -0
- courier_encode-0.1.0.dist-info/licenses/LICENSE +201 -0
- encode/__init__.py +113 -0
- encode/_config.py +61 -0
- encode/_http.py +150 -0
- encode/_schema.py +135 -0
- encode/_streaming.py +97 -0
- encode/_version.py +1 -0
- encode/client.py +138 -0
- encode/errors.py +169 -0
- encode/messages.py +342 -0
- encode/py.typed +0 -0
- encode/relay.py +1222 -0
- encode/responses.py +67 -0
- encode/session.py +447 -0
- encode/tools.py +107 -0
- encode/whisper.py +200 -0
encode/responses.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Pydantic response models returned by relay() and whisper()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Usage(BaseModel):
|
|
11
|
+
model_config = ConfigDict(extra="allow")
|
|
12
|
+
prompt_tokens: int | None = None
|
|
13
|
+
completion_tokens: int | None = None
|
|
14
|
+
total_tokens: int | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ToolCallRecord(BaseModel):
|
|
18
|
+
model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
|
|
19
|
+
id: str
|
|
20
|
+
name: str
|
|
21
|
+
arguments: dict[str, Any]
|
|
22
|
+
arguments_raw: str
|
|
23
|
+
result: Any = None
|
|
24
|
+
result_serialized: str = ""
|
|
25
|
+
error: str | None = None
|
|
26
|
+
iteration: int = 0
|
|
27
|
+
duration_ms: float = 0.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AssistantTurn(BaseModel):
|
|
31
|
+
model_config = ConfigDict(extra="allow")
|
|
32
|
+
role: Literal["assistant"] = "assistant"
|
|
33
|
+
content: str | None = None
|
|
34
|
+
tool_calls: list[ToolCallRecord] = []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RelayResponse(BaseModel):
|
|
38
|
+
model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
|
|
39
|
+
content: str | None = None
|
|
40
|
+
parsed: Any = None
|
|
41
|
+
messages: list[dict[str, Any]] = []
|
|
42
|
+
tool_calls: list[ToolCallRecord] = []
|
|
43
|
+
iterations: int = 1
|
|
44
|
+
finish_reason: str | None = None
|
|
45
|
+
endpoint: Literal["chat", "responses"] = "chat"
|
|
46
|
+
model: str = ""
|
|
47
|
+
raw: Any = None
|
|
48
|
+
usage: Usage | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class WhisperResponse(BaseModel):
|
|
52
|
+
model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
|
|
53
|
+
text: str
|
|
54
|
+
language: str | None = None
|
|
55
|
+
duration: float | None = None
|
|
56
|
+
segments: list[dict[str, Any]] | None = None
|
|
57
|
+
words: list[dict[str, Any]] | None = None
|
|
58
|
+
raw: Any = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"Usage",
|
|
63
|
+
"ToolCallRecord",
|
|
64
|
+
"AssistantTurn",
|
|
65
|
+
"RelayResponse",
|
|
66
|
+
"WhisperResponse",
|
|
67
|
+
]
|
encode/session.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Persistent shell sessions.
|
|
2
|
+
|
|
3
|
+
A ``Session`` wraps a single long-lived bash process. State (cwd, env vars,
|
|
4
|
+
sourced venvs) persists across ``run()`` calls because every command executes
|
|
5
|
+
in the same shell. Use ``CommandResult`` to inspect output, exit code, and the
|
|
6
|
+
post-command cwd.
|
|
7
|
+
|
|
8
|
+
Backed by ``pexpect`` (sync) and ``asyncio.create_subprocess_shell`` (async).
|
|
9
|
+
macOS and Linux only.
|
|
10
|
+
|
|
11
|
+
Sentinel protocol: each ``run()`` appends a unique echo command after the
|
|
12
|
+
user's command. Bash emits ``---ENCODE_DONE_<nonce>:<exit>:<cwd>---`` on its
|
|
13
|
+
own line, which is how the SDK detects completion without prompt matching.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import secrets
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from os import PathLike
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, ConfigDict
|
|
28
|
+
|
|
29
|
+
from .errors import SessionError, SessionTimeoutError
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
import pexpect as _pexpect_module
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_SENTINEL_TEMPLATE = '; echo "---ENCODE_DONE_{nonce}:$?:$(pwd)---"'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _sentinel_for(nonce: str) -> str:
|
|
39
|
+
return _SENTINEL_TEMPLATE.format(nonce=nonce)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _sentinel_str_re(nonce: str) -> re.Pattern[str]:
|
|
43
|
+
# Trailing \r?\n? is consumed so the next command's read buffer starts clean.
|
|
44
|
+
return re.compile(rf"---ENCODE_DONE_{nonce}:(-?\d+):([^\r\n]*)---\r?\n?")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _sentinel_bytes_re(nonce: str) -> re.Pattern[bytes]:
|
|
48
|
+
return re.compile(
|
|
49
|
+
rb"---ENCODE_DONE_" + nonce.encode("ascii") + rb":(-?\d+):([^\r\n]*)---\r?\n?"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_platform() -> None:
|
|
54
|
+
if sys.platform.startswith("win"):
|
|
55
|
+
raise SessionError(
|
|
56
|
+
"encode.Session is only supported on macOS and Linux"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _shell_env() -> dict[str, str]:
|
|
61
|
+
"""Inherit os.environ but suppress prompt and prompt-side-effects."""
|
|
62
|
+
env = dict(os.environ)
|
|
63
|
+
env["PS1"] = ""
|
|
64
|
+
env["PS2"] = ""
|
|
65
|
+
env["PROMPT_COMMAND"] = ""
|
|
66
|
+
env["TERM"] = env.get("TERM", "dumb")
|
|
67
|
+
return env
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CommandResult(BaseModel):
|
|
71
|
+
"""Result of a single ``Session.run()`` call.
|
|
72
|
+
|
|
73
|
+
``exit_code`` is ``None`` only when the command timed out before the
|
|
74
|
+
sentinel was emitted (paired with ``timed_out=True``).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
model_config = ConfigDict(extra="allow")
|
|
78
|
+
|
|
79
|
+
command: str
|
|
80
|
+
output: str
|
|
81
|
+
exit_code: int | None
|
|
82
|
+
cwd: str
|
|
83
|
+
duration_ms: float
|
|
84
|
+
timed_out: bool = False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Session:
|
|
88
|
+
"""Persistent bash session backed by ``pexpect.spawn``.
|
|
89
|
+
|
|
90
|
+
State (cwd, env vars, activated venvs) persists across ``run()`` calls.
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
with encode.Session() as sh:
|
|
94
|
+
sh.run("export FOO=bar")
|
|
95
|
+
r = sh.run("echo $FOO")
|
|
96
|
+
assert r.output == "bar"
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
cwd: str | PathLike[str] | None = None,
|
|
102
|
+
timeout: float = 30.0,
|
|
103
|
+
) -> None:
|
|
104
|
+
_check_platform()
|
|
105
|
+
try:
|
|
106
|
+
import pexpect
|
|
107
|
+
except ImportError as e: # pragma: no cover - declared dep
|
|
108
|
+
raise SessionError(f"pexpect is required for sessions: {e}") from e
|
|
109
|
+
self._pexpect: Any = pexpect
|
|
110
|
+
self._cwd = os.fspath(cwd) if cwd is not None else os.getcwd()
|
|
111
|
+
self._default_timeout = timeout
|
|
112
|
+
self._nonce = secrets.token_hex(8)
|
|
113
|
+
self._sentinel = _sentinel_for(self._nonce)
|
|
114
|
+
self._sentinel_re = _sentinel_str_re(self._nonce)
|
|
115
|
+
self._proc: _pexpect_module.spawn | None = None
|
|
116
|
+
self._spawn()
|
|
117
|
+
try:
|
|
118
|
+
self.run(":", timeout=timeout)
|
|
119
|
+
except SessionTimeoutError as e:
|
|
120
|
+
self.kill()
|
|
121
|
+
raise SessionError(
|
|
122
|
+
f"bash session failed to initialize within {timeout}s"
|
|
123
|
+
) from e
|
|
124
|
+
|
|
125
|
+
def _spawn(self) -> None:
|
|
126
|
+
try:
|
|
127
|
+
self._proc = self._pexpect.spawn(
|
|
128
|
+
"/bin/bash",
|
|
129
|
+
["--norc", "--noprofile"],
|
|
130
|
+
cwd=self._cwd,
|
|
131
|
+
env=_shell_env(),
|
|
132
|
+
encoding="utf-8",
|
|
133
|
+
echo=False,
|
|
134
|
+
timeout=self._default_timeout,
|
|
135
|
+
dimensions=(24, 200),
|
|
136
|
+
)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
raise SessionError(f"failed to spawn bash: {e}") from e
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def alive(self) -> bool:
|
|
142
|
+
return self._proc is not None and bool(self._proc.isalive())
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def pid(self) -> int | None:
|
|
146
|
+
return self._proc.pid if self._proc is not None else None
|
|
147
|
+
|
|
148
|
+
def run(self, command: str, *, timeout: float | None = None) -> CommandResult:
|
|
149
|
+
if self._proc is None or not self._proc.isalive():
|
|
150
|
+
raise SessionError(
|
|
151
|
+
"session is not running; spawn was killed or never started"
|
|
152
|
+
)
|
|
153
|
+
t = self._default_timeout if timeout is None else timeout
|
|
154
|
+
full = command + self._sentinel
|
|
155
|
+
start = time.perf_counter()
|
|
156
|
+
self._proc.sendline(full)
|
|
157
|
+
try:
|
|
158
|
+
idx = self._proc.expect(
|
|
159
|
+
[self._sentinel_re, self._pexpect.TIMEOUT, self._pexpect.EOF],
|
|
160
|
+
timeout=t,
|
|
161
|
+
)
|
|
162
|
+
except Exception as e: # pragma: no cover - expect normally returns an index
|
|
163
|
+
raise SessionError(f"session read failed: {e}") from e
|
|
164
|
+
duration_ms = (time.perf_counter() - start) * 1000.0
|
|
165
|
+
if idx == 1:
|
|
166
|
+
partial = self._proc.before or ""
|
|
167
|
+
raise SessionTimeoutError(
|
|
168
|
+
f"command timed out after {t}s",
|
|
169
|
+
partial_output=partial,
|
|
170
|
+
command=command,
|
|
171
|
+
)
|
|
172
|
+
if idx == 2:
|
|
173
|
+
raise SessionError(
|
|
174
|
+
f"bash exited unexpectedly during command: {command!r}"
|
|
175
|
+
)
|
|
176
|
+
match = self._proc.match
|
|
177
|
+
exit_code = int(match.group(1))
|
|
178
|
+
cwd = match.group(2)
|
|
179
|
+
before = self._proc.before or ""
|
|
180
|
+
output = before.replace("\r\n", "\n").rstrip()
|
|
181
|
+
return CommandResult(
|
|
182
|
+
command=command,
|
|
183
|
+
output=output,
|
|
184
|
+
exit_code=exit_code,
|
|
185
|
+
cwd=cwd,
|
|
186
|
+
duration_ms=duration_ms,
|
|
187
|
+
timed_out=False,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def start(self, command: str) -> None:
|
|
191
|
+
"""Send a command without a sentinel — fire and forget.
|
|
192
|
+
|
|
193
|
+
Useful for long-running processes (servers, watchers). After ``start()``
|
|
194
|
+
the shell is busy with the foreground command; subsequent ``run()``
|
|
195
|
+
calls will block until that command exits, so background with ``&`` if
|
|
196
|
+
you want to interleave.
|
|
197
|
+
"""
|
|
198
|
+
if self._proc is None or not self._proc.isalive():
|
|
199
|
+
raise SessionError("session is not running")
|
|
200
|
+
self._proc.sendline(command)
|
|
201
|
+
|
|
202
|
+
def read(self, timeout: float = 1.0) -> str:
|
|
203
|
+
"""Drain currently available output (non-blocking after the first read)."""
|
|
204
|
+
if self._proc is None or not self._proc.isalive():
|
|
205
|
+
raise SessionError("session is not running")
|
|
206
|
+
chunks: list[str] = []
|
|
207
|
+
sub_t = timeout
|
|
208
|
+
while True:
|
|
209
|
+
try:
|
|
210
|
+
data = self._proc.read_nonblocking(size=4096, timeout=sub_t)
|
|
211
|
+
except self._pexpect.TIMEOUT:
|
|
212
|
+
break
|
|
213
|
+
except self._pexpect.EOF:
|
|
214
|
+
break
|
|
215
|
+
if not data:
|
|
216
|
+
break
|
|
217
|
+
chunks.append(data)
|
|
218
|
+
sub_t = 0
|
|
219
|
+
return "".join(chunks)
|
|
220
|
+
|
|
221
|
+
def kill(self) -> None:
|
|
222
|
+
"""Terminate the underlying bash process. Idempotent."""
|
|
223
|
+
if self._proc is None:
|
|
224
|
+
return
|
|
225
|
+
try:
|
|
226
|
+
if self._proc.isalive():
|
|
227
|
+
self._proc.terminate(force=True)
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
self._proc = None
|
|
231
|
+
|
|
232
|
+
def __enter__(self) -> Session:
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
def __exit__(self, *exc: object) -> None:
|
|
236
|
+
self.kill()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class AsyncSession:
|
|
240
|
+
"""Persistent bash session backed by ``asyncio.create_subprocess_shell``.
|
|
241
|
+
|
|
242
|
+
Same surface as :class:`Session` but async. Spawns lazily on the first
|
|
243
|
+
``run()`` / ``start()`` / ``read()`` because ``__init__`` cannot await.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
cwd: str | PathLike[str] | None = None,
|
|
249
|
+
timeout: float = 30.0,
|
|
250
|
+
) -> None:
|
|
251
|
+
_check_platform()
|
|
252
|
+
self._cwd = os.fspath(cwd) if cwd is not None else os.getcwd()
|
|
253
|
+
self._default_timeout = timeout
|
|
254
|
+
self._nonce = secrets.token_hex(8)
|
|
255
|
+
self._sentinel = _sentinel_for(self._nonce)
|
|
256
|
+
self._sentinel_bytes_re = _sentinel_bytes_re(self._nonce)
|
|
257
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
258
|
+
self._lock: asyncio.Lock | None = None
|
|
259
|
+
self._buffer: bytes = b""
|
|
260
|
+
self._started = False
|
|
261
|
+
|
|
262
|
+
async def _ensure_started(self) -> None:
|
|
263
|
+
if self._started:
|
|
264
|
+
return
|
|
265
|
+
try:
|
|
266
|
+
self._proc = await asyncio.create_subprocess_shell(
|
|
267
|
+
"/bin/bash --norc --noprofile",
|
|
268
|
+
stdin=asyncio.subprocess.PIPE,
|
|
269
|
+
stdout=asyncio.subprocess.PIPE,
|
|
270
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
271
|
+
cwd=self._cwd,
|
|
272
|
+
env=_shell_env(),
|
|
273
|
+
)
|
|
274
|
+
except Exception as e:
|
|
275
|
+
raise SessionError(f"failed to spawn bash: {e}") from e
|
|
276
|
+
self._lock = asyncio.Lock()
|
|
277
|
+
self._started = True
|
|
278
|
+
try:
|
|
279
|
+
await self.run(":", timeout=self._default_timeout)
|
|
280
|
+
except SessionTimeoutError as e:
|
|
281
|
+
await self.kill()
|
|
282
|
+
raise SessionError(
|
|
283
|
+
f"bash session failed to initialize within {self._default_timeout}s"
|
|
284
|
+
) from e
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def alive(self) -> bool:
|
|
288
|
+
return self._proc is not None and self._proc.returncode is None
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def pid(self) -> int | None:
|
|
292
|
+
return self._proc.pid if self._proc is not None else None
|
|
293
|
+
|
|
294
|
+
async def run(self, command: str, *, timeout: float | None = None) -> CommandResult:
|
|
295
|
+
await self._ensure_started()
|
|
296
|
+
proc = self._proc
|
|
297
|
+
lock = self._lock
|
|
298
|
+
if proc is None or lock is None or proc.returncode is not None:
|
|
299
|
+
raise SessionError("session is not running")
|
|
300
|
+
if proc.stdin is None or proc.stdout is None:
|
|
301
|
+
raise SessionError("session pipes are not available")
|
|
302
|
+
t = self._default_timeout if timeout is None else timeout
|
|
303
|
+
async with lock:
|
|
304
|
+
full = (command + self._sentinel + "\n").encode()
|
|
305
|
+
start = time.perf_counter()
|
|
306
|
+
proc.stdin.write(full)
|
|
307
|
+
await proc.stdin.drain()
|
|
308
|
+
buffer = self._buffer
|
|
309
|
+
self._buffer = b""
|
|
310
|
+
deadline = time.monotonic() + t
|
|
311
|
+
while True:
|
|
312
|
+
m = self._sentinel_bytes_re.search(buffer)
|
|
313
|
+
if m:
|
|
314
|
+
break
|
|
315
|
+
remaining = deadline - time.monotonic()
|
|
316
|
+
if remaining <= 0:
|
|
317
|
+
raise SessionTimeoutError(
|
|
318
|
+
f"command timed out after {t}s",
|
|
319
|
+
partial_output=buffer.decode("utf-8", errors="replace"),
|
|
320
|
+
command=command,
|
|
321
|
+
)
|
|
322
|
+
try:
|
|
323
|
+
chunk = await asyncio.wait_for(
|
|
324
|
+
proc.stdout.read(4096), timeout=remaining
|
|
325
|
+
)
|
|
326
|
+
except asyncio.TimeoutError as e:
|
|
327
|
+
raise SessionTimeoutError(
|
|
328
|
+
f"command timed out after {t}s",
|
|
329
|
+
partial_output=buffer.decode("utf-8", errors="replace"),
|
|
330
|
+
command=command,
|
|
331
|
+
) from e
|
|
332
|
+
if not chunk:
|
|
333
|
+
raise SessionError(
|
|
334
|
+
f"bash exited unexpectedly during command: {command!r}"
|
|
335
|
+
)
|
|
336
|
+
buffer += chunk
|
|
337
|
+
duration_ms = (time.perf_counter() - start) * 1000.0
|
|
338
|
+
exit_code = int(m.group(1))
|
|
339
|
+
cwd = m.group(2).decode("utf-8", errors="replace")
|
|
340
|
+
line_start = buffer.rfind(b"\n", 0, m.start())
|
|
341
|
+
line_start = 0 if line_start == -1 else line_start
|
|
342
|
+
line_end_idx = buffer.find(b"\n", m.end())
|
|
343
|
+
if line_end_idx == -1:
|
|
344
|
+
line_end = len(buffer)
|
|
345
|
+
else:
|
|
346
|
+
line_end = line_end_idx + 1
|
|
347
|
+
before = buffer[:line_start]
|
|
348
|
+
self._buffer = buffer[line_end:]
|
|
349
|
+
output = (
|
|
350
|
+
before.decode("utf-8", errors="replace")
|
|
351
|
+
.replace("\r\n", "\n")
|
|
352
|
+
.rstrip()
|
|
353
|
+
)
|
|
354
|
+
return CommandResult(
|
|
355
|
+
command=command,
|
|
356
|
+
output=output,
|
|
357
|
+
exit_code=exit_code,
|
|
358
|
+
cwd=cwd,
|
|
359
|
+
duration_ms=duration_ms,
|
|
360
|
+
timed_out=False,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
async def start(self, command: str) -> None:
|
|
364
|
+
"""Send a command without a sentinel — fire and forget."""
|
|
365
|
+
await self._ensure_started()
|
|
366
|
+
proc = self._proc
|
|
367
|
+
if proc is None or proc.returncode is not None:
|
|
368
|
+
raise SessionError("session is not running")
|
|
369
|
+
if proc.stdin is None:
|
|
370
|
+
raise SessionError("session stdin is not available")
|
|
371
|
+
proc.stdin.write((command + "\n").encode())
|
|
372
|
+
await proc.stdin.drain()
|
|
373
|
+
|
|
374
|
+
async def read(self, timeout: float = 1.0) -> str:
|
|
375
|
+
"""Drain currently available output."""
|
|
376
|
+
await self._ensure_started()
|
|
377
|
+
proc = self._proc
|
|
378
|
+
if proc is None:
|
|
379
|
+
raise SessionError("session is not running")
|
|
380
|
+
if proc.stdout is None:
|
|
381
|
+
raise SessionError("session stdout is not available")
|
|
382
|
+
buf = self._buffer
|
|
383
|
+
self._buffer = b""
|
|
384
|
+
try:
|
|
385
|
+
chunk = await asyncio.wait_for(proc.stdout.read(4096), timeout=timeout)
|
|
386
|
+
if chunk:
|
|
387
|
+
buf += chunk
|
|
388
|
+
except asyncio.TimeoutError:
|
|
389
|
+
pass
|
|
390
|
+
while True:
|
|
391
|
+
try:
|
|
392
|
+
chunk = await asyncio.wait_for(proc.stdout.read(4096), timeout=0.01)
|
|
393
|
+
except asyncio.TimeoutError:
|
|
394
|
+
break
|
|
395
|
+
if not chunk:
|
|
396
|
+
break
|
|
397
|
+
buf += chunk
|
|
398
|
+
return buf.decode("utf-8", errors="replace")
|
|
399
|
+
|
|
400
|
+
async def kill(self) -> None:
|
|
401
|
+
"""Terminate the underlying bash process. Idempotent."""
|
|
402
|
+
proc = self._proc
|
|
403
|
+
if proc is None:
|
|
404
|
+
return
|
|
405
|
+
try:
|
|
406
|
+
if proc.stdin is not None and not proc.stdin.is_closing():
|
|
407
|
+
proc.stdin.close()
|
|
408
|
+
except Exception:
|
|
409
|
+
pass
|
|
410
|
+
if proc.returncode is None:
|
|
411
|
+
try:
|
|
412
|
+
proc.terminate()
|
|
413
|
+
except ProcessLookupError:
|
|
414
|
+
pass
|
|
415
|
+
try:
|
|
416
|
+
await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
417
|
+
except asyncio.TimeoutError:
|
|
418
|
+
try:
|
|
419
|
+
proc.kill()
|
|
420
|
+
except ProcessLookupError:
|
|
421
|
+
pass
|
|
422
|
+
try:
|
|
423
|
+
await asyncio.wait_for(proc.wait(), timeout=1.0)
|
|
424
|
+
except asyncio.TimeoutError:
|
|
425
|
+
pass
|
|
426
|
+
# Close the subprocess transport so its pipes don't try to clean up
|
|
427
|
+
# against an already-closed event loop at GC time.
|
|
428
|
+
try:
|
|
429
|
+
transport = getattr(proc, "_transport", None)
|
|
430
|
+
if transport is not None:
|
|
431
|
+
transport.close()
|
|
432
|
+
except Exception:
|
|
433
|
+
pass
|
|
434
|
+
self._proc = None
|
|
435
|
+
|
|
436
|
+
async def __aenter__(self) -> AsyncSession:
|
|
437
|
+
return self
|
|
438
|
+
|
|
439
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
440
|
+
await self.kill()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
__all__ = [
|
|
444
|
+
"CommandResult",
|
|
445
|
+
"Session",
|
|
446
|
+
"AsyncSession",
|
|
447
|
+
]
|
encode/tools.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Tool registry helpers for the relay() loop.
|
|
2
|
+
|
|
3
|
+
Splits the user's ``tools=`` list into:
|
|
4
|
+
|
|
5
|
+
- ``tool_dicts``: list[dict] sent to the model
|
|
6
|
+
- ``tool_index``: name -> Python callable (for execution)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable, Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import errors
|
|
18
|
+
from ._schema import callable_to_tool_dict
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_tools(
|
|
22
|
+
tools: Sequence[Callable[..., Any] | dict[str, Any]] | None,
|
|
23
|
+
*,
|
|
24
|
+
web_search: bool = False,
|
|
25
|
+
) -> tuple[list[dict[str, Any]], dict[str, Callable[..., Any]]]:
|
|
26
|
+
tool_dicts: list[dict[str, Any]] = []
|
|
27
|
+
tool_index: dict[str, Callable[..., Any]] = {}
|
|
28
|
+
|
|
29
|
+
if tools:
|
|
30
|
+
for t in tools:
|
|
31
|
+
if callable(t) and not isinstance(t, dict):
|
|
32
|
+
d = callable_to_tool_dict(t)
|
|
33
|
+
tool_dicts.append(d)
|
|
34
|
+
tool_index[d["function"]["name"]] = t
|
|
35
|
+
else:
|
|
36
|
+
td = dict(t)
|
|
37
|
+
tool_dicts.append(td)
|
|
38
|
+
# raw dict tools have no callable; nothing to register
|
|
39
|
+
|
|
40
|
+
if web_search:
|
|
41
|
+
if not any(td.get("type") == "web_search" for td in tool_dicts):
|
|
42
|
+
tool_dicts.append({"type": "web_search"})
|
|
43
|
+
|
|
44
|
+
return tool_dicts, tool_index
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def safe_call(
|
|
48
|
+
fn: Callable[..., Any], args: dict[str, Any]
|
|
49
|
+
) -> tuple[Any, str | None, float]:
|
|
50
|
+
"""Run a tool function, capturing any exception as ``{"error": repr(e)}``.
|
|
51
|
+
|
|
52
|
+
Returns ``(result_or_error_dict, error_repr_or_none, duration_ms)``.
|
|
53
|
+
"""
|
|
54
|
+
if inspect.iscoroutinefunction(fn):
|
|
55
|
+
raise TypeError(
|
|
56
|
+
f"async tool function {fn.__qualname__} cannot be used in sync relay(); use relay_async()"
|
|
57
|
+
)
|
|
58
|
+
t0 = time.perf_counter()
|
|
59
|
+
try:
|
|
60
|
+
result = fn(**args)
|
|
61
|
+
return result, None, (time.perf_counter() - t0) * 1000
|
|
62
|
+
except Exception as e:
|
|
63
|
+
return {"error": repr(e)}, repr(e), (time.perf_counter() - t0) * 1000
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def safe_call_async(
|
|
67
|
+
fn: Callable[..., Any], args: dict[str, Any]
|
|
68
|
+
) -> tuple[Any, str | None, float]:
|
|
69
|
+
t0 = time.perf_counter()
|
|
70
|
+
try:
|
|
71
|
+
if inspect.iscoroutinefunction(fn):
|
|
72
|
+
result = await fn(**args)
|
|
73
|
+
else:
|
|
74
|
+
result = fn(**args)
|
|
75
|
+
return result, None, (time.perf_counter() - t0) * 1000
|
|
76
|
+
except Exception as e:
|
|
77
|
+
return {"error": repr(e)}, repr(e), (time.perf_counter() - t0) * 1000
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def serialize_tool_result(result: Any) -> str:
|
|
81
|
+
"""JSON-serialize a tool result for the tool/function_call_output message."""
|
|
82
|
+
if isinstance(result, str):
|
|
83
|
+
return result
|
|
84
|
+
try:
|
|
85
|
+
return json.dumps(result, default=str)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
return json.dumps({"error": f"failed to serialize tool result: {e!r}"})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_arguments(raw: str | None) -> dict[str, Any]:
|
|
91
|
+
if not raw:
|
|
92
|
+
return {}
|
|
93
|
+
try:
|
|
94
|
+
parsed = json.loads(raw)
|
|
95
|
+
except json.JSONDecodeError as e:
|
|
96
|
+
raise errors.InvalidToolCallError(
|
|
97
|
+
f"tool call arguments are not valid JSON: {e!r}",
|
|
98
|
+
code="invalid_tool_call",
|
|
99
|
+
raw=raw,
|
|
100
|
+
) from e
|
|
101
|
+
if not isinstance(parsed, dict):
|
|
102
|
+
raise errors.InvalidToolCallError(
|
|
103
|
+
"tool call arguments must decode to a JSON object",
|
|
104
|
+
code="invalid_tool_call",
|
|
105
|
+
raw=raw,
|
|
106
|
+
)
|
|
107
|
+
return parsed
|