@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +16 -2
  3. package/package.json +5 -4
  4. package/src/bridge/kernel-tools-protocol.ts +8 -0
  5. package/src/bridges/eval-virtual-schemas.ts +21 -0
  6. package/src/environments/editable-check.ts +116 -0
  7. package/src/environments/install-lock.ts +223 -0
  8. package/src/environments/py-environment.ts +98 -0
  9. package/src/environments/py-installer.ts +187 -0
  10. package/src/environments/revision-store.ts +95 -0
  11. package/src/extension/kernel-registration.ts +5 -0
  12. package/src/extension/session-manager.ts +15 -1
  13. package/src/kernels/js/kernel-tools-errors.js +2 -0
  14. package/src/kernels/js/kernel-tools-errors.ts +2 -0
  15. package/src/kernels/py/kernel-contract.ts +11 -2
  16. package/src/kernels/py/kernel-tools-host.ts +97 -0
  17. package/src/kernels/py/kernel.ts +40 -8
  18. package/src/kernels/py/prelude.py +659 -9
  19. package/src/kernels/py/process.ts +15 -4
  20. package/src/kernels/py/startup.ts +58 -20
  21. package/src/kernels/py/transport.ts +36 -7
  22. package/src/kernels/sandbox/vendor/pi-codemode/LICENSE +21 -0
  23. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +42 -0
  24. package/src/kernels/sandbox/vendor/pi-codemode/identifier.ts +14 -0
  25. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +514 -0
  26. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +431 -0
  27. package/src/kernels/sandbox/vendor/pi-codemode/runtime/protocol.ts +61 -0
  28. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +231 -0
  29. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +185 -0
  30. package/src/kernels/sandbox/vendor/pi-codemode/wasm.ts +37 -0
  31. package/src/kernels/shared/kernel-tools-pump.ts +6 -0
  32. package/src/kernels/shared/process-cpu.ts +69 -0
  33. 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
- try:
386
- with _BRIDGE_OPENER.open(request, timeout=socket_timeout) as response:
387
- response_data = response.read()
388
- except urllib.error.HTTPError as exc:
389
- response_data = exc.read()
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 = {pool.submit(function, value): index for index, value in enumerate(values)}
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
- with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
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
- for raw in sys.stdin:
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(json.loads(raw)):
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.