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.
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