okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
57a82be
main
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 ( 14Execute as Execute , 15HostSuccess as HostSuccess , 16HostFailure as HostFailure , 17HostReply as HostReply , 18Reply as Reply , 19Interrupt as Interrupt , 20Shutdown as Shutdown , 21Invoke as Invoke , 22Introspect as Introspect , 23Release as Release , 24State as State , 25JobStop as JobStop , 26Incoming as Incoming , 27SavedCell 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.""" 42yield from () 43return value 44 45 46class Text (str ): 47"""A result that is ready now. Awaiting it is harmless, so a synchronous 48call reads the same with or without `await`. `.content` and `.text` are the 49text itself, for code written against a response object.""" 50 51def __await__ (self ): 52return _ready (self ) 53 54@property 55def content (self )-> str : 56return str (self ) 57 58@property 59def text (self )-> str : 60return str (self ) 61 62 63class ReadyList (list ): 64"""A list result that is ready now; like Text, it may be awaited.""" 65 66def __await__ (self ): 67return _ready (self ) 68 69 70class Record (dict ): 71"""A result record: `item["id"]` and `item.id` both work, it prints and 72serializes as the plain dict it is, and it may be awaited like Text.""" 73 74__slots__ = () 75 76def __getattr__ (self ,name :str )-> object : 77try : 78return self [name ] 79except KeyError : 80raise AttributeError ( 81f" { type ( self ). __name__ } has no field { name !r } ; its fields are { ', ' . join ( self ) or 'none' } " 82 )from None 83 84def __await__ (self ): 85return _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.""" 93if not 0 < timeout <= MAX_JOB_SECONDS : 94raise ValueError ( 95f"timeout is in seconds: 0 < timeout <= { MAX_JOB_SECONDS } required," 96f" got { timeout :g } " 97+ (" (milliseconds?)" if timeout > MAX_JOB_SECONDS else "" ) 98 ) 99 100 101def excerpt ( 102text :str ,chars :int ,lines :int | None ,* ,end :bool ,clipped :bool = False 103)-> Text : 104"""A requested preview, never silently reduced to the retained window.""" 105recovery = "use job.read() for complete text or job.save(path) for exact bytes" 106if lines is None : 107if not 0 <= chars <= OUTPUT_PREVIEW : 108raise ValueError (f"0 <= n <= { OUTPUT_PREVIEW } for previews; { recovery } " ) 109if clipped and chars > len (text ): 110raise ValueError ( 111f"the retained preview cannot supply { chars } characters; { recovery } " 112 ) 113return Text ((text [- chars :]if end else text [:chars ])if chars else "" ) 114if lines < 0 : 115raise ValueError ("lines must be nonnegative" ) 116kept = text .splitlines (keepends = True ) 117# A window cut through a line cannot count that fragment as a whole line. 118if clipped and kept : 119if end : 120kept = kept [1 :] 121elif not kept [- 1 ].endswith (("\n" ,"\r" )): 122kept = kept [:- 1 ] 123if clipped and lines > len (kept ): 124raise ValueError ( 125f"the retained preview cannot supply { lines } lines; { recovery } " 126 ) 127return Text ("" .join (kept [- lines :]if end else kept [:lines ])if lines else "" ) 128 129 130class OutputCapture (Protocol ): 131data :bytearray # the retained start of the output, as written 132seen :int # bytes written, retained or not 133 134@property 135def retained (self )-> int : ... 136 137def read_bytes (self ,offset :int ,limit :int )-> bytes : ... 138 139def end_spill (self )-> None : ... 140 141def write (self ,text :str )-> None : ... 142 143def write_bytes (self ,data :bytes | bytearray | memoryview )-> None : ... 144 145def tail (self ,limit :int = 65536 )-> bytes : ... 146 147def read (self ,offset :int = 0 ,limit :int = 4000 )-> str : ... 148 149 150@dataclass( frozen = True ) 151class PythonApi : 152loop :asyncio .AbstractEventLoop 153host :Host 154HostError :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`. 157capture :Callable [[str ],OutputCapture ] 158preview :int 159send :Send 160on_shutdown :Callable [[Cleanup ],None ] 161background_handle :Callable [[type [object ]],None ] 162version :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). 165modules :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. 169watch_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. 172forget_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. 176attach_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. 179host_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. 182on_stop_job : ( 183Callable [[Callable [[str ],Awaitable [dict [str ,object ]| None ]]],None ]| None 184 )= None 185 186 187class PythonPlugin (Protocol ): 188def setup ( 189self ,api :PythonApi 190 )-> "dict[str, object] | Awaitable[dict[str, object]]" : ... 191 192 193async def load_plugins ( 194names :Sequence [str ],api :PythonApi ,namespace :dict [str ,object ] 195)-> None : 196"""Load explicitly trusted modules before the workspace enters sys.path. 197 198setup(api) returns public REPL bindings, or an awaitable that resolves them, 199so a plugin may call api.host while the kernel boots. Plugins use api.host 200for host RPC, api.on_shutdown for cleanup, and api.background_handle for 201nonblocking jobs. This is composition, not isolation: installed plugins 202execute trusted code. 203""" 204modules :list [str ]= [] 205for name in names : 206if not isinstance (name ,str )or not all ( 207part .isidentifier ()and not keyword .iskeyword (part ) 208for part in name .split ("." ) 209 ): 210raise ValueError (f"invalid Python plugin module: { name !r } " ) 211module = name if "." in name else "albedo_plugins." + name 212if module in modules : 213raise ValueError (f"duplicate Python plugin module: { module } " ) 214modules .append (module ) 215for module in modules : 216try : 217plugin = cast (PythonPlugin ,cast (object ,importlib .import_module (module ))) 218exports = plugin .setup (api ) 219if inspect .isawaitable (exports ): 220exports = await cast (Awaitable [dict [str ,object ]],exports ) 221if not isinstance (exports ,dict )or not all ( 222isinstance (name ,str ) 223and name .isidentifier () 224and not name .startswith ("_" ) 225and not keyword .iskeyword (name ) 226for name in exports 227 ): 228raise ValueError ( 229"setup(api) must return a dict of public Python bindings" 230 ) 231collisions = namespace .keys ()& exports .keys () 232if collisions : 233raise ValueError ( 234f"duplicate or reserved bindings: { ', ' . join ( sorted ( collisions )) } " 235 ) 236namespace .update (exports ) 237except Exception as error : 238raise RuntimeError (f"Python plugin { module } : { error } " )from error