@code-yeongyu/senpi-codemode 2026.10.7 → 2026.10.8
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 +18 -0
- package/README.md +16 -2
- package/package.json +5 -4
- package/src/bridge/kernel-tools-protocol.ts +8 -0
- package/src/bridges/eval-virtual-schemas.ts +21 -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 +187 -0
- package/src/environments/revision-store.ts +95 -0
- package/src/extension/kernel-registration.ts +5 -0
- package/src/extension/session-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/py/kernel-contract.ts +11 -2
- package/src/kernels/py/kernel-tools-host.ts +97 -0
- package/src/kernels/py/kernel.ts +40 -8
- package/src/kernels/py/prelude.py +659 -9
- package/src/kernels/py/process.ts +15 -4
- package/src/kernels/py/startup.ts +58 -20
- package/src/kernels/py/transport.ts +36 -7
- 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/kernel-tools-pump.ts +6 -0
- package/src/kernels/shared/process-cpu.ts +69 -0
- package/src/kernels/shared/subprocess-queue.ts +1 -0
|
@@ -26,12 +26,18 @@ import subprocess
|
|
|
26
26
|
import time
|
|
27
27
|
import traceback
|
|
28
28
|
import types
|
|
29
|
+
import typing
|
|
29
30
|
import urllib.error
|
|
30
31
|
import urllib.request
|
|
31
32
|
import uuid
|
|
32
33
|
from collections.abc import Iterable
|
|
33
34
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
34
35
|
from pathlib import Path
|
|
36
|
+
import contextvars
|
|
37
|
+
import queue
|
|
38
|
+
import threading
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from typing import Iterator
|
|
35
41
|
from threading import Lock, Thread
|
|
36
42
|
from typing import Any, Callable, Union
|
|
37
43
|
from urllib.parse import unquote
|
|
@@ -380,15 +386,20 @@ def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | N
|
|
|
380
386
|
headers={"authorization": f"Bearer {token}", "content-type": "application/json"},
|
|
381
387
|
method="POST",
|
|
382
388
|
)
|
|
389
|
+
_guard_host_call(payload)
|
|
383
390
|
emit_status(TIMEOUT_PAUSE_OP, force=True)
|
|
384
391
|
try:
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
392
|
+
with KERNEL_TOOL_TOKEN.parked():
|
|
393
|
+
try:
|
|
394
|
+
with _BRIDGE_OPENER.open(request, timeout=socket_timeout) as response:
|
|
395
|
+
response_data = response.read()
|
|
396
|
+
except urllib.error.HTTPError as exc:
|
|
397
|
+
response_data = exc.read()
|
|
390
398
|
finally:
|
|
391
399
|
emit_status(TIMEOUT_RESUME_OP, force=True)
|
|
400
|
+
invocation = CURRENT.get()
|
|
401
|
+
if invocation is not None and invocation.cancelled.is_set():
|
|
402
|
+
raise Cancelled(CANCELLED_AT_PARK_POINT)
|
|
392
403
|
|
|
393
404
|
try:
|
|
394
405
|
body = json.loads(response_data.decode("utf-8"))
|
|
@@ -402,6 +413,591 @@ def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | N
|
|
|
402
413
|
raise PreludeRuntimeError(str(error))
|
|
403
414
|
|
|
404
415
|
|
|
416
|
+
# --- Kernel tools (`@tool`): inference, admission token, output routing, registry and serving threads. ---
|
|
417
|
+
# Admission is one interpreter-owner token: the main thread holds it while a cell runs and releases it only inside a
|
|
418
|
+
# host bridge call or while idle; a callback runs only while holding it, so a cell busy in pure computation never yields.
|
|
419
|
+
|
|
420
|
+
MCP_TOOL_NAME_MAX_LENGTH = 64
|
|
421
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
422
|
+
RESERVED_TOOL_NAMES = frozenset({"__agent__", "__output__", "__schema__", "defined", "undefine"})
|
|
423
|
+
_NONE_TYPE = type(None)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class KernelToolError(Exception):
|
|
427
|
+
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
|
|
428
|
+
super().__init__(message)
|
|
429
|
+
self.code = code
|
|
430
|
+
self.details = details
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def tool_key(name: str) -> str:
|
|
434
|
+
return re.sub(r"[^a-zA-Z0-9_-]", "_", name).replace("-", "_")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def validate_tool_name(name: Any) -> str:
|
|
438
|
+
if not isinstance(name, str) or not name:
|
|
439
|
+
raise KernelToolError("invalid_tool_definition", "@tool name must be a non-empty string")
|
|
440
|
+
if not _NAME_RE.match(name) or len(name) > MCP_TOOL_NAME_MAX_LENGTH:
|
|
441
|
+
raise KernelToolError("invalid_tool_definition", f"Kernel tool name must match MCP name grammar: {name}")
|
|
442
|
+
if tool_key(name) in {tool_key(reserved) for reserved in RESERVED_TOOL_NAMES}:
|
|
443
|
+
raise KernelToolError("reserved_tool_name", f"Kernel tool name is reserved: {name}")
|
|
444
|
+
return name
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _resolved_hints(fn: Callable[..., Any], params: list[inspect.Parameter]) -> dict[str, Any]:
|
|
448
|
+
# Only parameters shape the input schema; the return annotation may name a class defined later.
|
|
449
|
+
def parameters_only() -> None: ...
|
|
450
|
+
|
|
451
|
+
parameters_only.__annotations__ = {
|
|
452
|
+
param.name: param.annotation for param in params if param.annotation is not inspect.Parameter.empty
|
|
453
|
+
}
|
|
454
|
+
try:
|
|
455
|
+
return typing.get_type_hints(parameters_only, globalns=getattr(fn, "__globals__", None), include_extras=True)
|
|
456
|
+
except Exception as exc: # noqa: BROAD_EXCEPT_OK — any resolution failure is reported with the annotation that caused it.
|
|
457
|
+
globalns = getattr(fn, "__globals__", {})
|
|
458
|
+
for param in params:
|
|
459
|
+
annotation = getattr(param.annotation, "__forward_arg__", param.annotation)
|
|
460
|
+
if not isinstance(annotation, str):
|
|
461
|
+
continue
|
|
462
|
+
try:
|
|
463
|
+
eval(annotation, globalns) # noqa: S307 — the same evaluation get_type_hints performs, one annotation at a time.
|
|
464
|
+
except Exception as inner: # noqa: BROAD_EXCEPT_OK — names the annotation that failed.
|
|
465
|
+
raise KernelToolError(
|
|
466
|
+
"invalid_tool_definition",
|
|
467
|
+
f"can't resolve the annotation {annotation!r} on parameter {param.name!r} ({inner}); pass schema=",
|
|
468
|
+
) from inner
|
|
469
|
+
raise KernelToolError("invalid_tool_definition", f"can't resolve the type hints ({exc}); pass schema=") from exc
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _schema_for(annotation: Any, where: str) -> dict[str, Any]:
|
|
473
|
+
if annotation is inspect.Parameter.empty or annotation is typing.Any:
|
|
474
|
+
return {}
|
|
475
|
+
origin = typing.get_origin(annotation)
|
|
476
|
+
args = typing.get_args(annotation)
|
|
477
|
+
if origin is typing.Annotated:
|
|
478
|
+
schema = _schema_for(args[0], where)
|
|
479
|
+
description = next((item for item in args[1:] if isinstance(item, str)), None)
|
|
480
|
+
return {**schema, "description": description} if description is not None else schema
|
|
481
|
+
if annotation is _NONE_TYPE or annotation is None:
|
|
482
|
+
return {"type": "null"}
|
|
483
|
+
if annotation is bool:
|
|
484
|
+
return {"type": "boolean"}
|
|
485
|
+
if annotation is int:
|
|
486
|
+
return {"type": "integer"}
|
|
487
|
+
if annotation is float:
|
|
488
|
+
return {"type": "number"}
|
|
489
|
+
if annotation is str:
|
|
490
|
+
return {"type": "string"}
|
|
491
|
+
if annotation is list or annotation is typing.List:
|
|
492
|
+
return {"type": "array"}
|
|
493
|
+
if annotation is dict or annotation is typing.Dict:
|
|
494
|
+
return {"type": "object"}
|
|
495
|
+
if origin is list:
|
|
496
|
+
return {"type": "array", "items": _schema_for(args[0], where)} if args else {"type": "array"}
|
|
497
|
+
if origin is dict:
|
|
498
|
+
if args and args[0] is not str:
|
|
499
|
+
raise KernelToolError("invalid_tool_definition", f"{where}: dict keys must be str")
|
|
500
|
+
return {"type": "object", "additionalProperties": _schema_for(args[1], where)} if args else {"type": "object"}
|
|
501
|
+
if origin is typing.Literal:
|
|
502
|
+
if not all(isinstance(value, (str, int, float, bool)) or value is None for value in args):
|
|
503
|
+
raise KernelToolError("invalid_tool_definition", f"{where}: Literal values must be JSON scalars")
|
|
504
|
+
return {"enum": list(args)}
|
|
505
|
+
if origin is typing.Union or origin is getattr(types, "UnionType", None):
|
|
506
|
+
return {"anyOf": [_schema_for(item, where) for item in args]}
|
|
507
|
+
raise KernelToolError("invalid_tool_definition", f"{where}: unsupported annotation {annotation!r}; pass schema=")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _json_default(value: Any, where: str) -> Any:
|
|
511
|
+
if callable(value):
|
|
512
|
+
raise KernelToolError("invalid_tool_definition", f"{where}: default must be a JSON value, not a callable")
|
|
513
|
+
try:
|
|
514
|
+
json.dumps(value, allow_nan=False)
|
|
515
|
+
except (TypeError, ValueError) as exc:
|
|
516
|
+
raise KernelToolError("invalid_tool_definition", f"{where}: default must be a JSON value ({exc})") from exc
|
|
517
|
+
return value
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _description(fn: Callable[..., Any], explicit: Any) -> str:
|
|
521
|
+
if explicit is not None:
|
|
522
|
+
if not isinstance(explicit, str):
|
|
523
|
+
raise KernelToolError("invalid_tool_definition", "@tool description must be a string")
|
|
524
|
+
return explicit
|
|
525
|
+
doc = inspect.getdoc(fn) or ""
|
|
526
|
+
return doc.split("\n\n", 1)[0].strip()
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _signature(fn: Callable[..., Any]) -> inspect.Signature:
|
|
530
|
+
# Python 3.14 evaluates annotations lazily; asking for forward references keeps a return type that names
|
|
531
|
+
# a class defined later from failing here.
|
|
532
|
+
try:
|
|
533
|
+
import annotationlib
|
|
534
|
+
|
|
535
|
+
return inspect.signature(fn, annotation_format=annotationlib.Format.FORWARDREF)
|
|
536
|
+
except ImportError:
|
|
537
|
+
return inspect.signature(fn)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def infer_tool(fn: Any, *, name: Any = None, description: Any = None, schema: Any = None) -> dict[str, Any]:
|
|
541
|
+
if not callable(fn) or not hasattr(fn, "__name__"):
|
|
542
|
+
raise KernelToolError("invalid_tool_definition", "@tool requires a named function")
|
|
543
|
+
tool_name = validate_tool_name(fn.__name__ if name is None else name)
|
|
544
|
+
params = list(_signature(fn).parameters.values())
|
|
545
|
+
for param in params:
|
|
546
|
+
if param.kind is inspect.Parameter.POSITIONAL_ONLY:
|
|
547
|
+
raise KernelToolError("invalid_tool_definition", "positional-only parameters are not supported")
|
|
548
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
549
|
+
raise KernelToolError("invalid_tool_definition", f"*args and **kwargs are not supported ({param.name})")
|
|
550
|
+
names = [param.name for param in params]
|
|
551
|
+
text = _description(fn, description)
|
|
552
|
+
if schema is not None:
|
|
553
|
+
if not isinstance(schema, dict) or schema.get("type") != "object" or not isinstance(schema.get("properties"), dict):
|
|
554
|
+
raise KernelToolError("invalid_tool_definition", "@tool schema must be a JSON object schema")
|
|
555
|
+
if sorted(schema["properties"]) != sorted(names):
|
|
556
|
+
raise KernelToolError("invalid_tool_definition", "@tool schema properties must match parameters")
|
|
557
|
+
return {"name": tool_name, "description": text, "input_schema": schema, "params": names}
|
|
558
|
+
hints = _resolved_hints(fn, params)
|
|
559
|
+
properties: dict[str, Any] = {}
|
|
560
|
+
required: list[str] = []
|
|
561
|
+
for param in params:
|
|
562
|
+
where = f"parameter {param.name!r}"
|
|
563
|
+
prop = _schema_for(hints.get(param.name, param.annotation), where)
|
|
564
|
+
if param.default is inspect.Parameter.empty:
|
|
565
|
+
required.append(param.name)
|
|
566
|
+
else:
|
|
567
|
+
prop = {**prop, "default": _json_default(param.default, where)}
|
|
568
|
+
properties[param.name] = prop
|
|
569
|
+
input_schema: dict[str, Any] = {"type": "object", "properties": properties, "additionalProperties": False}
|
|
570
|
+
if required:
|
|
571
|
+
input_schema["required"] = required
|
|
572
|
+
return {"name": tool_name, "description": text, "input_schema": input_schema, "params": names}
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
TOKEN_GROUP: contextvars.ContextVar[int | None] = contextvars.ContextVar("senpi_kernel_token_group", default=None)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _token_key() -> int:
|
|
579
|
+
group = TOKEN_GROUP.get()
|
|
580
|
+
return group if group is not None else threading.get_ident()
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
class OwnerToken:
|
|
584
|
+
"""
|
|
585
|
+
The right to run user code, held by the cell or one kernel tool call at a time. Its key is the thread
|
|
586
|
+
that acquired it, inherited by that thread's parallel()/pipeline() workers, so a host call from any of
|
|
587
|
+
them parks the token for the whole group; the group takes it back once its last host call returns.
|
|
588
|
+
"""
|
|
589
|
+
|
|
590
|
+
def __init__(self) -> None:
|
|
591
|
+
self._cond = threading.Condition()
|
|
592
|
+
self._owner: int | None = None
|
|
593
|
+
self._depth = 0
|
|
594
|
+
self._parked: dict[int, list[int]] = {}
|
|
595
|
+
|
|
596
|
+
def acquire(self) -> None:
|
|
597
|
+
me = _token_key()
|
|
598
|
+
with self._cond:
|
|
599
|
+
while self._owner not in (None, me):
|
|
600
|
+
self._cond.wait()
|
|
601
|
+
self._owner = me
|
|
602
|
+
self._depth += 1
|
|
603
|
+
|
|
604
|
+
def release(self) -> None:
|
|
605
|
+
with self._cond:
|
|
606
|
+
if self._owner != _token_key():
|
|
607
|
+
return
|
|
608
|
+
self._depth -= 1
|
|
609
|
+
if self._depth == 0:
|
|
610
|
+
self._owner = None
|
|
611
|
+
self._cond.notify_all()
|
|
612
|
+
|
|
613
|
+
@contextlib.contextmanager
|
|
614
|
+
def parked(self) -> Iterator[None]:
|
|
615
|
+
me = _token_key()
|
|
616
|
+
with self._cond:
|
|
617
|
+
record = self._parked.get(me)
|
|
618
|
+
if self._owner == me:
|
|
619
|
+
self._parked[me] = [1, self._depth]
|
|
620
|
+
self._owner, self._depth = None, 0
|
|
621
|
+
self._cond.notify_all()
|
|
622
|
+
mine = True
|
|
623
|
+
elif record is not None:
|
|
624
|
+
record[0] += 1
|
|
625
|
+
mine = True
|
|
626
|
+
else:
|
|
627
|
+
mine = False
|
|
628
|
+
try:
|
|
629
|
+
yield
|
|
630
|
+
finally:
|
|
631
|
+
if mine:
|
|
632
|
+
with self._cond:
|
|
633
|
+
record = self._parked[me]
|
|
634
|
+
record[0] -= 1
|
|
635
|
+
if record[0] == 0:
|
|
636
|
+
while self._owner is not None:
|
|
637
|
+
self._cond.wait()
|
|
638
|
+
del self._parked[me]
|
|
639
|
+
self._owner, self._depth = me, record[1]
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
class ThreadRoutedStream(io.TextIOBase):
|
|
643
|
+
"""sys.stdout / sys.stderr: each writer thread's text goes to the buffer it registered, else the cell's."""
|
|
644
|
+
|
|
645
|
+
def __init__(self, stderr: bool) -> None:
|
|
646
|
+
self._stderr = stderr
|
|
647
|
+
self._targets: dict[int, io.StringIO] = {}
|
|
648
|
+
# Followed by the parallel()/pipeline() workers a capturing thread starts (they run in a copy of its context).
|
|
649
|
+
self._inherited: contextvars.ContextVar[io.StringIO | None] = contextvars.ContextVar(
|
|
650
|
+
f"senpi_output_{'stderr' if stderr else 'stdout'}", default=None
|
|
651
|
+
)
|
|
652
|
+
self.cell_target: io.StringIO | None = None
|
|
653
|
+
|
|
654
|
+
@contextlib.contextmanager
|
|
655
|
+
def capture(self, buffer: io.StringIO, *, cell: bool) -> Iterator[None]:
|
|
656
|
+
me = threading.get_ident()
|
|
657
|
+
previous = self._targets.get(me)
|
|
658
|
+
self._targets[me] = buffer
|
|
659
|
+
inherited = self._inherited.set(buffer)
|
|
660
|
+
if cell:
|
|
661
|
+
self.cell_target = buffer
|
|
662
|
+
try:
|
|
663
|
+
yield
|
|
664
|
+
finally:
|
|
665
|
+
self._inherited.reset(inherited)
|
|
666
|
+
if previous is None:
|
|
667
|
+
self._targets.pop(me, None)
|
|
668
|
+
else:
|
|
669
|
+
self._targets[me] = previous
|
|
670
|
+
if cell:
|
|
671
|
+
self.cell_target = None
|
|
672
|
+
|
|
673
|
+
def write(self, text: str) -> int:
|
|
674
|
+
target = self._targets.get(threading.get_ident()) or self._inherited.get() or self.cell_target
|
|
675
|
+
if target is None:
|
|
676
|
+
return sys.__stderr__.write(text)
|
|
677
|
+
return target.write(text)
|
|
678
|
+
|
|
679
|
+
def writable(self) -> bool:
|
|
680
|
+
return True
|
|
681
|
+
|
|
682
|
+
@property
|
|
683
|
+
def encoding(self) -> None:
|
|
684
|
+
return None
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@dataclass
|
|
688
|
+
class _Entry:
|
|
689
|
+
name: str
|
|
690
|
+
fn: Callable[..., Any]
|
|
691
|
+
description: str
|
|
692
|
+
input_schema: dict[str, Any]
|
|
693
|
+
params: list[str]
|
|
694
|
+
revision: int
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
class Registry:
|
|
698
|
+
def __init__(self, on_change: Callable[[list[str]], None]) -> None:
|
|
699
|
+
self._lock = threading.Lock()
|
|
700
|
+
self._entries: dict[str, _Entry] = {}
|
|
701
|
+
# Revisions outlive undefine: a redefined name never reuses a revision an old descriptor carries.
|
|
702
|
+
self._revisions: dict[str, int] = {}
|
|
703
|
+
self._on_change = on_change
|
|
704
|
+
self.generation = 1
|
|
705
|
+
|
|
706
|
+
def define(self, fn: Any, *, name: Any = None, description: Any = None, schema: Any = None) -> Any:
|
|
707
|
+
inferred = infer_tool(fn, name=name, description=description, schema=schema)
|
|
708
|
+
key = tool_key(inferred["name"])
|
|
709
|
+
with self._lock:
|
|
710
|
+
existing = self._entries.get(key)
|
|
711
|
+
if existing is not None and existing.name != inferred["name"]:
|
|
712
|
+
raise KernelToolError("tool_name_collision", f"Kernel tool name collides: {inferred['name']}")
|
|
713
|
+
revision = self._revisions.get(key, 0) + 1
|
|
714
|
+
self._revisions[key] = revision
|
|
715
|
+
self._entries[key] = _Entry(fn=fn, revision=revision, **inferred)
|
|
716
|
+
names = sorted(entry.name for entry in self._entries.values())
|
|
717
|
+
self._on_change(names)
|
|
718
|
+
return fn
|
|
719
|
+
|
|
720
|
+
def undefine(self, name: Any) -> bool:
|
|
721
|
+
if not isinstance(name, str):
|
|
722
|
+
return False
|
|
723
|
+
with self._lock:
|
|
724
|
+
removed = self._entries.pop(tool_key(name), None) is not None
|
|
725
|
+
names = sorted(entry.name for entry in self._entries.values())
|
|
726
|
+
if removed:
|
|
727
|
+
self._on_change(names)
|
|
728
|
+
return removed
|
|
729
|
+
|
|
730
|
+
def defined(self) -> list[str]:
|
|
731
|
+
with self._lock:
|
|
732
|
+
return sorted(entry.name for entry in self._entries.values())
|
|
733
|
+
|
|
734
|
+
def describe(self, names: list[str]) -> list[dict[str, Any]]:
|
|
735
|
+
results = []
|
|
736
|
+
with self._lock:
|
|
737
|
+
for name in names:
|
|
738
|
+
entry = self._entries.get(tool_key(name))
|
|
739
|
+
if entry is None:
|
|
740
|
+
error = {"code": "kernel_tool_missing", "message": f"Kernel tool is not defined: {name}"}
|
|
741
|
+
results.append({"name": name, "ok": False, "error": error})
|
|
742
|
+
continue
|
|
743
|
+
descriptor = {"name": entry.name, "description": entry.description, "input_schema": entry.input_schema,
|
|
744
|
+
"language": "py", "kernel_generation": self.generation, "definition_revision": entry.revision}
|
|
745
|
+
results.append({"name": name, "ok": True, "descriptor": descriptor})
|
|
746
|
+
return results
|
|
747
|
+
|
|
748
|
+
def resolve(self, request: dict[str, Any]) -> _Entry:
|
|
749
|
+
if request.get("kernel_generation") != self.generation:
|
|
750
|
+
raise KernelToolError("kernel_tool_stale", "Kernel tool descriptor generation is stale")
|
|
751
|
+
with self._lock:
|
|
752
|
+
entry = self._entries.get(tool_key(str(request.get("name", ""))))
|
|
753
|
+
if entry is None:
|
|
754
|
+
raise KernelToolError("kernel_tool_missing", f"Kernel tool is not defined: {request.get('name')}")
|
|
755
|
+
if entry.revision != request.get("definition_revision"):
|
|
756
|
+
raise KernelToolError("kernel_tool_stale", "Kernel tool descriptor revision is stale")
|
|
757
|
+
return entry
|
|
758
|
+
|
|
759
|
+
def is_live(self, entry: _Entry) -> bool:
|
|
760
|
+
with self._lock:
|
|
761
|
+
return self._entries.get(tool_key(entry.name)) is entry
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
class Cancelled(BaseException):
|
|
765
|
+
pass
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
@dataclass
|
|
769
|
+
class Invocation:
|
|
770
|
+
request_id: str
|
|
771
|
+
call_id: str
|
|
772
|
+
scope: Any
|
|
773
|
+
cancelled: threading.Event = field(default_factory=threading.Event)
|
|
774
|
+
task: tuple[asyncio.AbstractEventLoop, asyncio.Task[Any]] | None = None
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
CURRENT: contextvars.ContextVar[Invocation | None] = contextvars.ContextVar("senpi_kernel_tool_call", default=None)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def host_call_refusal(scope: Any, tool_name: str) -> str | None:
|
|
781
|
+
tools = scope.get("tools") if isinstance(scope, dict) else None
|
|
782
|
+
if not isinstance(tools, dict):
|
|
783
|
+
return None
|
|
784
|
+
deny, allow = tools.get("deny"), tools.get("allow")
|
|
785
|
+
if deny is not None and (not isinstance(deny, list) or tool_name in deny):
|
|
786
|
+
return "deny"
|
|
787
|
+
if allow is not None and (not isinstance(allow, list) or tool_name not in allow):
|
|
788
|
+
return "allow"
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def jsonable(value: Any) -> Any:
|
|
793
|
+
try:
|
|
794
|
+
json.dumps(value, allow_nan=False)
|
|
795
|
+
except (TypeError, ValueError):
|
|
796
|
+
return repr(value)
|
|
797
|
+
return value
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def validate_args(schema: dict[str, Any], args: Any) -> dict[str, Any]:
|
|
801
|
+
if not isinstance(args, dict):
|
|
802
|
+
raise KernelToolError("invalid_tool_definition", "kernel tool args must be a JSON object")
|
|
803
|
+
missing = [key for key in schema.get("required", []) if key not in args]
|
|
804
|
+
if missing:
|
|
805
|
+
raise KernelToolError("invalid_tool_definition", f"kernel tool args are missing: {', '.join(missing)}")
|
|
806
|
+
properties = schema.get("properties", {})
|
|
807
|
+
unknown = [key for key in args if key not in properties]
|
|
808
|
+
if unknown:
|
|
809
|
+
raise KernelToolError("invalid_tool_definition", f"kernel tool args are not parameters: {', '.join(unknown)}")
|
|
810
|
+
return args
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def is_coroutine_function(fn: Callable[..., Any]) -> bool:
|
|
814
|
+
return inspect.iscoroutinefunction(fn)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
CANCELLED_AT_PARK_POINT = "cancelled: took effect at a host park point"
|
|
818
|
+
CANCELLED_AT_AWAIT = "cancelled: took effect at an await point"
|
|
819
|
+
CANCELLED_BEFORE_START = "cancelled: the call never started"
|
|
820
|
+
CANCELLED_RESULT_DROPPED = "cancelled: the call finished after cancellation; its result was dropped"
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
class KernelToolRunner:
|
|
824
|
+
"""Answers describe from the reader thread and runs each invocation on its own thread under the token."""
|
|
825
|
+
|
|
826
|
+
def __init__(
|
|
827
|
+
self,
|
|
828
|
+
emit: Callable[[dict[str, Any]], None],
|
|
829
|
+
token: OwnerToken,
|
|
830
|
+
registry: Registry,
|
|
831
|
+
streams: tuple[ThreadRoutedStream, ThreadRoutedStream],
|
|
832
|
+
) -> None:
|
|
833
|
+
self._emit = emit
|
|
834
|
+
self._token = token
|
|
835
|
+
self._registry = registry
|
|
836
|
+
self._streams = streams
|
|
837
|
+
self._lock = threading.Lock()
|
|
838
|
+
self._invocations: dict[str, Invocation] = {}
|
|
839
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
840
|
+
self._closed = False
|
|
841
|
+
|
|
842
|
+
def handle(self, message: dict[str, Any]) -> bool:
|
|
843
|
+
kind = message.get("type")
|
|
844
|
+
if kind == "kernel-tool-describe":
|
|
845
|
+
names = [str(name) for name in message.get("names", [])]
|
|
846
|
+
self._emit({"type": "kernel-tool-describe-reply", "requestId": message.get("requestId"), "ok": True,
|
|
847
|
+
"results": self._registry.describe(names)})
|
|
848
|
+
return True
|
|
849
|
+
if kind == "kernel-tool-invoke":
|
|
850
|
+
self._start(message)
|
|
851
|
+
return True
|
|
852
|
+
if kind == "kernel-tool-cancel":
|
|
853
|
+
self._cancel(str(message.get("requestId", "")))
|
|
854
|
+
return True
|
|
855
|
+
return False
|
|
856
|
+
|
|
857
|
+
def close(self) -> None:
|
|
858
|
+
with self._lock:
|
|
859
|
+
self._closed = True
|
|
860
|
+
pending = list(self._invocations.values())
|
|
861
|
+
for invocation in pending:
|
|
862
|
+
invocation.cancelled.set()
|
|
863
|
+
|
|
864
|
+
def _start(self, message: dict[str, Any]) -> None:
|
|
865
|
+
invocation = Invocation(request_id=str(message.get("requestId", "")), call_id=str(message.get("call_id", "")),
|
|
866
|
+
scope=message.get("scope"))
|
|
867
|
+
with self._lock:
|
|
868
|
+
if self._closed:
|
|
869
|
+
self._reply_error(invocation, "tools_unavailable", "Python kernel is closing")
|
|
870
|
+
return
|
|
871
|
+
self._invocations[invocation.request_id] = invocation
|
|
872
|
+
thread = threading.Thread(target=self._run, args=(invocation, message), name="senpi-kernel-tool", daemon=True)
|
|
873
|
+
thread.start()
|
|
874
|
+
|
|
875
|
+
def _cancel(self, request_id: str) -> None:
|
|
876
|
+
with self._lock:
|
|
877
|
+
invocation = self._invocations.get(request_id)
|
|
878
|
+
if invocation is None:
|
|
879
|
+
return
|
|
880
|
+
invocation.cancelled.set()
|
|
881
|
+
if invocation.task is not None:
|
|
882
|
+
loop, task = invocation.task
|
|
883
|
+
loop.call_soon_threadsafe(task.cancel)
|
|
884
|
+
|
|
885
|
+
def _run(self, invocation: Invocation, message: dict[str, Any]) -> None:
|
|
886
|
+
output = io.StringIO()
|
|
887
|
+
try:
|
|
888
|
+
entry = self._registry.resolve(message)
|
|
889
|
+
args = validate_args(entry.input_schema, message.get("args"))
|
|
890
|
+
self._token.acquire()
|
|
891
|
+
try:
|
|
892
|
+
if invocation.cancelled.is_set():
|
|
893
|
+
raise Cancelled(CANCELLED_BEFORE_START)
|
|
894
|
+
CURRENT.set(invocation)
|
|
895
|
+
stdout, stderr = self._streams
|
|
896
|
+
with stdout.capture(output, cell=False), stderr.capture(output, cell=False):
|
|
897
|
+
value = self._call(invocation, entry.fn, args)
|
|
898
|
+
finally:
|
|
899
|
+
CURRENT.set(None)
|
|
900
|
+
self._token.release()
|
|
901
|
+
if invocation.cancelled.is_set():
|
|
902
|
+
raise Cancelled(CANCELLED_RESULT_DROPPED)
|
|
903
|
+
if not self._registry.is_live(entry):
|
|
904
|
+
raise KernelToolError("kernel_tool_stale", "Kernel tool descriptor is stale")
|
|
905
|
+
self._emit({"type": "kernel-tool-invoke-reply", "requestId": invocation.request_id, "ok": True,
|
|
906
|
+
"value": jsonable(value), **self._output_field(output)})
|
|
907
|
+
except Cancelled as exc:
|
|
908
|
+
self._reply_error(invocation, "kernel_tool_cancelled", str(exc), output)
|
|
909
|
+
except KernelToolError as exc:
|
|
910
|
+
self._reply_error(invocation, exc.code, str(exc), output, exc.details)
|
|
911
|
+
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — the invocation boundary serializes the tool's own failure.
|
|
912
|
+
self._reply_error(invocation, "kernel_tool_failed", f"{type(exc).__name__}: {exc}", output,
|
|
913
|
+
stack="".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
|
|
914
|
+
finally:
|
|
915
|
+
with self._lock:
|
|
916
|
+
self._invocations.pop(invocation.request_id, None)
|
|
917
|
+
|
|
918
|
+
def _call(self, invocation: Invocation, fn: Callable[..., Any], args: dict[str, Any]) -> Any:
|
|
919
|
+
result = fn(**args)
|
|
920
|
+
if not asyncio.iscoroutine(result) and not isinstance(result, asyncio.Future):
|
|
921
|
+
return result
|
|
922
|
+
loop = self._callback_loop()
|
|
923
|
+
temporary = loop.is_running()
|
|
924
|
+
if temporary:
|
|
925
|
+
loop = asyncio.new_event_loop()
|
|
926
|
+
asyncio.set_event_loop(loop)
|
|
927
|
+
task = loop.create_task(_awaited(result))
|
|
928
|
+
invocation.task = (loop, task)
|
|
929
|
+
try:
|
|
930
|
+
return loop.run_until_complete(task)
|
|
931
|
+
except asyncio.CancelledError as exc:
|
|
932
|
+
raise Cancelled(CANCELLED_AT_AWAIT) from exc
|
|
933
|
+
except RuntimeError as exc:
|
|
934
|
+
if "attached to a different loop" in str(exc) or "different event loop" in str(exc):
|
|
935
|
+
raise KernelToolError(
|
|
936
|
+
"kernel_tool_loop_mismatch",
|
|
937
|
+
"an awaited object belongs to another event loop (for example the parent cell's); "
|
|
938
|
+
"create it inside the tool",
|
|
939
|
+
) from exc
|
|
940
|
+
raise
|
|
941
|
+
finally:
|
|
942
|
+
invocation.task = None
|
|
943
|
+
asyncio.set_event_loop(None)
|
|
944
|
+
if temporary:
|
|
945
|
+
loop.close()
|
|
946
|
+
|
|
947
|
+
def _callback_loop(self) -> asyncio.AbstractEventLoop:
|
|
948
|
+
with self._lock:
|
|
949
|
+
if self._loop is None or self._loop.is_closed():
|
|
950
|
+
self._loop = asyncio.new_event_loop()
|
|
951
|
+
return self._loop
|
|
952
|
+
|
|
953
|
+
def _output_field(self, output: io.StringIO) -> dict[str, Any]:
|
|
954
|
+
text = output.getvalue()
|
|
955
|
+
return {"output": text} if text else {}
|
|
956
|
+
|
|
957
|
+
def _reply_error(self, invocation: Invocation, code: str, message: str, output: io.StringIO | None = None,
|
|
958
|
+
details: Any = None, stack: str | None = None) -> None:
|
|
959
|
+
error: dict[str, Any] = {"code": code, "message": message}
|
|
960
|
+
if details is not None:
|
|
961
|
+
error["details"] = details
|
|
962
|
+
if stack is not None:
|
|
963
|
+
error["stack"] = stack
|
|
964
|
+
frame: dict[str, Any] = {"type": "kernel-tool-invoke-reply", "requestId": invocation.request_id, "ok": False,
|
|
965
|
+
"error": error}
|
|
966
|
+
if output is not None:
|
|
967
|
+
frame.update(self._output_field(output))
|
|
968
|
+
self._emit(frame)
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
async def _awaited(awaitable: Any) -> Any:
|
|
972
|
+
return await awaitable
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
KERNEL_TOOL_TOKEN = OwnerToken()
|
|
976
|
+
KERNEL_TOOL_STREAMS = (ThreadRoutedStream(stderr=False), ThreadRoutedStream(stderr=True))
|
|
977
|
+
KERNEL_TOOL_REGISTRY = Registry(lambda names: emit({"type": "kernel-tools-defined", "names": names}))
|
|
978
|
+
KERNEL_TOOL_RUNNER = KernelToolRunner(emit, KERNEL_TOOL_TOKEN, KERNEL_TOOL_REGISTRY, KERNEL_TOOL_STREAMS)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _prelude_tool_error(exc: KernelToolError) -> PreludeRuntimeError:
|
|
982
|
+
return PreludeRuntimeError(f"{exc.code}: {exc}", exc.code)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def _guard_host_call(payload: dict[str, Any]) -> None:
|
|
986
|
+
invocation = CURRENT.get()
|
|
987
|
+
if invocation is None:
|
|
988
|
+
return
|
|
989
|
+
if invocation.cancelled.is_set():
|
|
990
|
+
raise Cancelled(CANCELLED_AT_PARK_POINT)
|
|
991
|
+
tool_name = payload.get("toolName")
|
|
992
|
+
refusal = host_call_refusal(invocation.scope, tool_name) if isinstance(tool_name, str) else None
|
|
993
|
+
if refusal is not None:
|
|
994
|
+
raise KernelToolError(
|
|
995
|
+
"kernel_tool_host_denied",
|
|
996
|
+
f"Host tool is outside this kernel tool call's scope: {tool_name} ({refusal})",
|
|
997
|
+
{"tool": tool_name, "call_id": invocation.call_id, "reason": refusal},
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
|
|
405
1001
|
class ToolCallable:
|
|
406
1002
|
__slots__ = ("_name",)
|
|
407
1003
|
|
|
@@ -430,6 +1026,21 @@ class ToolCallable:
|
|
|
430
1026
|
class ToolProxy:
|
|
431
1027
|
__slots__ = ()
|
|
432
1028
|
|
|
1029
|
+
def __call__(self, fn: Any = None, /, *, name: Any = None, description: Any = None, schema: Any = None) -> Any:
|
|
1030
|
+
def register(target: Any) -> Any:
|
|
1031
|
+
try:
|
|
1032
|
+
return KERNEL_TOOL_REGISTRY.define(target, name=name, description=description, schema=schema)
|
|
1033
|
+
except KernelToolError as exc:
|
|
1034
|
+
raise _prelude_tool_error(exc) from None
|
|
1035
|
+
|
|
1036
|
+
return register if fn is None else register(fn)
|
|
1037
|
+
|
|
1038
|
+
def defined(self) -> list[str]:
|
|
1039
|
+
return KERNEL_TOOL_REGISTRY.defined()
|
|
1040
|
+
|
|
1041
|
+
def undefine(self, name: Any) -> bool:
|
|
1042
|
+
return KERNEL_TOOL_REGISTRY.undefine(name)
|
|
1043
|
+
|
|
433
1044
|
def __getattr__(self, name: str) -> ToolCallable:
|
|
434
1045
|
if name.startswith("_"):
|
|
435
1046
|
raise AttributeError(name)
|
|
@@ -763,8 +1374,17 @@ def _pool_map(items: Iterable[Any], function: Callable[[Any], Any]) -> list[Any]
|
|
|
763
1374
|
workers = min(max(1, width), len(values))
|
|
764
1375
|
results: list[Any] = [None] * len(values)
|
|
765
1376
|
errors: dict[int, BaseException] = {}
|
|
1377
|
+
group = _token_key()
|
|
1378
|
+
|
|
1379
|
+
def submit(pool: ThreadPoolExecutor, value: Any) -> Any:
|
|
1380
|
+
# Each worker runs in a copy of this thread's context: the kernel tool call it serves (scope),
|
|
1381
|
+
# its output buffer, and its token group, so its host calls park this thread's token.
|
|
1382
|
+
context = contextvars.copy_context()
|
|
1383
|
+
context.run(TOKEN_GROUP.set, group)
|
|
1384
|
+
return pool.submit(context.run, function, value)
|
|
1385
|
+
|
|
766
1386
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
767
|
-
futures = {
|
|
1387
|
+
futures = {submit(pool, value): index for index, value in enumerate(values)}
|
|
768
1388
|
for future in as_completed(futures):
|
|
769
1389
|
index = futures[future]
|
|
770
1390
|
try:
|
|
@@ -1500,7 +2120,9 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
|
|
|
1500
2120
|
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
1501
2121
|
result: dict[str, Any]
|
|
1502
2122
|
try:
|
|
1503
|
-
|
|
2123
|
+
KERNEL_TOOL_TOKEN.acquire()
|
|
2124
|
+
cell_stdout, cell_stderr = KERNEL_TOOL_STREAMS
|
|
2125
|
+
with cell_stdout.capture(stdout, cell=True), cell_stderr.capture(stderr, cell=True):
|
|
1504
2126
|
apply_preludes(preludes)
|
|
1505
2127
|
body, expression = compile_cell(code)
|
|
1506
2128
|
LOOP.run_until_complete(run_code(body, False))
|
|
@@ -1526,6 +2148,7 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
|
|
|
1526
2148
|
"durationMs": elapsed(start),
|
|
1527
2149
|
}
|
|
1528
2150
|
finally:
|
|
2151
|
+
KERNEL_TOOL_TOKEN.release()
|
|
1529
2152
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
1530
2153
|
try:
|
|
1531
2154
|
memory = KERNEL_MEMORY.after_cell()
|
|
@@ -1550,6 +2173,9 @@ def _handle_message(message: dict[str, Any]) -> bool:
|
|
|
1550
2173
|
emit({"type": "init-failed", "error": {"message": "missing bridge connection"}})
|
|
1551
2174
|
return True
|
|
1552
2175
|
CONNECTION = connection
|
|
2176
|
+
generation = message.get("kernelGeneration")
|
|
2177
|
+
if isinstance(generation, int) and not isinstance(generation, bool):
|
|
2178
|
+
KERNEL_TOOL_REGISTRY.generation = generation
|
|
1553
2179
|
KERNEL_MEMORY.configure(message.get("memory"))
|
|
1554
2180
|
emit({"type": "ready"})
|
|
1555
2181
|
return True
|
|
@@ -1557,6 +2183,7 @@ def _handle_message(message: dict[str, Any]) -> bool:
|
|
|
1557
2183
|
run_cell(str(message.get("cellId", "")), str(message.get("code", "")), message.get("preludes"))
|
|
1558
2184
|
return True
|
|
1559
2185
|
if message_type == "close":
|
|
2186
|
+
KERNEL_TOOL_RUNNER.close()
|
|
1560
2187
|
emit({"type": "closed"})
|
|
1561
2188
|
return False
|
|
1562
2189
|
return True
|
|
@@ -1612,14 +2239,37 @@ def _start_parent_watch() -> None:
|
|
|
1612
2239
|
).start()
|
|
1613
2240
|
|
|
1614
2241
|
|
|
2242
|
+
def _read_control(commands: queue.Queue[tuple[str, Any]]) -> None:
|
|
2243
|
+
# Kernel-tool frames are served here, so a callback can be admitted while the main thread is inside a cell.
|
|
2244
|
+
for raw in sys.stdin:
|
|
2245
|
+
try:
|
|
2246
|
+
message = json.loads(raw)
|
|
2247
|
+
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — malformed input is reported by the main loop, in order.
|
|
2248
|
+
commands.put(("malformed", exc))
|
|
2249
|
+
continue
|
|
2250
|
+
if isinstance(message, dict) and KERNEL_TOOL_RUNNER.handle(message):
|
|
2251
|
+
continue
|
|
2252
|
+
commands.put(("message", message))
|
|
2253
|
+
commands.put(("eof", None))
|
|
2254
|
+
|
|
2255
|
+
|
|
1615
2256
|
def main() -> None:
|
|
1616
2257
|
emit_status("kernel-startup", force=True, stage="host-init")
|
|
1617
2258
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
1618
2259
|
_start_parent_watch()
|
|
2260
|
+
sys.stdout, sys.stderr = KERNEL_TOOL_STREAMS
|
|
2261
|
+
commands: queue.Queue[tuple[str, Any]] = queue.Queue()
|
|
2262
|
+
threading.Thread(target=_read_control, args=(commands,), name="senpi-control-reader", daemon=True).start()
|
|
1619
2263
|
host_closed = False
|
|
1620
|
-
|
|
2264
|
+
while True:
|
|
2265
|
+
kind, payload = commands.get()
|
|
2266
|
+
if kind == "eof":
|
|
2267
|
+
break
|
|
2268
|
+
if kind == "malformed":
|
|
2269
|
+
emit({"type": "init-failed", "error": bridge_error(payload)})
|
|
2270
|
+
continue
|
|
1621
2271
|
try:
|
|
1622
|
-
if not _handle_message(
|
|
2272
|
+
if not _handle_message(payload):
|
|
1623
2273
|
host_closed = True
|
|
1624
2274
|
break
|
|
1625
2275
|
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — process boundary serializes malformed input and interrupts.
|