@code-yeongyu/senpi-codemode 2026.10.6 → 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 +37 -0
- package/README.md +20 -2
- package/package.json +5 -4
- package/src/bridge/kernel-tools-protocol.ts +8 -0
- package/src/bridge/reserved.ts +10 -0
- package/src/bridges/eval-virtual-schemas.ts +100 -0
- package/src/bridges/handle-bridge.ts +72 -0
- package/src/bridges/reserved-dispatch.ts +20 -2
- package/src/bridges/schema-bridge.ts +4 -0
- package/src/completion/handle.ts +69 -0
- package/src/completion/tool-bridge.ts +31 -12
- 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/bridge-dispatch.ts +41 -0
- package/src/extension/bridge-tool-call.ts +5 -1
- package/src/extension/host-facts.ts +18 -0
- package/src/extension/kernel-registration.ts +5 -0
- package/src/extension/removed-tool-hints.ts +15 -0
- package/src/extension/runtime-factory.ts +3 -0
- package/src/extension/session-manager-contract.ts +3 -0
- package/src/extension/session-manager.ts +32 -5
- package/src/extension/start-recovery.ts +111 -0
- package/src/handles/completion-handles.ts +202 -0
- package/src/handles/handle-args.ts +103 -0
- package/src/handles/handle-registry.ts +164 -0
- package/src/handles/wait.ts +214 -0
- package/src/handles/watch-queue.ts +102 -0
- package/src/index.ts +99 -94
- package/src/kernels/jl/handles.jl +124 -0
- package/src/kernels/jl/prelude.jl +9 -0
- package/src/kernels/jl/runner.jl +6 -1
- package/src/kernels/js/handles.js +93 -0
- package/src/kernels/js/kernel-tools-define.d.ts +6 -0
- package/src/kernels/js/kernel-tools-define.js +3 -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/kernel-tools-registry.d.ts +2 -0
- package/src/kernels/js/kernel-tools-registry.js +18 -9
- package/src/kernels/js/kernel-tools-schema.js +6 -2
- package/src/kernels/js/worker-runtime.js +10 -1
- package/src/kernels/py/host-cell.ts +42 -0
- package/src/kernels/py/kernel-contract.ts +13 -2
- package/src/kernels/py/kernel-tools-host.ts +97 -0
- package/src/kernels/py/kernel.ts +88 -12
- package/src/kernels/py/prelude.py +797 -11
- 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/rb/handles.rb +97 -0
- package/src/kernels/rb/paths.rb +37 -0
- package/src/kernels/rb/prelude.rb +15 -38
- package/src/kernels/rb/runner.rb +7 -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
- package/src/tool/cell-handler.ts +21 -18
- package/src/tool/cell-reserved-dispatch.ts +51 -0
- package/src/tool/eval-tool-options.ts +3 -0
- package/src/tool/run-eval-cell.ts +2 -0
- package/src/tool/types.ts +15 -0
|
@@ -18,6 +18,7 @@ import inspect
|
|
|
18
18
|
import io
|
|
19
19
|
import json
|
|
20
20
|
import locale
|
|
21
|
+
import math
|
|
21
22
|
import os
|
|
22
23
|
import re
|
|
23
24
|
import signal
|
|
@@ -25,12 +26,18 @@ import subprocess
|
|
|
25
26
|
import time
|
|
26
27
|
import traceback
|
|
27
28
|
import types
|
|
29
|
+
import typing
|
|
28
30
|
import urllib.error
|
|
29
31
|
import urllib.request
|
|
30
32
|
import uuid
|
|
31
33
|
from collections.abc import Iterable
|
|
32
34
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
33
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
|
|
34
41
|
from threading import Lock, Thread
|
|
35
42
|
from typing import Any, Callable, Union
|
|
36
43
|
from urllib.parse import unquote
|
|
@@ -48,6 +55,11 @@ EMIT_LOCK = Lock()
|
|
|
48
55
|
RESERVED_AGENT_TOOL = "__agent__"
|
|
49
56
|
RESERVED_OUTPUT_TOOL = "__output__"
|
|
50
57
|
RESERVED_SCHEMA_TOOL = "__schema__"
|
|
58
|
+
RESERVED_WAIT_TOOL = "__wait__"
|
|
59
|
+
RESERVED_HANDLE_STATUS_TOOL = "__handle_status__"
|
|
60
|
+
RESERVED_HANDLE_OUTPUT_TOOL = "__handle_output__"
|
|
61
|
+
RESERVED_HANDLE_SEND_TOOL = "__handle_send__"
|
|
62
|
+
RESERVED_HANDLE_CANCEL_TOOL = "__handle_cancel__"
|
|
51
63
|
TIMEOUT_PAUSE_OP = "timeout-pause"
|
|
52
64
|
TIMEOUT_RESUME_OP = "timeout-resume"
|
|
53
65
|
|
|
@@ -357,9 +369,12 @@ def write(path: str | Path, content: str) -> Path:
|
|
|
357
369
|
# The bridge is a loopback call: urllib's default opener would route it through a configured proxy
|
|
358
370
|
# (the environment everywhere, the registry on Windows), which a 127.0.0.1 request must never use.
|
|
359
371
|
_BRIDGE_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
372
|
+
# Ordinary bridge calls keep this socket timeout; only `__wait__` passes its own bound (see `_bridge_wait_post`).
|
|
373
|
+
_BRIDGE_SOCKET_TIMEOUT_SECONDS = 60
|
|
374
|
+
_WAIT_SOCKET_GRACE_SECONDS = 30
|
|
360
375
|
|
|
361
376
|
|
|
362
|
-
def bridge_post(path: str, payload: dict[str, Any]) -> Any:
|
|
377
|
+
def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | None = _BRIDGE_SOCKET_TIMEOUT_SECONDS) -> Any:
|
|
363
378
|
port = CONNECTION.get("port")
|
|
364
379
|
token = CONNECTION.get("token")
|
|
365
380
|
if not isinstance(port, int) or not isinstance(token, str):
|
|
@@ -371,15 +386,20 @@ def bridge_post(path: str, payload: dict[str, Any]) -> Any:
|
|
|
371
386
|
headers={"authorization": f"Bearer {token}", "content-type": "application/json"},
|
|
372
387
|
method="POST",
|
|
373
388
|
)
|
|
389
|
+
_guard_host_call(payload)
|
|
374
390
|
emit_status(TIMEOUT_PAUSE_OP, force=True)
|
|
375
391
|
try:
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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()
|
|
381
398
|
finally:
|
|
382
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)
|
|
383
403
|
|
|
384
404
|
try:
|
|
385
405
|
body = json.loads(response_data.decode("utf-8"))
|
|
@@ -393,6 +413,591 @@ def bridge_post(path: str, payload: dict[str, Any]) -> Any:
|
|
|
393
413
|
raise PreludeRuntimeError(str(error))
|
|
394
414
|
|
|
395
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
|
+
|
|
396
1001
|
class ToolCallable:
|
|
397
1002
|
__slots__ = ("_name",)
|
|
398
1003
|
|
|
@@ -421,6 +1026,21 @@ class ToolCallable:
|
|
|
421
1026
|
class ToolProxy:
|
|
422
1027
|
__slots__ = ()
|
|
423
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
|
+
|
|
424
1044
|
def __getattr__(self, name: str) -> ToolCallable:
|
|
425
1045
|
if name.startswith("_"):
|
|
426
1046
|
raise AttributeError(name)
|
|
@@ -500,6 +1120,8 @@ def completion(
|
|
|
500
1120
|
if schema is not None:
|
|
501
1121
|
options["schema"] = schema
|
|
502
1122
|
response = bridge_post("/completion", {"prompt": prompt, "opts": options})
|
|
1123
|
+
if options.get("handle") is True:
|
|
1124
|
+
return handle(response)
|
|
503
1125
|
if not isinstance(response, dict):
|
|
504
1126
|
return response
|
|
505
1127
|
if "value" in response:
|
|
@@ -616,6 +1238,129 @@ def agent(
|
|
|
616
1238
|
return node
|
|
617
1239
|
|
|
618
1240
|
|
|
1241
|
+
_HANDLE_KINDS = ("agent", "completion", "workpool")
|
|
1242
|
+
_WAIT_MODES = ("all", "any", "settled")
|
|
1243
|
+
_HANDLE_USAGE = (
|
|
1244
|
+
"handle() expects an agent(..., handle=True) record, a workpool, a completion handle, "
|
|
1245
|
+
"or a saved {kind, id, run_epoch} reference"
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
def _handle_ref(value: Any) -> dict[str, Any]:
|
|
1250
|
+
if isinstance(value, HandleView):
|
|
1251
|
+
return dict(value.ref)
|
|
1252
|
+
if isinstance(value, Workpool):
|
|
1253
|
+
return {"kind": "workpool", "id": value.pool_id, "run_epoch": 0}
|
|
1254
|
+
if not isinstance(value, dict):
|
|
1255
|
+
raise PreludeTypeError(_HANDLE_USAGE)
|
|
1256
|
+
if isinstance(value.get("pool_id"), str):
|
|
1257
|
+
return {"kind": "workpool", "id": value["pool_id"], "run_epoch": 0}
|
|
1258
|
+
kind = value.get("kind")
|
|
1259
|
+
if kind not in _HANDLE_KINDS:
|
|
1260
|
+
handle_uri = value.get("handle")
|
|
1261
|
+
scheme = handle_uri.split("://", 1)[0] if isinstance(handle_uri, str) else None
|
|
1262
|
+
kind = scheme if scheme in _HANDLE_KINDS else None
|
|
1263
|
+
identity = value.get("id", value.get("task_id"))
|
|
1264
|
+
if kind is None or not isinstance(identity, str) or not identity:
|
|
1265
|
+
raise PreludeTypeError(_HANDLE_USAGE)
|
|
1266
|
+
run_epoch = value.get("run_epoch", 0 if kind == "workpool" else None)
|
|
1267
|
+
if isinstance(run_epoch, bool) or not isinstance(run_epoch, int) or run_epoch < 0:
|
|
1268
|
+
raise PreludeTypeError(f"{_HANDLE_USAGE}; run_epoch must be a non-negative integer")
|
|
1269
|
+
return {"kind": kind, "id": identity, "run_epoch": run_epoch}
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def _handle_call(tool_name: str, args: dict[str, Any]) -> Any:
|
|
1273
|
+
return bridge_post("/call", {"callId": f"py-{uuid.uuid4()}", "toolName": tool_name, "args": args})
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _bridge_wait_post(args: dict[str, Any], timeout: float | None) -> Any:
|
|
1277
|
+
# A long-lived request: no 60 s socket cap. An explicit timeout bounds it (plus grace for the host's
|
|
1278
|
+
# reply); without one only the cell's own end does: SIGINT from the host (cancel, the cell's hard limit,
|
|
1279
|
+
# which the eval timeout can raise) interrupts the blocking read, which closes the socket and so the
|
|
1280
|
+
# host-side subscription.
|
|
1281
|
+
socket_timeout = None if timeout is None else float(timeout) + _WAIT_SOCKET_GRACE_SECONDS
|
|
1282
|
+
return bridge_post(
|
|
1283
|
+
"/call",
|
|
1284
|
+
{"callId": f"py-{uuid.uuid4()}", "toolName": RESERVED_WAIT_TOOL, "args": args},
|
|
1285
|
+
socket_timeout=socket_timeout,
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def wait(handles: Any, *, timeout: float | None = None, mode: str = "all") -> Any:
|
|
1290
|
+
"""Block until the handles settle; never cancels work. See tool_schema("eval:wait")."""
|
|
1291
|
+
if handles is None:
|
|
1292
|
+
items: list[Any] = []
|
|
1293
|
+
elif isinstance(handles, (list, tuple)):
|
|
1294
|
+
items = list(handles)
|
|
1295
|
+
else:
|
|
1296
|
+
items = [handles]
|
|
1297
|
+
if timeout is not None and (
|
|
1298
|
+
isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout < 0
|
|
1299
|
+
):
|
|
1300
|
+
raise PreludeValueError("wait() timeout must be a finite number of seconds >= 0")
|
|
1301
|
+
if mode not in _WAIT_MODES:
|
|
1302
|
+
raise PreludeValueError("wait() mode must be 'all', 'any' or 'settled'")
|
|
1303
|
+
args: dict[str, Any] = {"refs": [_handle_ref(item) for item in items], "mode": mode}
|
|
1304
|
+
if timeout is not None:
|
|
1305
|
+
args["timeout"] = timeout
|
|
1306
|
+
return _bridge_wait_post(args, timeout)
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
class HandleControl:
|
|
1310
|
+
"""Epoch-fenced control over one handle; every call goes through the host capability."""
|
|
1311
|
+
|
|
1312
|
+
__slots__ = ("_ref",)
|
|
1313
|
+
|
|
1314
|
+
def __init__(self, ref: dict[str, Any]) -> None:
|
|
1315
|
+
self._ref = dict(ref)
|
|
1316
|
+
|
|
1317
|
+
def __repr__(self) -> str:
|
|
1318
|
+
return f"<handle.control {self._ref['kind']}://{self._ref['id']}@{self._ref['run_epoch']}>"
|
|
1319
|
+
|
|
1320
|
+
def status(self) -> Any:
|
|
1321
|
+
return _handle_call(RESERVED_HANDLE_STATUS_TOOL, {"ref": self._ref})
|
|
1322
|
+
|
|
1323
|
+
def output(self, format: str = "raw", offset: int | None = None, limit: int | None = None) -> Any:
|
|
1324
|
+
args: dict[str, Any] = {"ref": self._ref, "format": format}
|
|
1325
|
+
if offset is not None:
|
|
1326
|
+
args["offset"] = offset
|
|
1327
|
+
if limit is not None:
|
|
1328
|
+
args["limit"] = limit
|
|
1329
|
+
return _handle_call(RESERVED_HANDLE_OUTPUT_TOOL, args)
|
|
1330
|
+
|
|
1331
|
+
def send(self, message: str) -> Any:
|
|
1332
|
+
return _handle_call(RESERVED_HANDLE_SEND_TOOL, {"ref": self._ref, "message": str(message)})
|
|
1333
|
+
|
|
1334
|
+
def cancel(self) -> Any:
|
|
1335
|
+
return _handle_call(RESERVED_HANDLE_CANCEL_TOOL, {"ref": self._ref})
|
|
1336
|
+
|
|
1337
|
+
def wait(self, timeout: float | None = None) -> Any:
|
|
1338
|
+
return wait([self._ref], timeout=timeout, mode="all")[0]
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
class HandleView(dict):
|
|
1342
|
+
"""The legacy record's fields as a dict, plus `control` and `ref` as attributes (never keys)."""
|
|
1343
|
+
|
|
1344
|
+
__slots__ = ("control", "ref")
|
|
1345
|
+
|
|
1346
|
+
def __init__(self, fields: dict[str, Any], ref: dict[str, Any]) -> None:
|
|
1347
|
+
super().__init__(fields)
|
|
1348
|
+
self.ref = dict(ref)
|
|
1349
|
+
self.control = HandleControl(ref)
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def handle(value: Any) -> HandleView:
|
|
1353
|
+
"""Rich view of an agent record, workpool, completion handle or saved reference; see tool_schema("eval:helpers")."""
|
|
1354
|
+
ref = _handle_ref(value)
|
|
1355
|
+
fields: dict[str, Any] = {}
|
|
1356
|
+
if isinstance(value, dict):
|
|
1357
|
+
fields.update({key: item for key, item in value.items() if not callable(item)})
|
|
1358
|
+
fields.setdefault("id", ref["id"])
|
|
1359
|
+
fields.setdefault("run_epoch", ref["run_epoch"])
|
|
1360
|
+
fields.setdefault("handle", f"{ref['kind']}://{ref['id']}")
|
|
1361
|
+
return HandleView(fields, ref)
|
|
1362
|
+
|
|
1363
|
+
|
|
619
1364
|
def _pool_map(items: Iterable[Any], function: Callable[[Any], Any]) -> list[Any]:
|
|
620
1365
|
values = list(items)
|
|
621
1366
|
if not values:
|
|
@@ -629,8 +1374,17 @@ def _pool_map(items: Iterable[Any], function: Callable[[Any], Any]) -> list[Any]
|
|
|
629
1374
|
workers = min(max(1, width), len(values))
|
|
630
1375
|
results: list[Any] = [None] * len(values)
|
|
631
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
|
+
|
|
632
1386
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
633
|
-
futures = {
|
|
1387
|
+
futures = {submit(pool, value): index for index, value in enumerate(values)}
|
|
634
1388
|
for future in as_completed(futures):
|
|
635
1389
|
index = futures[future]
|
|
636
1390
|
try:
|
|
@@ -997,6 +1751,8 @@ USER_NS.update(
|
|
|
997
1751
|
"workpool": workpool,
|
|
998
1752
|
"output": output,
|
|
999
1753
|
"tool_schema": tool_schema,
|
|
1754
|
+
"wait": wait,
|
|
1755
|
+
"handle": handle,
|
|
1000
1756
|
"__senpi_magic": _magic,
|
|
1001
1757
|
"__senpi_magic_cell": _magic_cell,
|
|
1002
1758
|
"__senpi_shell": _shell,
|
|
@@ -1364,7 +2120,9 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
|
|
|
1364
2120
|
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
1365
2121
|
result: dict[str, Any]
|
|
1366
2122
|
try:
|
|
1367
|
-
|
|
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):
|
|
1368
2126
|
apply_preludes(preludes)
|
|
1369
2127
|
body, expression = compile_cell(code)
|
|
1370
2128
|
LOOP.run_until_complete(run_code(body, False))
|
|
@@ -1390,6 +2148,7 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
|
|
|
1390
2148
|
"durationMs": elapsed(start),
|
|
1391
2149
|
}
|
|
1392
2150
|
finally:
|
|
2151
|
+
KERNEL_TOOL_TOKEN.release()
|
|
1393
2152
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
1394
2153
|
try:
|
|
1395
2154
|
memory = KERNEL_MEMORY.after_cell()
|
|
@@ -1405,7 +2164,7 @@ def elapsed(start: float) -> int:
|
|
|
1405
2164
|
return max(0, int((time.monotonic() - start) * 1000))
|
|
1406
2165
|
|
|
1407
2166
|
|
|
1408
|
-
def
|
|
2167
|
+
def _handle_message(message: dict[str, Any]) -> bool:
|
|
1409
2168
|
global CONNECTION
|
|
1410
2169
|
message_type = message.get("type")
|
|
1411
2170
|
if message_type == "init":
|
|
@@ -1414,6 +2173,9 @@ def handle(message: dict[str, Any]) -> bool:
|
|
|
1414
2173
|
emit({"type": "init-failed", "error": {"message": "missing bridge connection"}})
|
|
1415
2174
|
return True
|
|
1416
2175
|
CONNECTION = connection
|
|
2176
|
+
generation = message.get("kernelGeneration")
|
|
2177
|
+
if isinstance(generation, int) and not isinstance(generation, bool):
|
|
2178
|
+
KERNEL_TOOL_REGISTRY.generation = generation
|
|
1417
2179
|
KERNEL_MEMORY.configure(message.get("memory"))
|
|
1418
2180
|
emit({"type": "ready"})
|
|
1419
2181
|
return True
|
|
@@ -1421,6 +2183,7 @@ def handle(message: dict[str, Any]) -> bool:
|
|
|
1421
2183
|
run_cell(str(message.get("cellId", "")), str(message.get("code", "")), message.get("preludes"))
|
|
1422
2184
|
return True
|
|
1423
2185
|
if message_type == "close":
|
|
2186
|
+
KERNEL_TOOL_RUNNER.close()
|
|
1424
2187
|
emit({"type": "closed"})
|
|
1425
2188
|
return False
|
|
1426
2189
|
return True
|
|
@@ -1476,14 +2239,37 @@ def _start_parent_watch() -> None:
|
|
|
1476
2239
|
).start()
|
|
1477
2240
|
|
|
1478
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
|
+
|
|
1479
2256
|
def main() -> None:
|
|
1480
2257
|
emit_status("kernel-startup", force=True, stage="host-init")
|
|
1481
2258
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
1482
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()
|
|
1483
2263
|
host_closed = False
|
|
1484
|
-
|
|
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
|
|
1485
2271
|
try:
|
|
1486
|
-
if not
|
|
2272
|
+
if not _handle_message(payload):
|
|
1487
2273
|
host_closed = True
|
|
1488
2274
|
break
|
|
1489
2275
|
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — process boundary serializes malformed input and interrupts.
|