okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnkernel: a job_stop frame lets /jobs stop remote background jobs57a82be

main
8.5 KiB238 linesraw
1"""Python plugin boundary; imported without starting a kernel."""
2
3from __future__ import annotations
4
5import asyncio
6from collections.abc import Awaitable, Callable, Sequence
7import importlib
8import inspect
9import keyword
10from dataclasses import dataclass
11from typing import Protocol, cast
12
13from albedo_protocol import (
14    Execute as Execute,
15    HostSuccess as HostSuccess,
16    HostFailure as HostFailure,
17    HostReply as HostReply,
18    Reply as Reply,
19    Interrupt as Interrupt,
20    Shutdown as Shutdown,
21    Invoke as Invoke,
22    Introspect as Introspect,
23    Release as Release,
24    State as State,
25    JobStop as JobStop,
26    Incoming as Incoming,
27    SavedCell as SavedCell,
28)
29
30RETAIN = 1024 * 1024  # output bytes kept per channel; a late pipe reads them
31OUTPUT_PREVIEW = 64 * 1024
32
33Host = Callable[[str, dict[str, object]], Awaitable[object]]
34Send = Callable[[dict[str, object]], None]
35# Shutdown callbacks run while the loop can still run code; returning an
36# awaitable lets a plugin clean up asynchronously.
37Cleanup = Callable[[], object]
38
39
40def _ready(value):
41    """`await` on a value that is already here: returns it without suspending."""
42    yield from ()
43    return value
44
45
46class Text(str):
47    """A result that is ready now. Awaiting it is harmless, so a synchronous
48    call reads the same with or without `await`. `.content` and `.text` are the
49    text itself, for code written against a response object."""
50
51    def __await__(self):
52        return _ready(self)
53
54    @property
55    def content(self) -> str:
56        return str(self)
57
58    @property
59    def text(self) -> str:
60        return str(self)
61
62
63class ReadyList(list):
64    """A list result that is ready now; like Text, it may be awaited."""
65
66    def __await__(self):
67        return _ready(self)
68
69
70class Record(dict):
71    """A result record: `item["id"]` and `item.id` both work, it prints and
72    serializes as the plain dict it is, and it may be awaited like Text."""
73
74    __slots__ = ()
75
76    def __getattr__(self, name: str) -> object:
77        try:
78            return self[name]
79        except KeyError:
80            raise AttributeError(
81                f"{type(self).__name__} has no field {name!r}; its fields are {', '.join(self) or 'none'}"
82            ) from None
83
84    def __await__(self):
85        return _ready(self)
86
87
88MAX_JOB_SECONDS = 86400  # a day: long builds fit, a deadline in ms does not
89
90
91def check_timeout(timeout: float) -> None:
92    """Raise unless a job timeout is within 0 < timeout <= MAX_JOB_SECONDS."""
93    if not 0 < timeout <= MAX_JOB_SECONDS:
94        raise ValueError(
95            f"timeout is in seconds: 0 < timeout <= {MAX_JOB_SECONDS} required,"
96            f" got {timeout:g}"
97            + (" (milliseconds?)" if timeout > MAX_JOB_SECONDS else "")
98        )
99
100
101def excerpt(
102    text: str, chars: int, lines: int | None, *, end: bool, clipped: bool = False
103) -> Text:
104    """A requested preview, never silently reduced to the retained window."""
105    recovery = "use job.read() for complete text or job.save(path) for exact bytes"
106    if lines is None:
107        if not 0 <= chars <= OUTPUT_PREVIEW:
108            raise ValueError(f"0 <= n <= {OUTPUT_PREVIEW} for previews; {recovery}")
109        if clipped and chars > len(text):
110            raise ValueError(
111                f"the retained preview cannot supply {chars} characters; {recovery}"
112            )
113        return Text((text[-chars:] if end else text[:chars]) if chars else "")
114    if lines < 0:
115        raise ValueError("lines must be nonnegative")
116    kept = text.splitlines(keepends=True)
117    # A window cut through a line cannot count that fragment as a whole line.
118    if clipped and kept:
119        if end:
120            kept = kept[1:]
121        elif not kept[-1].endswith(("\n", "\r")):
122            kept = kept[:-1]
123    if clipped and lines > len(kept):
124        raise ValueError(
125            f"the retained preview cannot supply {lines} lines; {recovery}"
126        )
127    return Text("".join(kept[-lines:] if end else kept[:lines]) if lines else "")
128
129
130class OutputCapture(Protocol):
131    data: bytearray  # the retained start of the output, as written
132    seen: int  # bytes written, retained or not
133
134    @property
135    def retained(self) -> int: ...
136
137    def read_bytes(self, offset: int, limit: int) -> bytes: ...
138
139    def end_spill(self) -> None: ...
140
141    def write(self, text: str) -> None: ...
142
143    def write_bytes(self, data: bytes | bytearray | memoryview) -> None: ...
144
145    def tail(self, limit: int = 65536) -> bytes: ...
146
147    def read(self, offset: int = 0, limit: int = 4000) -> str: ...
148
149
150@dataclass(frozen=True)
151class PythonApi:
152    loop: asyncio.AbstractEventLoop
153    host: Host
154    HostError: type[Exception]
155    # An output channel for one piece of background work, retained under that id
156    # so the session can read it back through `output`.
157    capture: Callable[[str], OutputCapture]
158    preview: int
159    send: Send
160    on_shutdown: Callable[[Cleanup], None]
161    background_handle: Callable[[type[object]], None]
162    version: int = 1
163    # The module list this kernel was started with, so a plugin can learn the
164    # session's tool selection (remote boots the same set on another machine).
165    modules: Sequence[str] = ()
166    # Registers one callback for a retained output channel, fired when the
167    # model reads it, so background work can withdraw a completion wake the
168    # read already satisfied. Absent when the host pre-dates the hook.
169    watch_output: Callable[[str, Callable[[], None]], None] | None = None
170    # Drops one retained output channel, so work a plugin runs for itself (a
171    # search job, say) never crowds the model's own output out of retention.
172    forget_output: Callable[[str], None] | None = None
173    # Attaches PNG, JPEG, or WebP bytes to the running cell's result, so the
174    # model sees the image; returns a short description, raises ValueError
175    # past the per-cell limits. Absent when the host pre-dates images.
176    attach_image: Callable[[bytes], str] | None = None
177    # A host call that blocks until its answer, for synchronous APIs over
178    # routes that answer at once. Absent when the host pre-dates it.
179    host_now: Callable[[str, dict[str, object]], object] | None = None
180    # Registers one handler to stop a background job (e.g. remote or fallback).
181    # Handler returns {"ok": True} or {"ok": False, "error": "..."}, or None if not found.
182    on_stop_job: (
183        Callable[[Callable[[str], Awaitable[dict[str, object] | None]]], None] | None
184    ) = None
185
186
187class PythonPlugin(Protocol):
188    def setup(
189        self, api: PythonApi
190    ) -> "dict[str, object] | Awaitable[dict[str, object]]": ...
191
192
193async def load_plugins(
194    names: Sequence[str], api: PythonApi, namespace: dict[str, object]
195) -> None:
196    """Load explicitly trusted modules before the workspace enters sys.path.
197
198    setup(api) returns public REPL bindings, or an awaitable that resolves them,
199    so a plugin may call api.host while the kernel boots. Plugins use api.host
200    for host RPC, api.on_shutdown for cleanup, and api.background_handle for
201    nonblocking jobs. This is composition, not isolation: installed plugins
202    execute trusted code.
203    """
204    modules: list[str] = []
205    for name in names:
206        if not isinstance(name, str) or not all(
207            part.isidentifier() and not keyword.iskeyword(part)
208            for part in name.split(".")
209        ):
210            raise ValueError(f"invalid Python plugin module: {name!r}")
211        module = name if "." in name else "albedo_plugins." + name
212        if module in modules:
213            raise ValueError(f"duplicate Python plugin module: {module}")
214        modules.append(module)
215    for module in modules:
216        try:
217            plugin = cast(PythonPlugin, cast(object, importlib.import_module(module)))
218            exports = plugin.setup(api)
219            if inspect.isawaitable(exports):
220                exports = await cast(Awaitable[dict[str, object]], exports)
221            if not isinstance(exports, dict) or not all(
222                isinstance(name, str)
223                and name.isidentifier()
224                and not name.startswith("_")
225                and not keyword.iskeyword(name)
226                for name in exports
227            ):
228                raise ValueError(
229                    "setup(api) must return a dict of public Python bindings"
230                )
231            collisions = namespace.keys() & exports.keys()
232            if collisions:
233                raise ValueError(
234                    f"duplicate or reserved bindings: {', '.join(sorted(collisions))}"
235                )
236            namespace.update(exports)
237        except Exception as error:
238            raise RuntimeError(f"Python plugin {module}: {error}") from error