@code-yeongyu/senpi-codemode 2026.10.8 → 2026.10.10-2
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 +58 -0
- package/README.md +103 -7
- package/package.json +6 -5
- package/src/bridge/http-server.ts +9 -1
- package/src/bridge/protocol.ts +4 -0
- package/src/bridges/eval-virtual-schemas.ts +1 -0
- package/src/config/executable-settings.json +5 -0
- package/src/config/project-trust.ts +51 -0
- package/src/config/settings-overrides.ts +2 -0
- package/src/environments/js-environments.ts +208 -0
- package/src/environments/js-installer.ts +268 -0
- package/src/environments/js-revision-files.ts +91 -0
- package/src/environments/no-links.ts +132 -0
- package/src/environments/project-resolves.ts +54 -0
- package/src/environments/py-installer.ts +64 -4
- package/src/environments/python-environments.ts +85 -0
- package/src/environments/revision-store.ts +38 -7
- package/src/extension/bridge-tool-call.ts +2 -0
- package/src/extension/cell-kernel-tools.ts +28 -0
- package/src/extension/kernel-replacement.ts +20 -0
- package/src/extension/runtime-factory.ts +44 -2
- package/src/extension/session-manager-proxy.ts +6 -0
- package/src/extension/session-manager.ts +18 -3
- package/src/index.ts +2 -0
- package/src/interpreters/detect.ts +34 -1
- package/src/kernels/jl/prelude.jl +6 -1
- package/src/kernels/js/cell-dispatch.ts +44 -0
- package/src/kernels/js/context-manager.ts +25 -15
- package/src/kernels/js/host-entries.ts +62 -0
- package/src/kernels/js/kernel-contract.ts +2 -0
- package/src/kernels/js/kernel-tools-registry.js +1 -0
- package/src/kernels/js/local-module-loader.ts +21 -5
- package/src/kernels/js/worker-core.js +3 -0
- package/src/kernels/js/worker-package-resolve.js +153 -0
- package/src/kernels/js/worker-runtime.js +4 -1
- package/src/kernels/js/worker-startup.ts +1 -0
- package/src/kernels/js/workpool.js +5 -2
- package/src/kernels/py/host-cell.ts +8 -31
- package/src/kernels/py/kernel.ts +8 -2
- package/src/kernels/py/prelude.py +122 -11
- package/src/kernels/py/process.ts +4 -0
- package/src/kernels/py/transport.ts +13 -1
- package/src/kernels/rb/workpool.rb +7 -1
- package/src/kernels/sandbox/sandbox-cell.ts +116 -0
- package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
- package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
- package/src/kernels/shared/cell-source-at-start.ts +18 -0
- package/src/kernels/shared/host-cell.ts +57 -0
- package/src/prompt/eval-prompt.ts +8 -1
- package/src/tool/cell-handler.ts +10 -12
- package/src/tool/eval-request.ts +23 -1
- package/src/tool/eval-tool-options.ts +6 -0
- package/src/tool/eval-tool.ts +17 -4
- package/src/tool/load-cell.ts +88 -0
- package/src/tool/magic-cell-host.ts +123 -0
- package/src/tool/magic-cells.ts +75 -0
- package/src/tool/run-eval-cell.ts +57 -0
- package/src/tool/types.ts +48 -1
|
@@ -13,6 +13,7 @@ import asyncio # noqa: ANYIO_OK — stdlib-only embedded kernel runner.
|
|
|
13
13
|
import base64
|
|
14
14
|
import codecs
|
|
15
15
|
import contextlib
|
|
16
|
+
import importlib
|
|
16
17
|
import gc
|
|
17
18
|
import inspect
|
|
18
19
|
import io
|
|
@@ -374,7 +375,15 @@ _BRIDGE_SOCKET_TIMEOUT_SECONDS = 60
|
|
|
374
375
|
_WAIT_SOCKET_GRACE_SECONDS = 30
|
|
375
376
|
|
|
376
377
|
|
|
378
|
+
# The host's secret for the run in this context. Every host call carries it, so the host gives the call that run's
|
|
379
|
+
# kernel tools; copied contexts (parallel workers) keep it, plain threads do not, and the host forgets it at settle.
|
|
380
|
+
CURRENT_CELL_TOKEN: contextvars.ContextVar[str | None] = contextvars.ContextVar("senpi_cell_token", default=None)
|
|
381
|
+
|
|
382
|
+
|
|
377
383
|
def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | None = _BRIDGE_SOCKET_TIMEOUT_SECONDS) -> Any:
|
|
384
|
+
cell_token = CURRENT_CELL_TOKEN.get()
|
|
385
|
+
if path == "/call" and cell_token is not None and "cellToken" not in payload:
|
|
386
|
+
payload = {**payload, "cellToken": cell_token}
|
|
378
387
|
port = CONNECTION.get("port")
|
|
379
388
|
token = CONNECTION.get("token")
|
|
380
389
|
if not isinstance(port, int) or not isinstance(token, str):
|
|
@@ -695,7 +704,8 @@ class _Entry:
|
|
|
695
704
|
|
|
696
705
|
|
|
697
706
|
class Registry:
|
|
698
|
-
def __init__(self, on_change: Callable[[list[str]], None]) -> None:
|
|
707
|
+
def __init__(self, on_change: Callable[[list[str]], None], *, disabled: bool = False) -> None:
|
|
708
|
+
self._disabled = disabled
|
|
699
709
|
self._lock = threading.Lock()
|
|
700
710
|
self._entries: dict[str, _Entry] = {}
|
|
701
711
|
# Revisions outlive undefine: a redefined name never reuses a revision an old descriptor carries.
|
|
@@ -704,6 +714,8 @@ class Registry:
|
|
|
704
714
|
self.generation = 1
|
|
705
715
|
|
|
706
716
|
def define(self, fn: Any, *, name: Any = None, description: Any = None, schema: Any = None) -> Any:
|
|
717
|
+
if self._disabled:
|
|
718
|
+
raise KernelToolError("tools_unavailable", "kernel tools are turned off for this project (kernelTools.enabled is false)")
|
|
707
719
|
inferred = infer_tool(fn, name=name, description=description, schema=schema)
|
|
708
720
|
key = tool_key(inferred["name"])
|
|
709
721
|
with self._lock:
|
|
@@ -974,7 +986,12 @@ async def _awaited(awaitable: Any) -> Any:
|
|
|
974
986
|
|
|
975
987
|
KERNEL_TOOL_TOKEN = OwnerToken()
|
|
976
988
|
KERNEL_TOOL_STREAMS = (ThreadRoutedStream(stderr=False), ThreadRoutedStream(stderr=True))
|
|
977
|
-
|
|
989
|
+
# kernelTools.enabled is read once, while the prelude loads and before any cell runs, then removed from the
|
|
990
|
+
# environment: rewriting os.environ cannot turn kernel tools back on, and cell subprocesses never see the switch.
|
|
991
|
+
KERNEL_TOOL_REGISTRY = Registry(
|
|
992
|
+
lambda names: emit({"type": "kernel-tools-defined", "names": names}),
|
|
993
|
+
disabled=os.environ.pop("SENPI_CODEMODE_KERNEL_TOOLS", None) == "0",
|
|
994
|
+
)
|
|
978
995
|
KERNEL_TOOL_RUNNER = KernelToolRunner(emit, KERNEL_TOOL_TOKEN, KERNEL_TOOL_REGISTRY, KERNEL_TOOL_STREAMS)
|
|
979
996
|
|
|
980
997
|
|
|
@@ -1088,10 +1105,18 @@ class Workpool:
|
|
|
1088
1105
|
return _workpool_call({"op": "cancel", "pool_id": self.pool_id})
|
|
1089
1106
|
|
|
1090
1107
|
|
|
1091
|
-
def workpool(
|
|
1108
|
+
def workpool(
|
|
1109
|
+
agent: dict[str, JsonValue], name: str, *, mode: str | None = None, tools: list[str] | None = None
|
|
1110
|
+
) -> Workpool:
|
|
1092
1111
|
args: dict[str, JsonValue] = {"op": "create", "agent": agent, "name": name}
|
|
1093
1112
|
if mode is not None:
|
|
1094
1113
|
args["mode"] = mode
|
|
1114
|
+
if tools is not None:
|
|
1115
|
+
if not isinstance(tools, (list, tuple)) or not all(isinstance(name, str) for name in tools):
|
|
1116
|
+
raise PreludeRuntimeError(
|
|
1117
|
+
f"workpool(tools=...) takes a list of tool names; got {type(tools).__name__}", "invalid_tools"
|
|
1118
|
+
)
|
|
1119
|
+
args["tools"] = list(tools)
|
|
1095
1120
|
result = _workpool_call(args)
|
|
1096
1121
|
details = result.get("details")
|
|
1097
1122
|
if isinstance(details, dict):
|
|
@@ -1169,9 +1194,12 @@ def agent(
|
|
|
1169
1194
|
apply: bool | None = None,
|
|
1170
1195
|
merge: bool | str | None = None,
|
|
1171
1196
|
handle: bool = False,
|
|
1197
|
+
tools: list[str] | None = None,
|
|
1172
1198
|
) -> Any:
|
|
1173
1199
|
"""Delegate work; isolated/apply/merge need a host that supports isolation, otherwise a warning.
|
|
1174
1200
|
|
|
1201
|
+
tools grants the child this kernel's @tool functions by name, as JavaScript's agent(prompt, { tools }) does.
|
|
1202
|
+
|
|
1175
1203
|
merge accepts "patch"/"branch" or False/True respectively. Unapplied foreground
|
|
1176
1204
|
changes raise an error with recovery instructions. A handle returns immediately;
|
|
1177
1205
|
await the completion notification or read task_output for the isolation result.
|
|
@@ -1193,6 +1221,10 @@ def agent(
|
|
|
1193
1221
|
args["merge"] = merge
|
|
1194
1222
|
if handle:
|
|
1195
1223
|
args["handle"] = True
|
|
1224
|
+
if tools is not None:
|
|
1225
|
+
if not isinstance(tools, (list, tuple)) or not all(isinstance(name, str) for name in tools):
|
|
1226
|
+
raise PreludeRuntimeError(f"agent(tools=...) takes a list of tool names; got {type(tools).__name__}", "invalid_tools")
|
|
1227
|
+
args["tools"] = list(tools)
|
|
1196
1228
|
|
|
1197
1229
|
response = bridge_post(
|
|
1198
1230
|
"/call",
|
|
@@ -1577,8 +1609,14 @@ _LINE_MAGICS: dict[str, Callable[[str], Any]] = {
|
|
|
1577
1609
|
}
|
|
1578
1610
|
|
|
1579
1611
|
|
|
1612
|
+
_HOST_MAGICS = frozenset({"pip", "environment"})
|
|
1613
|
+
|
|
1614
|
+
|
|
1580
1615
|
def _magic(name: str, args: str) -> Any:
|
|
1581
1616
|
handler = _LINE_MAGICS.get(name)
|
|
1617
|
+
if handler is None and name in _HOST_MAGICS:
|
|
1618
|
+
# The host runs these only as a cell's first code line; anywhere else they reach here.
|
|
1619
|
+
raise PreludeRuntimeError(f"put %{name} on its own cell, then run the code that uses it in the next cell")
|
|
1582
1620
|
if handler is None:
|
|
1583
1621
|
raise PreludeRuntimeError(f"Unsupported line magic: %{name}")
|
|
1584
1622
|
return handler(args)
|
|
@@ -2069,8 +2107,8 @@ KERNEL_MEMORY = _KernelMemory()
|
|
|
2069
2107
|
TLA_FLAG = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0x2000)
|
|
2070
2108
|
|
|
2071
2109
|
|
|
2072
|
-
def compile_cell(source: str) -> tuple[Any | None, Any | None]:
|
|
2073
|
-
module = ast.parse(transform_cell(source), mode="exec")
|
|
2110
|
+
def compile_cell(source: str, filename: str = "<cell>") -> tuple[Any | None, Any | None]:
|
|
2111
|
+
module = ast.parse(transform_cell(source), filename=filename, mode="exec")
|
|
2074
2112
|
if not module.body:
|
|
2075
2113
|
return None, None
|
|
2076
2114
|
last = module.body[-1]
|
|
@@ -2078,13 +2116,13 @@ def compile_cell(source: str) -> tuple[Any | None, Any | None]:
|
|
|
2078
2116
|
body = ast.Module(body=module.body[:-1], type_ignores=[])
|
|
2079
2117
|
expression = ast.Expression(body=last.value)
|
|
2080
2118
|
ast.copy_location(expression, last)
|
|
2081
|
-
return compile(body,
|
|
2119
|
+
return compile(body, filename, "exec", flags=TLA_FLAG), compile(
|
|
2082
2120
|
expression,
|
|
2083
|
-
|
|
2121
|
+
filename,
|
|
2084
2122
|
"eval",
|
|
2085
2123
|
flags=TLA_FLAG,
|
|
2086
2124
|
)
|
|
2087
|
-
return compile(module,
|
|
2125
|
+
return compile(module, filename, "exec", flags=TLA_FLAG), None
|
|
2088
2126
|
|
|
2089
2127
|
|
|
2090
2128
|
async def run_code(code: Any, want_value: bool) -> Any:
|
|
@@ -2111,7 +2149,41 @@ def apply_preludes(preludes: Any) -> None:
|
|
|
2111
2149
|
exec(compile(contribution.get("python", ""), "<kernel-prelude>", "exec"), USER_NS)
|
|
2112
2150
|
|
|
2113
2151
|
|
|
2114
|
-
def
|
|
2152
|
+
def _enter_source_file(source_file: str | None) -> Callable[[], None]:
|
|
2153
|
+
# A %load cell runs as its file: __file__ names it and its directory comes first on the import path,
|
|
2154
|
+
# so `from sibling import x` resolves next to the file the way it does when the file runs as a script.
|
|
2155
|
+
# The returned step undoes both when the cell ends, so a later cell never imports from that directory
|
|
2156
|
+
# ahead of the session environment; the file's own sys.path edits stay.
|
|
2157
|
+
if source_file is None:
|
|
2158
|
+
return lambda: None
|
|
2159
|
+
missing = object()
|
|
2160
|
+
previous_file = USER_NS.get("__file__", missing)
|
|
2161
|
+
directory = os.path.dirname(source_file)
|
|
2162
|
+
previous_index = sys.path.index(directory) if directory in sys.path else None
|
|
2163
|
+
with contextlib.suppress(ValueError):
|
|
2164
|
+
sys.path.remove(directory)
|
|
2165
|
+
sys.path.insert(0, directory)
|
|
2166
|
+
importlib.invalidate_caches()
|
|
2167
|
+
USER_NS["__file__"] = source_file
|
|
2168
|
+
|
|
2169
|
+
def restore() -> None:
|
|
2170
|
+
with contextlib.suppress(ValueError):
|
|
2171
|
+
sys.path.remove(directory)
|
|
2172
|
+
if previous_index is not None:
|
|
2173
|
+
sys.path.insert(min(previous_index, len(sys.path)), directory)
|
|
2174
|
+
if previous_file is missing:
|
|
2175
|
+
USER_NS.pop("__file__", None)
|
|
2176
|
+
else:
|
|
2177
|
+
USER_NS["__file__"] = previous_file
|
|
2178
|
+
# Last: it calls every sys.meta_path finder, and user code may have installed one that raises.
|
|
2179
|
+
importlib.invalidate_caches()
|
|
2180
|
+
|
|
2181
|
+
return restore
|
|
2182
|
+
|
|
2183
|
+
|
|
2184
|
+
def run_cell(
|
|
2185
|
+
cell_id: str, code: str, preludes: Any = None, source_file: str | None = None, cell_token: str | None = None
|
|
2186
|
+
) -> None:
|
|
2115
2187
|
start = time.monotonic()
|
|
2116
2188
|
stdout = io.StringIO()
|
|
2117
2189
|
stderr = io.StringIO()
|
|
@@ -2119,12 +2191,15 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
|
|
|
2119
2191
|
# cells) ignores it so a late signal cannot kill the stdin-read loop.
|
|
2120
2192
|
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
2121
2193
|
result: dict[str, Any]
|
|
2194
|
+
leave_source_file: Callable[[], None] = lambda: None
|
|
2195
|
+
cell_scope = CURRENT_CELL_TOKEN.set(cell_token)
|
|
2122
2196
|
try:
|
|
2123
2197
|
KERNEL_TOOL_TOKEN.acquire()
|
|
2124
2198
|
cell_stdout, cell_stderr = KERNEL_TOOL_STREAMS
|
|
2125
2199
|
with cell_stdout.capture(stdout, cell=True), cell_stderr.capture(stderr, cell=True):
|
|
2126
2200
|
apply_preludes(preludes)
|
|
2127
|
-
|
|
2201
|
+
leave_source_file = _enter_source_file(source_file)
|
|
2202
|
+
body, expression = compile_cell(code, source_file or "<cell>")
|
|
2128
2203
|
LOOP.run_until_complete(run_code(body, False))
|
|
2129
2204
|
value = LOOP.run_until_complete(run_code(expression, True))
|
|
2130
2205
|
text("stdout", stdout.getvalue())
|
|
@@ -2148,7 +2223,13 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
|
|
|
2148
2223
|
"durationMs": elapsed(start),
|
|
2149
2224
|
}
|
|
2150
2225
|
finally:
|
|
2226
|
+
# Liveness and revocation first: nothing the cell installed (an import finder, a path hook) can skip them.
|
|
2151
2227
|
KERNEL_TOOL_TOKEN.release()
|
|
2228
|
+
CURRENT_CELL_TOKEN.reset(cell_scope)
|
|
2229
|
+
try:
|
|
2230
|
+
leave_source_file()
|
|
2231
|
+
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — user code can break the restore; it must not wedge the kernel.
|
|
2232
|
+
text("stderr", f"[senpi] %load could not fully restore the import path: {exc}\n")
|
|
2152
2233
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
2153
2234
|
try:
|
|
2154
2235
|
memory = KERNEL_MEMORY.after_cell()
|
|
@@ -2180,7 +2261,18 @@ def _handle_message(message: dict[str, Any]) -> bool:
|
|
|
2180
2261
|
emit({"type": "ready"})
|
|
2181
2262
|
return True
|
|
2182
2263
|
if message_type == "run":
|
|
2183
|
-
|
|
2264
|
+
env_root = message.get("envRoot")
|
|
2265
|
+
if isinstance(env_root, str):
|
|
2266
|
+
_activate_env_root(env_root)
|
|
2267
|
+
source_file = message.get("sourceFile")
|
|
2268
|
+
token = message.get("bridgeCellToken")
|
|
2269
|
+
run_cell(
|
|
2270
|
+
str(message.get("cellId", "")),
|
|
2271
|
+
str(message.get("code", "")),
|
|
2272
|
+
message.get("preludes"),
|
|
2273
|
+
source_file if isinstance(source_file, str) and source_file else None,
|
|
2274
|
+
token if isinstance(token, str) else None,
|
|
2275
|
+
)
|
|
2184
2276
|
return True
|
|
2185
2277
|
if message_type == "close":
|
|
2186
2278
|
KERNEL_TOOL_RUNNER.close()
|
|
@@ -2189,6 +2281,25 @@ def _handle_message(message: dict[str, Any]) -> bool:
|
|
|
2189
2281
|
return True
|
|
2190
2282
|
|
|
2191
2283
|
|
|
2284
|
+
_ACTIVE_ENV_ROOT: list[str] = []
|
|
2285
|
+
|
|
2286
|
+
|
|
2287
|
+
def _activate_env_root(root: str) -> None:
|
|
2288
|
+
# One environment revision is on the import path at a time: a newer revision already holds every
|
|
2289
|
+
# package of the previous one, so it replaces that entry instead of stacking another.
|
|
2290
|
+
# An empty root means the session's current mode has nothing installed: the previous revision leaves too.
|
|
2291
|
+
wanted = [root] if root else []
|
|
2292
|
+
if _ACTIVE_ENV_ROOT == wanted:
|
|
2293
|
+
return
|
|
2294
|
+
for previous in _ACTIVE_ENV_ROOT:
|
|
2295
|
+
with contextlib.suppress(ValueError):
|
|
2296
|
+
sys.path.remove(previous)
|
|
2297
|
+
_ACTIVE_ENV_ROOT[:] = wanted
|
|
2298
|
+
if root:
|
|
2299
|
+
sys.path.insert(0, root)
|
|
2300
|
+
importlib.invalidate_caches()
|
|
2301
|
+
|
|
2302
|
+
|
|
2192
2303
|
def _terminate_process_group() -> None:
|
|
2193
2304
|
# The kernel is spawned into its own session (setsid), so its pid is its process
|
|
2194
2305
|
# group id and a cell's subprocesses inherit that group. Killing the group takes
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
2
4
|
import type { Readable, Writable } from "node:stream";
|
|
3
5
|
|
|
4
6
|
export interface KernelChild {
|
|
@@ -53,6 +55,8 @@ export function defaultSpawn(options: KernelSpawnOptions): KernelChild {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export function splitCommand(commandLine: string): { readonly command: string; readonly args: readonly string[] } {
|
|
58
|
+
// A configured interpreter path is one executable even when it contains spaces.
|
|
59
|
+
if (isAbsolute(commandLine) && existsSync(commandLine)) return { command: commandLine, args: [] };
|
|
56
60
|
const [command, ...args] = commandLine.split(" ").filter(Boolean);
|
|
57
61
|
if (!command) throw new Error("Python interpreter path is empty");
|
|
58
62
|
return { command, args };
|
|
@@ -35,6 +35,9 @@ export interface PythonTransportRunInput {
|
|
|
35
35
|
readonly code: string;
|
|
36
36
|
readonly timeoutMs?: number;
|
|
37
37
|
readonly preludePlan?: KernelPreludePlan;
|
|
38
|
+
readonly envRoot?: () => string;
|
|
39
|
+
readonly sourceFile?: string;
|
|
40
|
+
readonly bridgeCellToken?: string;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export interface PythonTransportOptions {
|
|
@@ -157,7 +160,16 @@ export class PythonKernelTransport {
|
|
|
157
160
|
install: input.preludePlan.install.map(({ exports, python }) => ({ exports: [...exports], python })),
|
|
158
161
|
remove: [...input.preludePlan.remove],
|
|
159
162
|
};
|
|
160
|
-
this.#write({
|
|
163
|
+
this.#write({
|
|
164
|
+
type: "run",
|
|
165
|
+
cellId: input.cellId,
|
|
166
|
+
code: input.code,
|
|
167
|
+
timeoutMs: input.timeoutMs,
|
|
168
|
+
preludes,
|
|
169
|
+
...(input.envRoot === undefined ? {} : { envRoot: input.envRoot() }),
|
|
170
|
+
...(input.sourceFile === undefined ? {} : { sourceFile: input.sourceFile }),
|
|
171
|
+
...(input.bridgeCellToken === undefined ? {} : { bridgeCellToken: input.bridgeCellToken }),
|
|
172
|
+
});
|
|
161
173
|
}
|
|
162
174
|
|
|
163
175
|
interrupt(reason: string): void {
|
|
@@ -23,9 +23,15 @@ class SenpiWorkpool
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def workpool(agent, name, mode: nil)
|
|
26
|
+
def workpool(agent, name, mode: nil, tools: nil)
|
|
27
27
|
args = { "op" => "create", "agent" => agent, "name" => name }
|
|
28
28
|
args["mode"] = mode unless mode.nil?
|
|
29
|
+
unless tools.nil?
|
|
30
|
+
unless tools.is_a?(Array) && tools.all? { |name| name.is_a?(String) }
|
|
31
|
+
raise SenpiBridgeError.new("workpool(tools: ...) takes an array of tool names; got #{tools.class}", "invalid_tools")
|
|
32
|
+
end
|
|
33
|
+
args["tools"] = tools.dup
|
|
34
|
+
end
|
|
29
35
|
result = __senpi_workpool_call(args)
|
|
30
36
|
details = result["details"]
|
|
31
37
|
if details.is_a?(Hash)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ResolvedSandbox } from "../../config/feature-settings.ts";
|
|
2
|
+
import { marshalToolResult } from "../../tool/image.ts";
|
|
3
|
+
import type { ExecuteTool, HostCellExecutor } from "../../tool/types.ts";
|
|
4
|
+
import type { CodemodeSandbox } from "./vendor/pi-codemode/runtime/host.ts";
|
|
5
|
+
import type { CodemodeError, CodemodeOutputFrame, CodemodeTool } from "./vendor/pi-codemode/types.ts";
|
|
6
|
+
|
|
7
|
+
export interface SandboxCellOptions {
|
|
8
|
+
readonly sandbox: ResolvedSandbox;
|
|
9
|
+
readonly executeTool: ExecuteTool;
|
|
10
|
+
readonly toolNames: () => readonly string[];
|
|
11
|
+
readonly describeTool?: (name: string) => string | undefined;
|
|
12
|
+
/** Where the QuickJS wasm lives; by default the installed `quickjs-wasi` package's file. */
|
|
13
|
+
readonly wasmPath?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// On the script's first line so reported line numbers still match the cell; globals, so a cell's own
|
|
17
|
+
// `const print` shadows them instead of failing as a redeclaration.
|
|
18
|
+
const OUTPUT_ALIASES =
|
|
19
|
+
'globalThis.print = (...values) => text(values.map((value) => (typeof value === "string" ? value : JSON.stringify(value))).join(" ")); globalThis.display = (value) => ((typeof value === "string" && value.startsWith("data:image/")) || (value !== null && typeof value === "object" && ("image_url" in value || value.type === "image")) ? image(value) : text(typeof value === "string" ? value : JSON.stringify(value))); globalThis.tool = tools; ';
|
|
20
|
+
|
|
21
|
+
const ERROR_CODES: Partial<Record<CodemodeError["kind"], string>> = {
|
|
22
|
+
timeout: "eval_isolate_timeout",
|
|
23
|
+
aborted: "eval_isolate_aborted",
|
|
24
|
+
sandbox: "eval_isolate_unavailable",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function codeFor(error: CodemodeError): string | undefined {
|
|
28
|
+
if (error.kind === "script" && error.reason === "memory") return "eval_isolate_memory_limit";
|
|
29
|
+
if (error.kind === "script" && error.name === "CodemodeStoreDisabledError") return "eval_isolate_no_state";
|
|
30
|
+
if (error.kind === "script" && error.reason === "unresolved") return "eval_isolate_unresolved_promise";
|
|
31
|
+
return ERROR_CODES[error.kind];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The persistent kernel never sees an isolated cell; its tools go through executeTool so permission hooks still apply. */
|
|
35
|
+
export function sandboxCellExecutor(code: string, options: SandboxCellOptions): HostCellExecutor {
|
|
36
|
+
return async ({ signal, emit }) => {
|
|
37
|
+
let sandbox: CodemodeSandbox;
|
|
38
|
+
try {
|
|
39
|
+
// Lazy: the vendored QuickJS runtime loads only when a session runs its first isolated cell. The wasm is loaded
|
|
40
|
+
// here, before any of the cell runs, so a missing runtime is reported as unavailable instead of failing inside
|
|
41
|
+
// the script.
|
|
42
|
+
const runtime = await import("./vendor/pi-codemode/runtime/host.ts");
|
|
43
|
+
const { loadQuickJSWasm } = await import("./vendor/pi-codemode/wasm.ts");
|
|
44
|
+
const wasm = await loadQuickJSWasm(options.wasmPath);
|
|
45
|
+
const items = new Map<number, string[]>();
|
|
46
|
+
const tools: CodemodeTool[] = options
|
|
47
|
+
.toolNames()
|
|
48
|
+
.filter((name) => name !== "eval")
|
|
49
|
+
.map((name) => ({
|
|
50
|
+
name,
|
|
51
|
+
description: options.describeTool?.(name) ?? "",
|
|
52
|
+
execute: async (args: unknown, context: { signal: AbortSignal }) =>
|
|
53
|
+
marshalToolResult(await options.executeTool(name, args, { signal: context.signal })),
|
|
54
|
+
}));
|
|
55
|
+
sandbox = new runtime.CodemodeSandbox({
|
|
56
|
+
wasm,
|
|
57
|
+
tools,
|
|
58
|
+
timeoutMs: options.sandbox.timeoutSeconds * 1_000,
|
|
59
|
+
memoryLimitBytes: options.sandbox.memoryMb * 1024 * 1024,
|
|
60
|
+
output: "stream",
|
|
61
|
+
builtins: { store: "reject" },
|
|
62
|
+
onOutputFrame: (frame: CodemodeOutputFrame) => {
|
|
63
|
+
const parts = items.get(frame.itemId) ?? [];
|
|
64
|
+
parts.push(frame.chunk);
|
|
65
|
+
if (!frame.final) {
|
|
66
|
+
items.set(frame.itemId, parts);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
items.delete(frame.itemId);
|
|
70
|
+
const payload = parts.join("");
|
|
71
|
+
if (frame.type === "image") {
|
|
72
|
+
emit({ type: "display", mimeType: frame.mimeType ?? "image/png", dataBase64: payload });
|
|
73
|
+
} else {
|
|
74
|
+
emit({ type: "text", stream: "stdout", data: payload.endsWith("\n") ? payload : `${payload}\n` });
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// The full error, paths included, goes to the host's own log so a maintainer can diagnose it; the cell gets
|
|
80
|
+
// only what failed and the error's code. This covers every setup step: the runtime import, the tool list,
|
|
81
|
+
// the wasm load and the sandbox itself.
|
|
82
|
+
console.error("[senpi-codemode] isolated cell setup failed:", error);
|
|
83
|
+
const code =
|
|
84
|
+
error instanceof Error && "code" in error && typeof error.code === "string" ? ` (${error.code})` : "";
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: {
|
|
88
|
+
name: "SandboxUnavailableError",
|
|
89
|
+
message: `eval_isolate_unavailable: the QuickJS runtime for isolated cells could not be loaded${code}; nothing in the cell ran`,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const result = await sandbox.execute(OUTPUT_ALIASES + code, { signal });
|
|
95
|
+
if (result.ok) {
|
|
96
|
+
return result.value === undefined
|
|
97
|
+
? { ok: true }
|
|
98
|
+
: {
|
|
99
|
+
ok: true,
|
|
100
|
+
valueRepr: typeof result.value === "string" ? result.value : JSON.stringify(result.value),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const errorCode = codeFor(result.error);
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: {
|
|
107
|
+
...(result.error.name === undefined ? {} : { name: result.error.name }),
|
|
108
|
+
message: errorCode === undefined ? result.error.message : `${errorCode}: ${result.error.message}`,
|
|
109
|
+
...(result.error.stack === undefined ? {} : { stack: result.error.stack }),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
} finally {
|
|
113
|
+
await sandbox.close();
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -40,3 +40,4 @@ A sync must re-apply both kinds: dropping a marked line (for example `"output-fr
|
|
|
40
40
|
- A normal finish settles only after every frame is consumed. An abort or timeout settles at once and drops frames still queued. The timer and the abort signal stay armed while a finished run's last frames drain, so a late abort, timeout or `close()` abandons them too (the run settles with that error): a consumer that never returns cannot keep a run alive.
|
|
41
41
|
- `frameBytes` and `windowBytes` must be at least 4, so a frame can always hold a whole surrogate pair.
|
|
42
42
|
2. **Builtin store policy** (`builtins: { store: "collect" | "reject" }`; `types.ts`, `runtime/protocol.ts`, `runtime/worker.ts`, `runtime/host.ts`, `runtime/prelude-source.ts`). With `"reject"`, `load()` returns `undefined` and `store()` throws `CodemodeStoreDisabledError`. The names stay defined, so scripts written for upstream fail with a clear message instead of a ReferenceError.
|
|
43
|
+
3. **Runtime-set error reasons** (`types.ts` `CodemodeError.reason`, `runtime/prelude-source.ts`, `runtime/worker.ts`). The prelude captures the engine's own `InternalError` before user code runs and tags a failure `reason: "memory"` only for an instance of it whose message is exactly "out of memory"; its stalled-promise failure is tagged `reason: "unresolved"`; the worker tags an engine out-of-memory from `evalCode` the same way; and `text()` rethrows the engine's own error instead of replacing it with a `TypeError`. The host maps the isolate error codes from `reason` only, never from message text. Upstream has no `reason`, so its behaviour is unchanged.
|
|
@@ -46,6 +46,10 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson
|
|
|
46
46
|
const options = optionsJson === undefined ? {} : JSON.parse(optionsJson);
|
|
47
47
|
const streamOutput = options.streamOutput === true;
|
|
48
48
|
const rejectStore = options.store === "reject";
|
|
49
|
+
// The engine's own InternalError, captured before user code runs: a failure is tagged by identity, never by its
|
|
50
|
+
// text, so a cell's own error that merely mentions memory keeps its own code. A script can still construct this
|
|
51
|
+
// constructor itself and so label its own failure as the memory limit; that mislabels only its own cell.
|
|
52
|
+
const EngineInternalError = typeof InternalError === "function" ? InternalError : undefined;
|
|
49
53
|
// senpi-change end
|
|
50
54
|
"use strict";
|
|
51
55
|
const stringify = JSON.stringify;
|
|
@@ -94,7 +98,13 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson
|
|
|
94
98
|
|
|
95
99
|
function describeError(error) {
|
|
96
100
|
if (error instanceof ErrorCtor) {
|
|
97
|
-
|
|
101
|
+
// senpi-change begin: the engine's out-of-memory error is reported by identity, not by matching its message
|
|
102
|
+
const reason =
|
|
103
|
+
EngineInternalError !== undefined && error instanceof EngineInternalError && error.message === "out of memory"
|
|
104
|
+
? "memory"
|
|
105
|
+
: undefined;
|
|
106
|
+
return stringify({ name: error.name, message: error.message, stack: errorText(error), reason });
|
|
107
|
+
// senpi-change end
|
|
98
108
|
}
|
|
99
109
|
return stringify({ message: format(error) });
|
|
100
110
|
}
|
|
@@ -285,6 +295,8 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson
|
|
|
285
295
|
try {
|
|
286
296
|
rendered = outputText(value);
|
|
287
297
|
} catch (error) {
|
|
298
|
+
// The engine's own out-of-memory error keeps its identity, so it is still reported as the memory limit.
|
|
299
|
+
if (EngineInternalError !== undefined && error instanceof EngineInternalError) throw error; // senpi-change
|
|
288
300
|
throw new TypeErrorCtor(error instanceof ErrorCtor ? error.message : String(error));
|
|
289
301
|
}
|
|
290
302
|
output("text", rendered);
|
|
@@ -423,6 +435,7 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson
|
|
|
423
435
|
name: "Error",
|
|
424
436
|
message:
|
|
425
437
|
"The script is waiting on a promise that can never settle: no tool call is pending, and timers do not exist here.",
|
|
438
|
+
reason: "unresolved", // senpi-change: tagged by the runtime, so a script's own error text never matches it
|
|
426
439
|
}),
|
|
427
440
|
);
|
|
428
441
|
return true;
|
|
@@ -48,7 +48,10 @@ function discardOutput(memory: { readonly buffer: ArrayBufferLike }) {
|
|
|
48
48
|
function describeException(error: JSException): string {
|
|
49
49
|
const head = error.message ? `${error.name}: ${error.message}` : error.name;
|
|
50
50
|
const stack = error.stack?.trimEnd();
|
|
51
|
-
|
|
51
|
+
// senpi-change begin: evalCode fails before any script runs, so an InternalError here is the engine's own
|
|
52
|
+
const reason = error.name === "InternalError" && error.message === "out of memory" ? "memory" : undefined;
|
|
53
|
+
return JSON.stringify({ name: error.name, message: error.message, stack: stack ? `${head}\n${stack}` : head, reason });
|
|
54
|
+
// senpi-change end
|
|
52
55
|
}
|
|
53
56
|
// senpi-change begin: output streaming
|
|
54
57
|
/**
|
|
@@ -71,6 +71,8 @@ export interface CodemodeError {
|
|
|
71
71
|
name?: string;
|
|
72
72
|
message: string;
|
|
73
73
|
stack?: string;
|
|
74
|
+
/** senpi-change: set by the runtime itself (never from script text) for its own memory-limit and stalled failures. */
|
|
75
|
+
reason?: "memory" | "unresolved";
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
/** Keys the script changed with `store()`. Only successful executions report writes. */
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CellSourceAtStart, EvalKernelRunInput } from "../../tool/types.ts";
|
|
2
|
+
|
|
3
|
+
/** The run input with its turn-time source applied, or the refusal to settle the cell with. */
|
|
4
|
+
export function inputAtStart<T extends EvalKernelRunInput>(input: T): T | { readonly refused: string } {
|
|
5
|
+
if (input.resolveAtStart === undefined) return input;
|
|
6
|
+
let source: CellSourceAtStart;
|
|
7
|
+
try {
|
|
8
|
+
source = input.resolveAtStart();
|
|
9
|
+
} catch (error) {
|
|
10
|
+
return { refused: error instanceof Error ? error.message : String(error) };
|
|
11
|
+
}
|
|
12
|
+
if (!source.ok) return { refused: source.message };
|
|
13
|
+
return {
|
|
14
|
+
...input,
|
|
15
|
+
code: source.code,
|
|
16
|
+
...(source.sourceFile === undefined ? {} : { sourceFile: source.sourceFile }),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import type { HostCellExecutor } from "../../tool/types.ts";
|
|
3
|
+
|
|
4
|
+
type HostCellOutcome = Awaited<ReturnType<HostCellExecutor>>;
|
|
5
|
+
type HostResult = Extract<KernelToHostMessage, { type: "result" }>;
|
|
6
|
+
|
|
7
|
+
export interface HostCellRun {
|
|
8
|
+
readonly cellId: string;
|
|
9
|
+
readonly abort: AbortController;
|
|
10
|
+
readonly done: Promise<HostResult>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runs a host entry (a cell the host executes, such as an install) for one queue slot: output after the outcome or after
|
|
15
|
+
* an abort belongs to no cell, a synchronous throw stays in this cell, and `settle` receives the cell's result.
|
|
16
|
+
* `settleAfterAbort` says whether an aborted entry still settles here or whoever aborted it settles the cell.
|
|
17
|
+
*/
|
|
18
|
+
export function runHostCell(
|
|
19
|
+
cellId: string,
|
|
20
|
+
host: HostCellExecutor,
|
|
21
|
+
io: {
|
|
22
|
+
readonly emit: (message: KernelToHostMessage) => void;
|
|
23
|
+
readonly settle: (result: HostResult) => void;
|
|
24
|
+
readonly durationMs: () => number;
|
|
25
|
+
readonly settleAfterAbort: boolean;
|
|
26
|
+
},
|
|
27
|
+
): HostCellRun {
|
|
28
|
+
const abort = new AbortController();
|
|
29
|
+
let finished = false;
|
|
30
|
+
const emit = (message: KernelToHostMessage): void => {
|
|
31
|
+
if (!finished && !abort.signal.aborted) io.emit(message);
|
|
32
|
+
};
|
|
33
|
+
const done = new Promise<HostCellOutcome>((resolve) => resolve(host({ signal: abort.signal, emit }))).then(
|
|
34
|
+
(outcome): HostResult =>
|
|
35
|
+
outcome.ok
|
|
36
|
+
? {
|
|
37
|
+
type: "result",
|
|
38
|
+
cellId,
|
|
39
|
+
ok: true,
|
|
40
|
+
durationMs: io.durationMs(),
|
|
41
|
+
...(outcome.valueRepr === undefined ? {} : { valueRepr: outcome.valueRepr }),
|
|
42
|
+
}
|
|
43
|
+
: { type: "result", cellId, ok: false, error: outcome.error, durationMs: 0 },
|
|
44
|
+
(error: unknown): HostResult => ({
|
|
45
|
+
type: "result",
|
|
46
|
+
cellId,
|
|
47
|
+
ok: false,
|
|
48
|
+
error: { message: error instanceof Error ? error.message : String(error) },
|
|
49
|
+
durationMs: 0,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
void done.then((result) => {
|
|
53
|
+
finished = true;
|
|
54
|
+
if (io.settleAfterAbort || !abort.signal.aborted) io.settle(result);
|
|
55
|
+
});
|
|
56
|
+
return { cellId, abort, done };
|
|
57
|
+
}
|
|
@@ -18,6 +18,8 @@ export interface EvalPromptParts {
|
|
|
18
18
|
|
|
19
19
|
export interface EvalPromptOptions {
|
|
20
20
|
readonly spawns: boolean;
|
|
21
|
+
/** `prompt.advertiseHelpers`: true appends one pointer line to the helper documentation entry. */
|
|
22
|
+
readonly advertiseHelpers?: boolean;
|
|
21
23
|
/** Whether the session registry exposes the monitor tool through eval. */
|
|
22
24
|
readonly monitor?: boolean;
|
|
23
25
|
readonly spawnDefaultAgent?: string;
|
|
@@ -74,6 +76,10 @@ export function evalEmphasisStyle(modelId: string | undefined): EvalEmphasisStyl
|
|
|
74
76
|
type ContextValue = string | boolean;
|
|
75
77
|
type Context = Readonly<Record<string, ContextValue>>;
|
|
76
78
|
|
|
79
|
+
/** The single line `prompt.advertiseHelpers: true` adds; off by default, so the default description is unchanged. */
|
|
80
|
+
export const ADVERTISED_HELPERS_LINE =
|
|
81
|
+
"Advanced cell helpers (handle controls, wait(), kernel tools in workpools) are documented on demand: tool_schema('eval:helpers').";
|
|
82
|
+
|
|
77
83
|
export function buildEvalPrompt(
|
|
78
84
|
enabled: EnabledLanguages,
|
|
79
85
|
options: EvalPromptOptions = { spawns: false },
|
|
@@ -104,9 +110,10 @@ export function buildEvalPrompt(
|
|
|
104
110
|
maxDetachedCells: String(options.maxDetachedCells ?? DEFAULT_MAX_DETACHED_CELLS),
|
|
105
111
|
kernelPreludeDocs: (options.kernelPreludes ?? []).map((prelude) => prelude.documentation).join("\n"),
|
|
106
112
|
};
|
|
107
|
-
const
|
|
113
|
+
const rendered = renderTemplate(EVAL_PROMPT_TEMPLATE, context)
|
|
108
114
|
.replace(/\n{3,}/g, "\n\n")
|
|
109
115
|
.trim();
|
|
116
|
+
const description = options.advertiseHelpers === true ? `${rendered}\n\n${ADVERTISED_HELPERS_LINE}` : rendered;
|
|
110
117
|
return {
|
|
111
118
|
description,
|
|
112
119
|
promptSnippet: "Run one incremental code cell in a persistent language kernel.",
|
package/src/tool/cell-handler.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface CellBridgeRuntime {
|
|
|
48
48
|
readonly ctx: ExtensionContext;
|
|
49
49
|
readonly artifactPath?: string;
|
|
50
50
|
readonly imageResizer?: EvalImageResizer;
|
|
51
|
-
/** This cell's live kernel-tool capability;
|
|
51
|
+
/** This cell's live kernel-tool capability; JS and Python kernels have one (#1754, #2731). */
|
|
52
52
|
readonly kernelTools?: KernelToolsCapability;
|
|
53
53
|
/** The session generation's handle registry behind `wait()` / `handle()` / completion handles. */
|
|
54
54
|
readonly handles?: HandleRegistry;
|
|
@@ -235,22 +235,20 @@ export class CellHandler {
|
|
|
235
235
|
this.#kernel.deliverToolReply({ type: "tool-reply", callId: message.callId, ok: true, value: reply.value });
|
|
236
236
|
} catch (error) {
|
|
237
237
|
if (!this.#state.active) return;
|
|
238
|
-
const
|
|
239
|
-
error instanceof Error ? error.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
238
|
+
const code =
|
|
239
|
+
error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined;
|
|
240
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
241
|
+
// A blocked call (a permission denial or another hook's veto) is not an argument problem.
|
|
242
|
+
const text =
|
|
243
|
+
code === "blocked"
|
|
244
|
+
? errorMessage
|
|
245
|
+
: appendSchemaHint(errorMessage, message.toolName, this.#toolParameters(message.toolName));
|
|
243
246
|
recordToolCall(this.#state.toolCalls, false, capture, undefined, text);
|
|
244
247
|
this.#kernel.deliverToolReply({
|
|
245
248
|
type: "tool-reply",
|
|
246
249
|
callId: message.callId,
|
|
247
250
|
ok: false,
|
|
248
|
-
error: {
|
|
249
|
-
message: text,
|
|
250
|
-
...(error instanceof Error && "code" in error && typeof error.code === "string"
|
|
251
|
-
? { code: error.code }
|
|
252
|
-
: {}),
|
|
253
|
-
},
|
|
251
|
+
error: { message: text, ...(code === undefined ? {} : { code }) },
|
|
254
252
|
});
|
|
255
253
|
}
|
|
256
254
|
this.#resultBuilder.emitUpdate(false);
|