@code-yeongyu/senpi-codemode 2026.10.7 → 2026.10.9
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.
- package/CHANGELOG.md +38 -0
- package/README.md +67 -5
- package/package.json +5 -4
- package/src/bridge/http-server.ts +9 -1
- package/src/bridge/kernel-tools-protocol.ts +8 -0
- package/src/bridge/protocol.ts +4 -0
- package/src/bridges/eval-virtual-schemas.ts +22 -0
- package/src/environments/editable-check.ts +116 -0
- package/src/environments/install-lock.ts +223 -0
- package/src/environments/py-environment.ts +98 -0
- package/src/environments/py-installer.ts +247 -0
- package/src/environments/python-environments.ts +85 -0
- package/src/environments/revision-store.ts +110 -0
- package/src/extension/bridge-tool-call.ts +2 -0
- package/src/extension/cell-kernel-tools.ts +28 -0
- package/src/extension/kernel-registration.ts +5 -0
- package/src/extension/kernel-replacement.ts +20 -0
- package/src/extension/runtime-factory.ts +12 -0
- package/src/extension/session-manager-proxy.ts +6 -0
- package/src/extension/session-manager.ts +24 -2
- package/src/index.ts +1 -0
- package/src/kernels/jl/prelude.jl +6 -1
- package/src/kernels/js/context-manager.ts +15 -1
- package/src/kernels/js/kernel-tools-errors.js +2 -0
- package/src/kernels/js/kernel-tools-errors.ts +2 -0
- package/src/kernels/js/local-module-loader.ts +9 -4
- package/src/kernels/js/worker-runtime.js +3 -1
- package/src/kernels/js/workpool.js +5 -2
- package/src/kernels/py/kernel-contract.ts +11 -2
- package/src/kernels/py/kernel-tools-host.ts +97 -0
- package/src/kernels/py/kernel.ts +48 -10
- package/src/kernels/py/prelude.py +771 -18
- package/src/kernels/py/process.ts +15 -4
- package/src/kernels/py/startup.ts +58 -20
- package/src/kernels/py/transport.ts +49 -8
- package/src/kernels/rb/workpool.rb +7 -1
- package/src/kernels/sandbox/vendor/pi-codemode/LICENSE +21 -0
- package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +42 -0
- package/src/kernels/sandbox/vendor/pi-codemode/identifier.ts +14 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +514 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +431 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/protocol.ts +61 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +231 -0
- package/src/kernels/sandbox/vendor/pi-codemode/types.ts +185 -0
- package/src/kernels/sandbox/vendor/pi-codemode/wasm.ts +37 -0
- package/src/kernels/shared/cell-source-at-start.ts +18 -0
- package/src/kernels/shared/kernel-tools-pump.ts +6 -0
- package/src/kernels/shared/process-cpu.ts +69 -0
- package/src/kernels/shared/subprocess-queue.ts +1 -0
- package/src/tool/cell-handler.ts +1 -1
- package/src/tool/eval-tool-options.ts +3 -0
- package/src/tool/load-cell.ts +73 -0
- package/src/tool/magic-cell-host.ts +65 -0
- package/src/tool/magic-cells.ts +65 -0
- package/src/tool/run-eval-cell.ts +30 -0
- package/src/tool/types.ts +27 -0
|
@@ -13,6 +13,7 @@ import asyncio # noqa: ANYIO_OK — stdlib-only embedded kernel runner.
|
|
|
13
13
|
import base64
|
|
14
14
|
import codecs
|
|
15
15
|
import contextlib
|
|
16
|
+
import importlib
|
|
16
17
|
import gc
|
|
17
18
|
import inspect
|
|
18
19
|
import io
|
|
@@ -26,12 +27,18 @@ import subprocess
|
|
|
26
27
|
import time
|
|
27
28
|
import traceback
|
|
28
29
|
import types
|
|
30
|
+
import typing
|
|
29
31
|
import urllib.error
|
|
30
32
|
import urllib.request
|
|
31
33
|
import uuid
|
|
32
34
|
from collections.abc import Iterable
|
|
33
35
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
34
36
|
from pathlib import Path
|
|
37
|
+
import contextvars
|
|
38
|
+
import queue
|
|
39
|
+
import threading
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from typing import Iterator
|
|
35
42
|
from threading import Lock, Thread
|
|
36
43
|
from typing import Any, Callable, Union
|
|
37
44
|
from urllib.parse import unquote
|
|
@@ -368,7 +375,15 @@ _BRIDGE_SOCKET_TIMEOUT_SECONDS = 60
|
|
|
368
375
|
_WAIT_SOCKET_GRACE_SECONDS = 30
|
|
369
376
|
|
|
370
377
|
|
|
378
|
+
# The host's secret for the run in this context. Every host call carries it, so the host gives the call that run's
|
|
379
|
+
# kernel tools; copied contexts (parallel workers) keep it, plain threads do not, and the host forgets it at settle.
|
|
380
|
+
CURRENT_CELL_TOKEN: contextvars.ContextVar[str | None] = contextvars.ContextVar("senpi_cell_token", default=None)
|
|
381
|
+
|
|
382
|
+
|
|
371
383
|
def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | None = _BRIDGE_SOCKET_TIMEOUT_SECONDS) -> Any:
|
|
384
|
+
cell_token = CURRENT_CELL_TOKEN.get()
|
|
385
|
+
if path == "/call" and cell_token is not None and "cellToken" not in payload:
|
|
386
|
+
payload = {**payload, "cellToken": cell_token}
|
|
372
387
|
port = CONNECTION.get("port")
|
|
373
388
|
token = CONNECTION.get("token")
|
|
374
389
|
if not isinstance(port, int) or not isinstance(token, str):
|
|
@@ -380,15 +395,20 @@ def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | N
|
|
|
380
395
|
headers={"authorization": f"Bearer {token}", "content-type": "application/json"},
|
|
381
396
|
method="POST",
|
|
382
397
|
)
|
|
398
|
+
_guard_host_call(payload)
|
|
383
399
|
emit_status(TIMEOUT_PAUSE_OP, force=True)
|
|
384
400
|
try:
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
401
|
+
with KERNEL_TOOL_TOKEN.parked():
|
|
402
|
+
try:
|
|
403
|
+
with _BRIDGE_OPENER.open(request, timeout=socket_timeout) as response:
|
|
404
|
+
response_data = response.read()
|
|
405
|
+
except urllib.error.HTTPError as exc:
|
|
406
|
+
response_data = exc.read()
|
|
390
407
|
finally:
|
|
391
408
|
emit_status(TIMEOUT_RESUME_OP, force=True)
|
|
409
|
+
invocation = CURRENT.get()
|
|
410
|
+
if invocation is not None and invocation.cancelled.is_set():
|
|
411
|
+
raise Cancelled(CANCELLED_AT_PARK_POINT)
|
|
392
412
|
|
|
393
413
|
try:
|
|
394
414
|
body = json.loads(response_data.decode("utf-8"))
|
|
@@ -402,6 +422,591 @@ def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | N
|
|
|
402
422
|
raise PreludeRuntimeError(str(error))
|
|
403
423
|
|
|
404
424
|
|
|
425
|
+
# --- Kernel tools (`@tool`): inference, admission token, output routing, registry and serving threads. ---
|
|
426
|
+
# Admission is one interpreter-owner token: the main thread holds it while a cell runs and releases it only inside a
|
|
427
|
+
# host bridge call or while idle; a callback runs only while holding it, so a cell busy in pure computation never yields.
|
|
428
|
+
|
|
429
|
+
MCP_TOOL_NAME_MAX_LENGTH = 64
|
|
430
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
431
|
+
RESERVED_TOOL_NAMES = frozenset({"__agent__", "__output__", "__schema__", "defined", "undefine"})
|
|
432
|
+
_NONE_TYPE = type(None)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class KernelToolError(Exception):
|
|
436
|
+
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
|
|
437
|
+
super().__init__(message)
|
|
438
|
+
self.code = code
|
|
439
|
+
self.details = details
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def tool_key(name: str) -> str:
|
|
443
|
+
return re.sub(r"[^a-zA-Z0-9_-]", "_", name).replace("-", "_")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def validate_tool_name(name: Any) -> str:
|
|
447
|
+
if not isinstance(name, str) or not name:
|
|
448
|
+
raise KernelToolError("invalid_tool_definition", "@tool name must be a non-empty string")
|
|
449
|
+
if not _NAME_RE.match(name) or len(name) > MCP_TOOL_NAME_MAX_LENGTH:
|
|
450
|
+
raise KernelToolError("invalid_tool_definition", f"Kernel tool name must match MCP name grammar: {name}")
|
|
451
|
+
if tool_key(name) in {tool_key(reserved) for reserved in RESERVED_TOOL_NAMES}:
|
|
452
|
+
raise KernelToolError("reserved_tool_name", f"Kernel tool name is reserved: {name}")
|
|
453
|
+
return name
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _resolved_hints(fn: Callable[..., Any], params: list[inspect.Parameter]) -> dict[str, Any]:
|
|
457
|
+
# Only parameters shape the input schema; the return annotation may name a class defined later.
|
|
458
|
+
def parameters_only() -> None: ...
|
|
459
|
+
|
|
460
|
+
parameters_only.__annotations__ = {
|
|
461
|
+
param.name: param.annotation for param in params if param.annotation is not inspect.Parameter.empty
|
|
462
|
+
}
|
|
463
|
+
try:
|
|
464
|
+
return typing.get_type_hints(parameters_only, globalns=getattr(fn, "__globals__", None), include_extras=True)
|
|
465
|
+
except Exception as exc: # noqa: BROAD_EXCEPT_OK — any resolution failure is reported with the annotation that caused it.
|
|
466
|
+
globalns = getattr(fn, "__globals__", {})
|
|
467
|
+
for param in params:
|
|
468
|
+
annotation = getattr(param.annotation, "__forward_arg__", param.annotation)
|
|
469
|
+
if not isinstance(annotation, str):
|
|
470
|
+
continue
|
|
471
|
+
try:
|
|
472
|
+
eval(annotation, globalns) # noqa: S307 — the same evaluation get_type_hints performs, one annotation at a time.
|
|
473
|
+
except Exception as inner: # noqa: BROAD_EXCEPT_OK — names the annotation that failed.
|
|
474
|
+
raise KernelToolError(
|
|
475
|
+
"invalid_tool_definition",
|
|
476
|
+
f"can't resolve the annotation {annotation!r} on parameter {param.name!r} ({inner}); pass schema=",
|
|
477
|
+
) from inner
|
|
478
|
+
raise KernelToolError("invalid_tool_definition", f"can't resolve the type hints ({exc}); pass schema=") from exc
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _schema_for(annotation: Any, where: str) -> dict[str, Any]:
|
|
482
|
+
if annotation is inspect.Parameter.empty or annotation is typing.Any:
|
|
483
|
+
return {}
|
|
484
|
+
origin = typing.get_origin(annotation)
|
|
485
|
+
args = typing.get_args(annotation)
|
|
486
|
+
if origin is typing.Annotated:
|
|
487
|
+
schema = _schema_for(args[0], where)
|
|
488
|
+
description = next((item for item in args[1:] if isinstance(item, str)), None)
|
|
489
|
+
return {**schema, "description": description} if description is not None else schema
|
|
490
|
+
if annotation is _NONE_TYPE or annotation is None:
|
|
491
|
+
return {"type": "null"}
|
|
492
|
+
if annotation is bool:
|
|
493
|
+
return {"type": "boolean"}
|
|
494
|
+
if annotation is int:
|
|
495
|
+
return {"type": "integer"}
|
|
496
|
+
if annotation is float:
|
|
497
|
+
return {"type": "number"}
|
|
498
|
+
if annotation is str:
|
|
499
|
+
return {"type": "string"}
|
|
500
|
+
if annotation is list or annotation is typing.List:
|
|
501
|
+
return {"type": "array"}
|
|
502
|
+
if annotation is dict or annotation is typing.Dict:
|
|
503
|
+
return {"type": "object"}
|
|
504
|
+
if origin is list:
|
|
505
|
+
return {"type": "array", "items": _schema_for(args[0], where)} if args else {"type": "array"}
|
|
506
|
+
if origin is dict:
|
|
507
|
+
if args and args[0] is not str:
|
|
508
|
+
raise KernelToolError("invalid_tool_definition", f"{where}: dict keys must be str")
|
|
509
|
+
return {"type": "object", "additionalProperties": _schema_for(args[1], where)} if args else {"type": "object"}
|
|
510
|
+
if origin is typing.Literal:
|
|
511
|
+
if not all(isinstance(value, (str, int, float, bool)) or value is None for value in args):
|
|
512
|
+
raise KernelToolError("invalid_tool_definition", f"{where}: Literal values must be JSON scalars")
|
|
513
|
+
return {"enum": list(args)}
|
|
514
|
+
if origin is typing.Union or origin is getattr(types, "UnionType", None):
|
|
515
|
+
return {"anyOf": [_schema_for(item, where) for item in args]}
|
|
516
|
+
raise KernelToolError("invalid_tool_definition", f"{where}: unsupported annotation {annotation!r}; pass schema=")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _json_default(value: Any, where: str) -> Any:
|
|
520
|
+
if callable(value):
|
|
521
|
+
raise KernelToolError("invalid_tool_definition", f"{where}: default must be a JSON value, not a callable")
|
|
522
|
+
try:
|
|
523
|
+
json.dumps(value, allow_nan=False)
|
|
524
|
+
except (TypeError, ValueError) as exc:
|
|
525
|
+
raise KernelToolError("invalid_tool_definition", f"{where}: default must be a JSON value ({exc})") from exc
|
|
526
|
+
return value
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _description(fn: Callable[..., Any], explicit: Any) -> str:
|
|
530
|
+
if explicit is not None:
|
|
531
|
+
if not isinstance(explicit, str):
|
|
532
|
+
raise KernelToolError("invalid_tool_definition", "@tool description must be a string")
|
|
533
|
+
return explicit
|
|
534
|
+
doc = inspect.getdoc(fn) or ""
|
|
535
|
+
return doc.split("\n\n", 1)[0].strip()
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _signature(fn: Callable[..., Any]) -> inspect.Signature:
|
|
539
|
+
# Python 3.14 evaluates annotations lazily; asking for forward references keeps a return type that names
|
|
540
|
+
# a class defined later from failing here.
|
|
541
|
+
try:
|
|
542
|
+
import annotationlib
|
|
543
|
+
|
|
544
|
+
return inspect.signature(fn, annotation_format=annotationlib.Format.FORWARDREF)
|
|
545
|
+
except ImportError:
|
|
546
|
+
return inspect.signature(fn)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def infer_tool(fn: Any, *, name: Any = None, description: Any = None, schema: Any = None) -> dict[str, Any]:
|
|
550
|
+
if not callable(fn) or not hasattr(fn, "__name__"):
|
|
551
|
+
raise KernelToolError("invalid_tool_definition", "@tool requires a named function")
|
|
552
|
+
tool_name = validate_tool_name(fn.__name__ if name is None else name)
|
|
553
|
+
params = list(_signature(fn).parameters.values())
|
|
554
|
+
for param in params:
|
|
555
|
+
if param.kind is inspect.Parameter.POSITIONAL_ONLY:
|
|
556
|
+
raise KernelToolError("invalid_tool_definition", "positional-only parameters are not supported")
|
|
557
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
558
|
+
raise KernelToolError("invalid_tool_definition", f"*args and **kwargs are not supported ({param.name})")
|
|
559
|
+
names = [param.name for param in params]
|
|
560
|
+
text = _description(fn, description)
|
|
561
|
+
if schema is not None:
|
|
562
|
+
if not isinstance(schema, dict) or schema.get("type") != "object" or not isinstance(schema.get("properties"), dict):
|
|
563
|
+
raise KernelToolError("invalid_tool_definition", "@tool schema must be a JSON object schema")
|
|
564
|
+
if sorted(schema["properties"]) != sorted(names):
|
|
565
|
+
raise KernelToolError("invalid_tool_definition", "@tool schema properties must match parameters")
|
|
566
|
+
return {"name": tool_name, "description": text, "input_schema": schema, "params": names}
|
|
567
|
+
hints = _resolved_hints(fn, params)
|
|
568
|
+
properties: dict[str, Any] = {}
|
|
569
|
+
required: list[str] = []
|
|
570
|
+
for param in params:
|
|
571
|
+
where = f"parameter {param.name!r}"
|
|
572
|
+
prop = _schema_for(hints.get(param.name, param.annotation), where)
|
|
573
|
+
if param.default is inspect.Parameter.empty:
|
|
574
|
+
required.append(param.name)
|
|
575
|
+
else:
|
|
576
|
+
prop = {**prop, "default": _json_default(param.default, where)}
|
|
577
|
+
properties[param.name] = prop
|
|
578
|
+
input_schema: dict[str, Any] = {"type": "object", "properties": properties, "additionalProperties": False}
|
|
579
|
+
if required:
|
|
580
|
+
input_schema["required"] = required
|
|
581
|
+
return {"name": tool_name, "description": text, "input_schema": input_schema, "params": names}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
TOKEN_GROUP: contextvars.ContextVar[int | None] = contextvars.ContextVar("senpi_kernel_token_group", default=None)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _token_key() -> int:
|
|
588
|
+
group = TOKEN_GROUP.get()
|
|
589
|
+
return group if group is not None else threading.get_ident()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
class OwnerToken:
|
|
593
|
+
"""
|
|
594
|
+
The right to run user code, held by the cell or one kernel tool call at a time. Its key is the thread
|
|
595
|
+
that acquired it, inherited by that thread's parallel()/pipeline() workers, so a host call from any of
|
|
596
|
+
them parks the token for the whole group; the group takes it back once its last host call returns.
|
|
597
|
+
"""
|
|
598
|
+
|
|
599
|
+
def __init__(self) -> None:
|
|
600
|
+
self._cond = threading.Condition()
|
|
601
|
+
self._owner: int | None = None
|
|
602
|
+
self._depth = 0
|
|
603
|
+
self._parked: dict[int, list[int]] = {}
|
|
604
|
+
|
|
605
|
+
def acquire(self) -> None:
|
|
606
|
+
me = _token_key()
|
|
607
|
+
with self._cond:
|
|
608
|
+
while self._owner not in (None, me):
|
|
609
|
+
self._cond.wait()
|
|
610
|
+
self._owner = me
|
|
611
|
+
self._depth += 1
|
|
612
|
+
|
|
613
|
+
def release(self) -> None:
|
|
614
|
+
with self._cond:
|
|
615
|
+
if self._owner != _token_key():
|
|
616
|
+
return
|
|
617
|
+
self._depth -= 1
|
|
618
|
+
if self._depth == 0:
|
|
619
|
+
self._owner = None
|
|
620
|
+
self._cond.notify_all()
|
|
621
|
+
|
|
622
|
+
@contextlib.contextmanager
|
|
623
|
+
def parked(self) -> Iterator[None]:
|
|
624
|
+
me = _token_key()
|
|
625
|
+
with self._cond:
|
|
626
|
+
record = self._parked.get(me)
|
|
627
|
+
if self._owner == me:
|
|
628
|
+
self._parked[me] = [1, self._depth]
|
|
629
|
+
self._owner, self._depth = None, 0
|
|
630
|
+
self._cond.notify_all()
|
|
631
|
+
mine = True
|
|
632
|
+
elif record is not None:
|
|
633
|
+
record[0] += 1
|
|
634
|
+
mine = True
|
|
635
|
+
else:
|
|
636
|
+
mine = False
|
|
637
|
+
try:
|
|
638
|
+
yield
|
|
639
|
+
finally:
|
|
640
|
+
if mine:
|
|
641
|
+
with self._cond:
|
|
642
|
+
record = self._parked[me]
|
|
643
|
+
record[0] -= 1
|
|
644
|
+
if record[0] == 0:
|
|
645
|
+
while self._owner is not None:
|
|
646
|
+
self._cond.wait()
|
|
647
|
+
del self._parked[me]
|
|
648
|
+
self._owner, self._depth = me, record[1]
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
class ThreadRoutedStream(io.TextIOBase):
|
|
652
|
+
"""sys.stdout / sys.stderr: each writer thread's text goes to the buffer it registered, else the cell's."""
|
|
653
|
+
|
|
654
|
+
def __init__(self, stderr: bool) -> None:
|
|
655
|
+
self._stderr = stderr
|
|
656
|
+
self._targets: dict[int, io.StringIO] = {}
|
|
657
|
+
# Followed by the parallel()/pipeline() workers a capturing thread starts (they run in a copy of its context).
|
|
658
|
+
self._inherited: contextvars.ContextVar[io.StringIO | None] = contextvars.ContextVar(
|
|
659
|
+
f"senpi_output_{'stderr' if stderr else 'stdout'}", default=None
|
|
660
|
+
)
|
|
661
|
+
self.cell_target: io.StringIO | None = None
|
|
662
|
+
|
|
663
|
+
@contextlib.contextmanager
|
|
664
|
+
def capture(self, buffer: io.StringIO, *, cell: bool) -> Iterator[None]:
|
|
665
|
+
me = threading.get_ident()
|
|
666
|
+
previous = self._targets.get(me)
|
|
667
|
+
self._targets[me] = buffer
|
|
668
|
+
inherited = self._inherited.set(buffer)
|
|
669
|
+
if cell:
|
|
670
|
+
self.cell_target = buffer
|
|
671
|
+
try:
|
|
672
|
+
yield
|
|
673
|
+
finally:
|
|
674
|
+
self._inherited.reset(inherited)
|
|
675
|
+
if previous is None:
|
|
676
|
+
self._targets.pop(me, None)
|
|
677
|
+
else:
|
|
678
|
+
self._targets[me] = previous
|
|
679
|
+
if cell:
|
|
680
|
+
self.cell_target = None
|
|
681
|
+
|
|
682
|
+
def write(self, text: str) -> int:
|
|
683
|
+
target = self._targets.get(threading.get_ident()) or self._inherited.get() or self.cell_target
|
|
684
|
+
if target is None:
|
|
685
|
+
return sys.__stderr__.write(text)
|
|
686
|
+
return target.write(text)
|
|
687
|
+
|
|
688
|
+
def writable(self) -> bool:
|
|
689
|
+
return True
|
|
690
|
+
|
|
691
|
+
@property
|
|
692
|
+
def encoding(self) -> None:
|
|
693
|
+
return None
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
@dataclass
|
|
697
|
+
class _Entry:
|
|
698
|
+
name: str
|
|
699
|
+
fn: Callable[..., Any]
|
|
700
|
+
description: str
|
|
701
|
+
input_schema: dict[str, Any]
|
|
702
|
+
params: list[str]
|
|
703
|
+
revision: int
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
class Registry:
|
|
707
|
+
def __init__(self, on_change: Callable[[list[str]], None]) -> None:
|
|
708
|
+
self._lock = threading.Lock()
|
|
709
|
+
self._entries: dict[str, _Entry] = {}
|
|
710
|
+
# Revisions outlive undefine: a redefined name never reuses a revision an old descriptor carries.
|
|
711
|
+
self._revisions: dict[str, int] = {}
|
|
712
|
+
self._on_change = on_change
|
|
713
|
+
self.generation = 1
|
|
714
|
+
|
|
715
|
+
def define(self, fn: Any, *, name: Any = None, description: Any = None, schema: Any = None) -> Any:
|
|
716
|
+
inferred = infer_tool(fn, name=name, description=description, schema=schema)
|
|
717
|
+
key = tool_key(inferred["name"])
|
|
718
|
+
with self._lock:
|
|
719
|
+
existing = self._entries.get(key)
|
|
720
|
+
if existing is not None and existing.name != inferred["name"]:
|
|
721
|
+
raise KernelToolError("tool_name_collision", f"Kernel tool name collides: {inferred['name']}")
|
|
722
|
+
revision = self._revisions.get(key, 0) + 1
|
|
723
|
+
self._revisions[key] = revision
|
|
724
|
+
self._entries[key] = _Entry(fn=fn, revision=revision, **inferred)
|
|
725
|
+
names = sorted(entry.name for entry in self._entries.values())
|
|
726
|
+
self._on_change(names)
|
|
727
|
+
return fn
|
|
728
|
+
|
|
729
|
+
def undefine(self, name: Any) -> bool:
|
|
730
|
+
if not isinstance(name, str):
|
|
731
|
+
return False
|
|
732
|
+
with self._lock:
|
|
733
|
+
removed = self._entries.pop(tool_key(name), None) is not None
|
|
734
|
+
names = sorted(entry.name for entry in self._entries.values())
|
|
735
|
+
if removed:
|
|
736
|
+
self._on_change(names)
|
|
737
|
+
return removed
|
|
738
|
+
|
|
739
|
+
def defined(self) -> list[str]:
|
|
740
|
+
with self._lock:
|
|
741
|
+
return sorted(entry.name for entry in self._entries.values())
|
|
742
|
+
|
|
743
|
+
def describe(self, names: list[str]) -> list[dict[str, Any]]:
|
|
744
|
+
results = []
|
|
745
|
+
with self._lock:
|
|
746
|
+
for name in names:
|
|
747
|
+
entry = self._entries.get(tool_key(name))
|
|
748
|
+
if entry is None:
|
|
749
|
+
error = {"code": "kernel_tool_missing", "message": f"Kernel tool is not defined: {name}"}
|
|
750
|
+
results.append({"name": name, "ok": False, "error": error})
|
|
751
|
+
continue
|
|
752
|
+
descriptor = {"name": entry.name, "description": entry.description, "input_schema": entry.input_schema,
|
|
753
|
+
"language": "py", "kernel_generation": self.generation, "definition_revision": entry.revision}
|
|
754
|
+
results.append({"name": name, "ok": True, "descriptor": descriptor})
|
|
755
|
+
return results
|
|
756
|
+
|
|
757
|
+
def resolve(self, request: dict[str, Any]) -> _Entry:
|
|
758
|
+
if request.get("kernel_generation") != self.generation:
|
|
759
|
+
raise KernelToolError("kernel_tool_stale", "Kernel tool descriptor generation is stale")
|
|
760
|
+
with self._lock:
|
|
761
|
+
entry = self._entries.get(tool_key(str(request.get("name", ""))))
|
|
762
|
+
if entry is None:
|
|
763
|
+
raise KernelToolError("kernel_tool_missing", f"Kernel tool is not defined: {request.get('name')}")
|
|
764
|
+
if entry.revision != request.get("definition_revision"):
|
|
765
|
+
raise KernelToolError("kernel_tool_stale", "Kernel tool descriptor revision is stale")
|
|
766
|
+
return entry
|
|
767
|
+
|
|
768
|
+
def is_live(self, entry: _Entry) -> bool:
|
|
769
|
+
with self._lock:
|
|
770
|
+
return self._entries.get(tool_key(entry.name)) is entry
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
class Cancelled(BaseException):
|
|
774
|
+
pass
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
@dataclass
|
|
778
|
+
class Invocation:
|
|
779
|
+
request_id: str
|
|
780
|
+
call_id: str
|
|
781
|
+
scope: Any
|
|
782
|
+
cancelled: threading.Event = field(default_factory=threading.Event)
|
|
783
|
+
task: tuple[asyncio.AbstractEventLoop, asyncio.Task[Any]] | None = None
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
CURRENT: contextvars.ContextVar[Invocation | None] = contextvars.ContextVar("senpi_kernel_tool_call", default=None)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def host_call_refusal(scope: Any, tool_name: str) -> str | None:
|
|
790
|
+
tools = scope.get("tools") if isinstance(scope, dict) else None
|
|
791
|
+
if not isinstance(tools, dict):
|
|
792
|
+
return None
|
|
793
|
+
deny, allow = tools.get("deny"), tools.get("allow")
|
|
794
|
+
if deny is not None and (not isinstance(deny, list) or tool_name in deny):
|
|
795
|
+
return "deny"
|
|
796
|
+
if allow is not None and (not isinstance(allow, list) or tool_name not in allow):
|
|
797
|
+
return "allow"
|
|
798
|
+
return None
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def jsonable(value: Any) -> Any:
|
|
802
|
+
try:
|
|
803
|
+
json.dumps(value, allow_nan=False)
|
|
804
|
+
except (TypeError, ValueError):
|
|
805
|
+
return repr(value)
|
|
806
|
+
return value
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def validate_args(schema: dict[str, Any], args: Any) -> dict[str, Any]:
|
|
810
|
+
if not isinstance(args, dict):
|
|
811
|
+
raise KernelToolError("invalid_tool_definition", "kernel tool args must be a JSON object")
|
|
812
|
+
missing = [key for key in schema.get("required", []) if key not in args]
|
|
813
|
+
if missing:
|
|
814
|
+
raise KernelToolError("invalid_tool_definition", f"kernel tool args are missing: {', '.join(missing)}")
|
|
815
|
+
properties = schema.get("properties", {})
|
|
816
|
+
unknown = [key for key in args if key not in properties]
|
|
817
|
+
if unknown:
|
|
818
|
+
raise KernelToolError("invalid_tool_definition", f"kernel tool args are not parameters: {', '.join(unknown)}")
|
|
819
|
+
return args
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def is_coroutine_function(fn: Callable[..., Any]) -> bool:
|
|
823
|
+
return inspect.iscoroutinefunction(fn)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
CANCELLED_AT_PARK_POINT = "cancelled: took effect at a host park point"
|
|
827
|
+
CANCELLED_AT_AWAIT = "cancelled: took effect at an await point"
|
|
828
|
+
CANCELLED_BEFORE_START = "cancelled: the call never started"
|
|
829
|
+
CANCELLED_RESULT_DROPPED = "cancelled: the call finished after cancellation; its result was dropped"
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
class KernelToolRunner:
|
|
833
|
+
"""Answers describe from the reader thread and runs each invocation on its own thread under the token."""
|
|
834
|
+
|
|
835
|
+
def __init__(
|
|
836
|
+
self,
|
|
837
|
+
emit: Callable[[dict[str, Any]], None],
|
|
838
|
+
token: OwnerToken,
|
|
839
|
+
registry: Registry,
|
|
840
|
+
streams: tuple[ThreadRoutedStream, ThreadRoutedStream],
|
|
841
|
+
) -> None:
|
|
842
|
+
self._emit = emit
|
|
843
|
+
self._token = token
|
|
844
|
+
self._registry = registry
|
|
845
|
+
self._streams = streams
|
|
846
|
+
self._lock = threading.Lock()
|
|
847
|
+
self._invocations: dict[str, Invocation] = {}
|
|
848
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
849
|
+
self._closed = False
|
|
850
|
+
|
|
851
|
+
def handle(self, message: dict[str, Any]) -> bool:
|
|
852
|
+
kind = message.get("type")
|
|
853
|
+
if kind == "kernel-tool-describe":
|
|
854
|
+
names = [str(name) for name in message.get("names", [])]
|
|
855
|
+
self._emit({"type": "kernel-tool-describe-reply", "requestId": message.get("requestId"), "ok": True,
|
|
856
|
+
"results": self._registry.describe(names)})
|
|
857
|
+
return True
|
|
858
|
+
if kind == "kernel-tool-invoke":
|
|
859
|
+
self._start(message)
|
|
860
|
+
return True
|
|
861
|
+
if kind == "kernel-tool-cancel":
|
|
862
|
+
self._cancel(str(message.get("requestId", "")))
|
|
863
|
+
return True
|
|
864
|
+
return False
|
|
865
|
+
|
|
866
|
+
def close(self) -> None:
|
|
867
|
+
with self._lock:
|
|
868
|
+
self._closed = True
|
|
869
|
+
pending = list(self._invocations.values())
|
|
870
|
+
for invocation in pending:
|
|
871
|
+
invocation.cancelled.set()
|
|
872
|
+
|
|
873
|
+
def _start(self, message: dict[str, Any]) -> None:
|
|
874
|
+
invocation = Invocation(request_id=str(message.get("requestId", "")), call_id=str(message.get("call_id", "")),
|
|
875
|
+
scope=message.get("scope"))
|
|
876
|
+
with self._lock:
|
|
877
|
+
if self._closed:
|
|
878
|
+
self._reply_error(invocation, "tools_unavailable", "Python kernel is closing")
|
|
879
|
+
return
|
|
880
|
+
self._invocations[invocation.request_id] = invocation
|
|
881
|
+
thread = threading.Thread(target=self._run, args=(invocation, message), name="senpi-kernel-tool", daemon=True)
|
|
882
|
+
thread.start()
|
|
883
|
+
|
|
884
|
+
def _cancel(self, request_id: str) -> None:
|
|
885
|
+
with self._lock:
|
|
886
|
+
invocation = self._invocations.get(request_id)
|
|
887
|
+
if invocation is None:
|
|
888
|
+
return
|
|
889
|
+
invocation.cancelled.set()
|
|
890
|
+
if invocation.task is not None:
|
|
891
|
+
loop, task = invocation.task
|
|
892
|
+
loop.call_soon_threadsafe(task.cancel)
|
|
893
|
+
|
|
894
|
+
def _run(self, invocation: Invocation, message: dict[str, Any]) -> None:
|
|
895
|
+
output = io.StringIO()
|
|
896
|
+
try:
|
|
897
|
+
entry = self._registry.resolve(message)
|
|
898
|
+
args = validate_args(entry.input_schema, message.get("args"))
|
|
899
|
+
self._token.acquire()
|
|
900
|
+
try:
|
|
901
|
+
if invocation.cancelled.is_set():
|
|
902
|
+
raise Cancelled(CANCELLED_BEFORE_START)
|
|
903
|
+
CURRENT.set(invocation)
|
|
904
|
+
stdout, stderr = self._streams
|
|
905
|
+
with stdout.capture(output, cell=False), stderr.capture(output, cell=False):
|
|
906
|
+
value = self._call(invocation, entry.fn, args)
|
|
907
|
+
finally:
|
|
908
|
+
CURRENT.set(None)
|
|
909
|
+
self._token.release()
|
|
910
|
+
if invocation.cancelled.is_set():
|
|
911
|
+
raise Cancelled(CANCELLED_RESULT_DROPPED)
|
|
912
|
+
if not self._registry.is_live(entry):
|
|
913
|
+
raise KernelToolError("kernel_tool_stale", "Kernel tool descriptor is stale")
|
|
914
|
+
self._emit({"type": "kernel-tool-invoke-reply", "requestId": invocation.request_id, "ok": True,
|
|
915
|
+
"value": jsonable(value), **self._output_field(output)})
|
|
916
|
+
except Cancelled as exc:
|
|
917
|
+
self._reply_error(invocation, "kernel_tool_cancelled", str(exc), output)
|
|
918
|
+
except KernelToolError as exc:
|
|
919
|
+
self._reply_error(invocation, exc.code, str(exc), output, exc.details)
|
|
920
|
+
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — the invocation boundary serializes the tool's own failure.
|
|
921
|
+
self._reply_error(invocation, "kernel_tool_failed", f"{type(exc).__name__}: {exc}", output,
|
|
922
|
+
stack="".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
|
|
923
|
+
finally:
|
|
924
|
+
with self._lock:
|
|
925
|
+
self._invocations.pop(invocation.request_id, None)
|
|
926
|
+
|
|
927
|
+
def _call(self, invocation: Invocation, fn: Callable[..., Any], args: dict[str, Any]) -> Any:
|
|
928
|
+
result = fn(**args)
|
|
929
|
+
if not asyncio.iscoroutine(result) and not isinstance(result, asyncio.Future):
|
|
930
|
+
return result
|
|
931
|
+
loop = self._callback_loop()
|
|
932
|
+
temporary = loop.is_running()
|
|
933
|
+
if temporary:
|
|
934
|
+
loop = asyncio.new_event_loop()
|
|
935
|
+
asyncio.set_event_loop(loop)
|
|
936
|
+
task = loop.create_task(_awaited(result))
|
|
937
|
+
invocation.task = (loop, task)
|
|
938
|
+
try:
|
|
939
|
+
return loop.run_until_complete(task)
|
|
940
|
+
except asyncio.CancelledError as exc:
|
|
941
|
+
raise Cancelled(CANCELLED_AT_AWAIT) from exc
|
|
942
|
+
except RuntimeError as exc:
|
|
943
|
+
if "attached to a different loop" in str(exc) or "different event loop" in str(exc):
|
|
944
|
+
raise KernelToolError(
|
|
945
|
+
"kernel_tool_loop_mismatch",
|
|
946
|
+
"an awaited object belongs to another event loop (for example the parent cell's); "
|
|
947
|
+
"create it inside the tool",
|
|
948
|
+
) from exc
|
|
949
|
+
raise
|
|
950
|
+
finally:
|
|
951
|
+
invocation.task = None
|
|
952
|
+
asyncio.set_event_loop(None)
|
|
953
|
+
if temporary:
|
|
954
|
+
loop.close()
|
|
955
|
+
|
|
956
|
+
def _callback_loop(self) -> asyncio.AbstractEventLoop:
|
|
957
|
+
with self._lock:
|
|
958
|
+
if self._loop is None or self._loop.is_closed():
|
|
959
|
+
self._loop = asyncio.new_event_loop()
|
|
960
|
+
return self._loop
|
|
961
|
+
|
|
962
|
+
def _output_field(self, output: io.StringIO) -> dict[str, Any]:
|
|
963
|
+
text = output.getvalue()
|
|
964
|
+
return {"output": text} if text else {}
|
|
965
|
+
|
|
966
|
+
def _reply_error(self, invocation: Invocation, code: str, message: str, output: io.StringIO | None = None,
|
|
967
|
+
details: Any = None, stack: str | None = None) -> None:
|
|
968
|
+
error: dict[str, Any] = {"code": code, "message": message}
|
|
969
|
+
if details is not None:
|
|
970
|
+
error["details"] = details
|
|
971
|
+
if stack is not None:
|
|
972
|
+
error["stack"] = stack
|
|
973
|
+
frame: dict[str, Any] = {"type": "kernel-tool-invoke-reply", "requestId": invocation.request_id, "ok": False,
|
|
974
|
+
"error": error}
|
|
975
|
+
if output is not None:
|
|
976
|
+
frame.update(self._output_field(output))
|
|
977
|
+
self._emit(frame)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
async def _awaited(awaitable: Any) -> Any:
|
|
981
|
+
return await awaitable
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
KERNEL_TOOL_TOKEN = OwnerToken()
|
|
985
|
+
KERNEL_TOOL_STREAMS = (ThreadRoutedStream(stderr=False), ThreadRoutedStream(stderr=True))
|
|
986
|
+
KERNEL_TOOL_REGISTRY = Registry(lambda names: emit({"type": "kernel-tools-defined", "names": names}))
|
|
987
|
+
KERNEL_TOOL_RUNNER = KernelToolRunner(emit, KERNEL_TOOL_TOKEN, KERNEL_TOOL_REGISTRY, KERNEL_TOOL_STREAMS)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _prelude_tool_error(exc: KernelToolError) -> PreludeRuntimeError:
|
|
991
|
+
return PreludeRuntimeError(f"{exc.code}: {exc}", exc.code)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _guard_host_call(payload: dict[str, Any]) -> None:
|
|
995
|
+
invocation = CURRENT.get()
|
|
996
|
+
if invocation is None:
|
|
997
|
+
return
|
|
998
|
+
if invocation.cancelled.is_set():
|
|
999
|
+
raise Cancelled(CANCELLED_AT_PARK_POINT)
|
|
1000
|
+
tool_name = payload.get("toolName")
|
|
1001
|
+
refusal = host_call_refusal(invocation.scope, tool_name) if isinstance(tool_name, str) else None
|
|
1002
|
+
if refusal is not None:
|
|
1003
|
+
raise KernelToolError(
|
|
1004
|
+
"kernel_tool_host_denied",
|
|
1005
|
+
f"Host tool is outside this kernel tool call's scope: {tool_name} ({refusal})",
|
|
1006
|
+
{"tool": tool_name, "call_id": invocation.call_id, "reason": refusal},
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
|
|
405
1010
|
class ToolCallable:
|
|
406
1011
|
__slots__ = ("_name",)
|
|
407
1012
|
|
|
@@ -430,6 +1035,21 @@ class ToolCallable:
|
|
|
430
1035
|
class ToolProxy:
|
|
431
1036
|
__slots__ = ()
|
|
432
1037
|
|
|
1038
|
+
def __call__(self, fn: Any = None, /, *, name: Any = None, description: Any = None, schema: Any = None) -> Any:
|
|
1039
|
+
def register(target: Any) -> Any:
|
|
1040
|
+
try:
|
|
1041
|
+
return KERNEL_TOOL_REGISTRY.define(target, name=name, description=description, schema=schema)
|
|
1042
|
+
except KernelToolError as exc:
|
|
1043
|
+
raise _prelude_tool_error(exc) from None
|
|
1044
|
+
|
|
1045
|
+
return register if fn is None else register(fn)
|
|
1046
|
+
|
|
1047
|
+
def defined(self) -> list[str]:
|
|
1048
|
+
return KERNEL_TOOL_REGISTRY.defined()
|
|
1049
|
+
|
|
1050
|
+
def undefine(self, name: Any) -> bool:
|
|
1051
|
+
return KERNEL_TOOL_REGISTRY.undefine(name)
|
|
1052
|
+
|
|
433
1053
|
def __getattr__(self, name: str) -> ToolCallable:
|
|
434
1054
|
if name.startswith("_"):
|
|
435
1055
|
raise AttributeError(name)
|
|
@@ -477,10 +1097,18 @@ class Workpool:
|
|
|
477
1097
|
return _workpool_call({"op": "cancel", "pool_id": self.pool_id})
|
|
478
1098
|
|
|
479
1099
|
|
|
480
|
-
def workpool(
|
|
1100
|
+
def workpool(
|
|
1101
|
+
agent: dict[str, JsonValue], name: str, *, mode: str | None = None, tools: list[str] | None = None
|
|
1102
|
+
) -> Workpool:
|
|
481
1103
|
args: dict[str, JsonValue] = {"op": "create", "agent": agent, "name": name}
|
|
482
1104
|
if mode is not None:
|
|
483
1105
|
args["mode"] = mode
|
|
1106
|
+
if tools is not None:
|
|
1107
|
+
if not isinstance(tools, (list, tuple)) or not all(isinstance(name, str) for name in tools):
|
|
1108
|
+
raise PreludeRuntimeError(
|
|
1109
|
+
f"workpool(tools=...) takes a list of tool names; got {type(tools).__name__}", "invalid_tools"
|
|
1110
|
+
)
|
|
1111
|
+
args["tools"] = list(tools)
|
|
484
1112
|
result = _workpool_call(args)
|
|
485
1113
|
details = result.get("details")
|
|
486
1114
|
if isinstance(details, dict):
|
|
@@ -558,9 +1186,12 @@ def agent(
|
|
|
558
1186
|
apply: bool | None = None,
|
|
559
1187
|
merge: bool | str | None = None,
|
|
560
1188
|
handle: bool = False,
|
|
1189
|
+
tools: list[str] | None = None,
|
|
561
1190
|
) -> Any:
|
|
562
1191
|
"""Delegate work; isolated/apply/merge need a host that supports isolation, otherwise a warning.
|
|
563
1192
|
|
|
1193
|
+
tools grants the child this kernel's @tool functions by name, as JavaScript's agent(prompt, { tools }) does.
|
|
1194
|
+
|
|
564
1195
|
merge accepts "patch"/"branch" or False/True respectively. Unapplied foreground
|
|
565
1196
|
changes raise an error with recovery instructions. A handle returns immediately;
|
|
566
1197
|
await the completion notification or read task_output for the isolation result.
|
|
@@ -582,6 +1213,10 @@ def agent(
|
|
|
582
1213
|
args["merge"] = merge
|
|
583
1214
|
if handle:
|
|
584
1215
|
args["handle"] = True
|
|
1216
|
+
if tools is not None:
|
|
1217
|
+
if not isinstance(tools, (list, tuple)) or not all(isinstance(name, str) for name in tools):
|
|
1218
|
+
raise PreludeRuntimeError(f"agent(tools=...) takes a list of tool names; got {type(tools).__name__}", "invalid_tools")
|
|
1219
|
+
args["tools"] = list(tools)
|
|
585
1220
|
|
|
586
1221
|
response = bridge_post(
|
|
587
1222
|
"/call",
|
|
@@ -763,8 +1398,17 @@ def _pool_map(items: Iterable[Any], function: Callable[[Any], Any]) -> list[Any]
|
|
|
763
1398
|
workers = min(max(1, width), len(values))
|
|
764
1399
|
results: list[Any] = [None] * len(values)
|
|
765
1400
|
errors: dict[int, BaseException] = {}
|
|
1401
|
+
group = _token_key()
|
|
1402
|
+
|
|
1403
|
+
def submit(pool: ThreadPoolExecutor, value: Any) -> Any:
|
|
1404
|
+
# Each worker runs in a copy of this thread's context: the kernel tool call it serves (scope),
|
|
1405
|
+
# its output buffer, and its token group, so its host calls park this thread's token.
|
|
1406
|
+
context = contextvars.copy_context()
|
|
1407
|
+
context.run(TOKEN_GROUP.set, group)
|
|
1408
|
+
return pool.submit(context.run, function, value)
|
|
1409
|
+
|
|
766
1410
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
767
|
-
futures = {
|
|
1411
|
+
futures = {submit(pool, value): index for index, value in enumerate(values)}
|
|
768
1412
|
for future in as_completed(futures):
|
|
769
1413
|
index = futures[future]
|
|
770
1414
|
try:
|
|
@@ -957,8 +1601,14 @@ _LINE_MAGICS: dict[str, Callable[[str], Any]] = {
|
|
|
957
1601
|
}
|
|
958
1602
|
|
|
959
1603
|
|
|
1604
|
+
_HOST_MAGICS = frozenset({"pip", "environment"})
|
|
1605
|
+
|
|
1606
|
+
|
|
960
1607
|
def _magic(name: str, args: str) -> Any:
|
|
961
1608
|
handler = _LINE_MAGICS.get(name)
|
|
1609
|
+
if handler is None and name in _HOST_MAGICS:
|
|
1610
|
+
# The host runs these only as a cell's first code line; anywhere else they reach here.
|
|
1611
|
+
raise PreludeRuntimeError(f"put %{name} on its own cell, then run the code that uses it in the next cell")
|
|
962
1612
|
if handler is None:
|
|
963
1613
|
raise PreludeRuntimeError(f"Unsupported line magic: %{name}")
|
|
964
1614
|
return handler(args)
|
|
@@ -1449,8 +2099,8 @@ KERNEL_MEMORY = _KernelMemory()
|
|
|
1449
2099
|
TLA_FLAG = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0x2000)
|
|
1450
2100
|
|
|
1451
2101
|
|
|
1452
|
-
def compile_cell(source: str) -> tuple[Any | None, Any | None]:
|
|
1453
|
-
module = ast.parse(transform_cell(source), mode="exec")
|
|
2102
|
+
def compile_cell(source: str, filename: str = "<cell>") -> tuple[Any | None, Any | None]:
|
|
2103
|
+
module = ast.parse(transform_cell(source), filename=filename, mode="exec")
|
|
1454
2104
|
if not module.body:
|
|
1455
2105
|
return None, None
|
|
1456
2106
|
last = module.body[-1]
|
|
@@ -1458,13 +2108,13 @@ def compile_cell(source: str) -> tuple[Any | None, Any | None]:
|
|
|
1458
2108
|
body = ast.Module(body=module.body[:-1], type_ignores=[])
|
|
1459
2109
|
expression = ast.Expression(body=last.value)
|
|
1460
2110
|
ast.copy_location(expression, last)
|
|
1461
|
-
return compile(body,
|
|
2111
|
+
return compile(body, filename, "exec", flags=TLA_FLAG), compile(
|
|
1462
2112
|
expression,
|
|
1463
|
-
|
|
2113
|
+
filename,
|
|
1464
2114
|
"eval",
|
|
1465
2115
|
flags=TLA_FLAG,
|
|
1466
2116
|
)
|
|
1467
|
-
return compile(module,
|
|
2117
|
+
return compile(module, filename, "exec", flags=TLA_FLAG), None
|
|
1468
2118
|
|
|
1469
2119
|
|
|
1470
2120
|
async def run_code(code: Any, want_value: bool) -> Any:
|
|
@@ -1491,7 +2141,41 @@ def apply_preludes(preludes: Any) -> None:
|
|
|
1491
2141
|
exec(compile(contribution.get("python", ""), "<kernel-prelude>", "exec"), USER_NS)
|
|
1492
2142
|
|
|
1493
2143
|
|
|
1494
|
-
def
|
|
2144
|
+
def _enter_source_file(source_file: str | None) -> Callable[[], None]:
|
|
2145
|
+
# A %load cell runs as its file: __file__ names it and its directory comes first on the import path,
|
|
2146
|
+
# so `from sibling import x` resolves next to the file the way it does when the file runs as a script.
|
|
2147
|
+
# The returned step undoes both when the cell ends, so a later cell never imports from that directory
|
|
2148
|
+
# ahead of the session environment; the file's own sys.path edits stay.
|
|
2149
|
+
if source_file is None:
|
|
2150
|
+
return lambda: None
|
|
2151
|
+
missing = object()
|
|
2152
|
+
previous_file = USER_NS.get("__file__", missing)
|
|
2153
|
+
directory = os.path.dirname(source_file)
|
|
2154
|
+
previous_index = sys.path.index(directory) if directory in sys.path else None
|
|
2155
|
+
with contextlib.suppress(ValueError):
|
|
2156
|
+
sys.path.remove(directory)
|
|
2157
|
+
sys.path.insert(0, directory)
|
|
2158
|
+
importlib.invalidate_caches()
|
|
2159
|
+
USER_NS["__file__"] = source_file
|
|
2160
|
+
|
|
2161
|
+
def restore() -> None:
|
|
2162
|
+
with contextlib.suppress(ValueError):
|
|
2163
|
+
sys.path.remove(directory)
|
|
2164
|
+
if previous_index is not None:
|
|
2165
|
+
sys.path.insert(min(previous_index, len(sys.path)), directory)
|
|
2166
|
+
if previous_file is missing:
|
|
2167
|
+
USER_NS.pop("__file__", None)
|
|
2168
|
+
else:
|
|
2169
|
+
USER_NS["__file__"] = previous_file
|
|
2170
|
+
# Last: it calls every sys.meta_path finder, and user code may have installed one that raises.
|
|
2171
|
+
importlib.invalidate_caches()
|
|
2172
|
+
|
|
2173
|
+
return restore
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
def run_cell(
|
|
2177
|
+
cell_id: str, code: str, preludes: Any = None, source_file: str | None = None, cell_token: str | None = None
|
|
2178
|
+
) -> None:
|
|
1495
2179
|
start = time.monotonic()
|
|
1496
2180
|
stdout = io.StringIO()
|
|
1497
2181
|
stderr = io.StringIO()
|
|
@@ -1499,10 +2183,15 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
|
|
|
1499
2183
|
# cells) ignores it so a late signal cannot kill the stdin-read loop.
|
|
1500
2184
|
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
1501
2185
|
result: dict[str, Any]
|
|
2186
|
+
leave_source_file: Callable[[], None] = lambda: None
|
|
2187
|
+
cell_scope = CURRENT_CELL_TOKEN.set(cell_token)
|
|
1502
2188
|
try:
|
|
1503
|
-
|
|
2189
|
+
KERNEL_TOOL_TOKEN.acquire()
|
|
2190
|
+
cell_stdout, cell_stderr = KERNEL_TOOL_STREAMS
|
|
2191
|
+
with cell_stdout.capture(stdout, cell=True), cell_stderr.capture(stderr, cell=True):
|
|
1504
2192
|
apply_preludes(preludes)
|
|
1505
|
-
|
|
2193
|
+
leave_source_file = _enter_source_file(source_file)
|
|
2194
|
+
body, expression = compile_cell(code, source_file or "<cell>")
|
|
1506
2195
|
LOOP.run_until_complete(run_code(body, False))
|
|
1507
2196
|
value = LOOP.run_until_complete(run_code(expression, True))
|
|
1508
2197
|
text("stdout", stdout.getvalue())
|
|
@@ -1526,6 +2215,13 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
|
|
|
1526
2215
|
"durationMs": elapsed(start),
|
|
1527
2216
|
}
|
|
1528
2217
|
finally:
|
|
2218
|
+
# Liveness and revocation first: nothing the cell installed (an import finder, a path hook) can skip them.
|
|
2219
|
+
KERNEL_TOOL_TOKEN.release()
|
|
2220
|
+
CURRENT_CELL_TOKEN.reset(cell_scope)
|
|
2221
|
+
try:
|
|
2222
|
+
leave_source_file()
|
|
2223
|
+
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — user code can break the restore; it must not wedge the kernel.
|
|
2224
|
+
text("stderr", f"[senpi] %load could not fully restore the import path: {exc}\n")
|
|
1529
2225
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
1530
2226
|
try:
|
|
1531
2227
|
memory = KERNEL_MEMORY.after_cell()
|
|
@@ -1550,18 +2246,52 @@ def _handle_message(message: dict[str, Any]) -> bool:
|
|
|
1550
2246
|
emit({"type": "init-failed", "error": {"message": "missing bridge connection"}})
|
|
1551
2247
|
return True
|
|
1552
2248
|
CONNECTION = connection
|
|
2249
|
+
generation = message.get("kernelGeneration")
|
|
2250
|
+
if isinstance(generation, int) and not isinstance(generation, bool):
|
|
2251
|
+
KERNEL_TOOL_REGISTRY.generation = generation
|
|
1553
2252
|
KERNEL_MEMORY.configure(message.get("memory"))
|
|
1554
2253
|
emit({"type": "ready"})
|
|
1555
2254
|
return True
|
|
1556
2255
|
if message_type == "run":
|
|
1557
|
-
|
|
2256
|
+
env_root = message.get("envRoot")
|
|
2257
|
+
if isinstance(env_root, str):
|
|
2258
|
+
_activate_env_root(env_root)
|
|
2259
|
+
source_file = message.get("sourceFile")
|
|
2260
|
+
token = message.get("bridgeCellToken")
|
|
2261
|
+
run_cell(
|
|
2262
|
+
str(message.get("cellId", "")),
|
|
2263
|
+
str(message.get("code", "")),
|
|
2264
|
+
message.get("preludes"),
|
|
2265
|
+
source_file if isinstance(source_file, str) and source_file else None,
|
|
2266
|
+
token if isinstance(token, str) else None,
|
|
2267
|
+
)
|
|
1558
2268
|
return True
|
|
1559
2269
|
if message_type == "close":
|
|
2270
|
+
KERNEL_TOOL_RUNNER.close()
|
|
1560
2271
|
emit({"type": "closed"})
|
|
1561
2272
|
return False
|
|
1562
2273
|
return True
|
|
1563
2274
|
|
|
1564
2275
|
|
|
2276
|
+
_ACTIVE_ENV_ROOT: list[str] = []
|
|
2277
|
+
|
|
2278
|
+
|
|
2279
|
+
def _activate_env_root(root: str) -> None:
|
|
2280
|
+
# One environment revision is on the import path at a time: a newer revision already holds every
|
|
2281
|
+
# package of the previous one, so it replaces that entry instead of stacking another.
|
|
2282
|
+
# An empty root means the session's current mode has nothing installed: the previous revision leaves too.
|
|
2283
|
+
wanted = [root] if root else []
|
|
2284
|
+
if _ACTIVE_ENV_ROOT == wanted:
|
|
2285
|
+
return
|
|
2286
|
+
for previous in _ACTIVE_ENV_ROOT:
|
|
2287
|
+
with contextlib.suppress(ValueError):
|
|
2288
|
+
sys.path.remove(previous)
|
|
2289
|
+
_ACTIVE_ENV_ROOT[:] = wanted
|
|
2290
|
+
if root:
|
|
2291
|
+
sys.path.insert(0, root)
|
|
2292
|
+
importlib.invalidate_caches()
|
|
2293
|
+
|
|
2294
|
+
|
|
1565
2295
|
def _terminate_process_group() -> None:
|
|
1566
2296
|
# The kernel is spawned into its own session (setsid), so its pid is its process
|
|
1567
2297
|
# group id and a cell's subprocesses inherit that group. Killing the group takes
|
|
@@ -1612,14 +2342,37 @@ def _start_parent_watch() -> None:
|
|
|
1612
2342
|
).start()
|
|
1613
2343
|
|
|
1614
2344
|
|
|
2345
|
+
def _read_control(commands: queue.Queue[tuple[str, Any]]) -> None:
|
|
2346
|
+
# Kernel-tool frames are served here, so a callback can be admitted while the main thread is inside a cell.
|
|
2347
|
+
for raw in sys.stdin:
|
|
2348
|
+
try:
|
|
2349
|
+
message = json.loads(raw)
|
|
2350
|
+
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — malformed input is reported by the main loop, in order.
|
|
2351
|
+
commands.put(("malformed", exc))
|
|
2352
|
+
continue
|
|
2353
|
+
if isinstance(message, dict) and KERNEL_TOOL_RUNNER.handle(message):
|
|
2354
|
+
continue
|
|
2355
|
+
commands.put(("message", message))
|
|
2356
|
+
commands.put(("eof", None))
|
|
2357
|
+
|
|
2358
|
+
|
|
1615
2359
|
def main() -> None:
|
|
1616
2360
|
emit_status("kernel-startup", force=True, stage="host-init")
|
|
1617
2361
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
1618
2362
|
_start_parent_watch()
|
|
2363
|
+
sys.stdout, sys.stderr = KERNEL_TOOL_STREAMS
|
|
2364
|
+
commands: queue.Queue[tuple[str, Any]] = queue.Queue()
|
|
2365
|
+
threading.Thread(target=_read_control, args=(commands,), name="senpi-control-reader", daemon=True).start()
|
|
1619
2366
|
host_closed = False
|
|
1620
|
-
|
|
2367
|
+
while True:
|
|
2368
|
+
kind, payload = commands.get()
|
|
2369
|
+
if kind == "eof":
|
|
2370
|
+
break
|
|
2371
|
+
if kind == "malformed":
|
|
2372
|
+
emit({"type": "init-failed", "error": bridge_error(payload)})
|
|
2373
|
+
continue
|
|
1621
2374
|
try:
|
|
1622
|
-
if not _handle_message(
|
|
2375
|
+
if not _handle_message(payload):
|
|
1623
2376
|
host_closed = True
|
|
1624
2377
|
break
|
|
1625
2378
|
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — process boundary serializes malformed input and interrupts.
|