@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
package/src/tool/eval-request.ts
CHANGED
|
@@ -17,9 +17,19 @@ export function normalizeEvalSummary(value: unknown): string | undefined {
|
|
|
17
17
|
return normalized.length === 0 ? undefined : normalized;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export class EvalIsolateInvalidError extends TypeError {
|
|
21
|
+
readonly name = "EvalIsolateInvalidError";
|
|
22
|
+
readonly code = "eval_isolate_invalid";
|
|
23
|
+
|
|
24
|
+
constructor(reason: string) {
|
|
25
|
+
super(`eval_isolate_invalid: ${reason}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
export function parseEvalRequest(
|
|
21
30
|
params: unknown,
|
|
22
31
|
enabledLanguages: readonly EvalLanguage[] = evalLanguageOrder,
|
|
32
|
+
options: { readonly sandbox?: boolean } = {},
|
|
23
33
|
): EvalToolRequest {
|
|
24
34
|
if (!isRecord(params)) throw new TypeError("eval parameters must be an object");
|
|
25
35
|
if (params.action === "list") return { action: "list" };
|
|
@@ -30,7 +40,9 @@ export function parseEvalRequest(
|
|
|
30
40
|
}
|
|
31
41
|
if (params.action !== undefined && params.action !== "run")
|
|
32
42
|
throw new TypeError(`Unknown eval action "${String(params.action)}"`);
|
|
33
|
-
if (
|
|
43
|
+
if (params.language === undefined) throw new TypeError(evalRunRequiresLanguageMessage(enabledLanguages));
|
|
44
|
+
if (!isEvalLanguage(params.language))
|
|
45
|
+
throw new TypeError(`eval run language must be one of: ${enabledLanguages.join(", ")}`);
|
|
34
46
|
if (typeof params.code !== "string")
|
|
35
47
|
throw new TypeError("eval run requires code — the cell body to execute, verbatim");
|
|
36
48
|
const summary = normalizeEvalSummary(params.summary);
|
|
@@ -40,6 +52,15 @@ export function parseEvalRequest(
|
|
|
40
52
|
);
|
|
41
53
|
if (params.on_timeout !== undefined && params.on_timeout !== "detach" && params.on_timeout !== "error")
|
|
42
54
|
throw new TypeError(`Unknown eval on_timeout value "${String(params.on_timeout)}"`);
|
|
55
|
+
if (params.isolate !== undefined && params.isolate !== false) {
|
|
56
|
+
if (params.isolate !== true) throw new EvalIsolateInvalidError("isolate must be true or false");
|
|
57
|
+
if (options.sandbox !== true) {
|
|
58
|
+
throw new EvalIsolateInvalidError("sandbox cells are turned off (set sandbox.enabled in codemode settings)");
|
|
59
|
+
}
|
|
60
|
+
if (params.language !== "js")
|
|
61
|
+
throw new EvalIsolateInvalidError('sandbox cells run JavaScript only (language: "js")');
|
|
62
|
+
if (params.reset === true) throw new EvalIsolateInvalidError("a sandbox cell starts fresh; reset does not apply");
|
|
63
|
+
}
|
|
43
64
|
return {
|
|
44
65
|
language: params.language,
|
|
45
66
|
code: params.code,
|
|
@@ -48,6 +69,7 @@ export function parseEvalRequest(
|
|
|
48
69
|
...(typeof params.timeout === "number" ? { timeout: params.timeout } : {}),
|
|
49
70
|
...(params.on_timeout === "detach" || params.on_timeout === "error" ? { on_timeout: params.on_timeout } : {}),
|
|
50
71
|
...(typeof params.reset === "boolean" ? { reset: params.reset } : {}),
|
|
72
|
+
...(params.isolate === true ? { isolate: true } : {}),
|
|
51
73
|
};
|
|
52
74
|
}
|
|
53
75
|
|
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
|
|
9
9
|
import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
|
|
10
10
|
import type { ResolvedCodemodeSettings } from "../config/settings.ts";
|
|
11
|
+
import type { JsEnvironments } from "../environments/js-environments.ts";
|
|
12
|
+
import type { PythonEnvironments } from "../environments/python-environments.ts";
|
|
11
13
|
import type { EvalExecutionTracker } from "../extension/session-manager.ts";
|
|
12
14
|
import type { HandleRegistry } from "../handles/handle-registry.ts";
|
|
13
15
|
import type { EvalTimeoutFactory } from "./cell-execution.ts";
|
|
@@ -69,6 +71,10 @@ export interface CreateEvalToolOptions {
|
|
|
69
71
|
readonly bunSkillPath?: string;
|
|
70
72
|
/** Kernel globals of the tools active when a cell is submitted; read once per cell. */
|
|
71
73
|
readonly kernelPreludes?: () => readonly KernelPreludeContribution[];
|
|
74
|
+
/** The session's Python environments: `%pip` / `%environment` cells and the import root of every Python cell. */
|
|
75
|
+
readonly pythonEnvironments?: PythonEnvironments;
|
|
76
|
+
/** The session's managed JavaScript packages: `%bun add` / `%npm add` cells and the bare-import fallback root. */
|
|
77
|
+
readonly jsEnvironments?: JsEnvironments;
|
|
72
78
|
/** Contributions whose documentation lines the description lists; snapshot taken when the tool is (re)registered. */
|
|
73
79
|
readonly promptKernelPreludes?: readonly KernelPreludeContribution[];
|
|
74
80
|
}
|
package/src/tool/eval-tool.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentToolResult, ToolDefinition } from "@code-yeongyu/senpi";
|
|
2
2
|
import type { TUnsafe } from "typebox";
|
|
3
|
+
import { resolveAdvertiseHelpers, resolveSandbox } from "../config/feature-settings.ts";
|
|
3
4
|
import {
|
|
4
5
|
DEFAULT_FOREGROUND_WINDOW_SECONDS,
|
|
5
6
|
defaultCodemodeSettings,
|
|
@@ -8,7 +9,12 @@ import {
|
|
|
8
9
|
import { buildEvalPrompt } from "../prompt/eval-prompt.ts";
|
|
9
10
|
import { EvalDetachedCellManager } from "./detached-cell-manager.ts";
|
|
10
11
|
import { executeEvalControl } from "./detached-eval-result.ts";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
EvalIsolateInvalidError,
|
|
14
|
+
isEvalControlRequest,
|
|
15
|
+
normalizeEvalSummary,
|
|
16
|
+
parseEvalRequest,
|
|
17
|
+
} from "./eval-request.ts";
|
|
12
18
|
import type { CreateEvalToolOptions } from "./eval-tool-options.ts";
|
|
13
19
|
import { runEvalCell } from "./run-eval-cell.ts";
|
|
14
20
|
import {
|
|
@@ -43,9 +49,11 @@ export function createEvalTool(options: CreateEvalToolOptions) {
|
|
|
43
49
|
foregroundWindowSeconds,
|
|
44
50
|
hardLimitSeconds: options.hardLimitSeconds ?? defaultEvalDeadlineSeconds.hardLimitSeconds,
|
|
45
51
|
};
|
|
46
|
-
const
|
|
52
|
+
const sandbox = resolveSandbox(options.settings ?? defaultCodemodeSettings).enabled;
|
|
53
|
+
const parameters = createEvalInputSchema(options.enabledLanguages, deadlines, { sandbox });
|
|
47
54
|
const prompt = buildEvalPrompt(options.enabledLanguages, {
|
|
48
55
|
spawns: options.spawns ?? false,
|
|
56
|
+
...(resolveAdvertiseHelpers(options.settings ?? defaultCodemodeSettings) ? { advertiseHelpers: true } : {}),
|
|
49
57
|
monitor: options.monitor,
|
|
50
58
|
maxDetachedCells,
|
|
51
59
|
runBudgetSeconds: deadlines.runBudgetSeconds,
|
|
@@ -74,9 +82,14 @@ export function createEvalTool(options: CreateEvalToolOptions) {
|
|
|
74
82
|
async function execute(
|
|
75
83
|
...[toolCallId, params, signal, onUpdate, ctx]: EvalExecuteArgs<EvalToolRequest>
|
|
76
84
|
): Promise<AgentToolResult<EvalResultDetails>> {
|
|
77
|
-
const request = parseEvalRequest(params, languages);
|
|
85
|
+
const request = parseEvalRequest(params, languages, { sandbox });
|
|
78
86
|
if (isEvalControlRequest(request)) return await executeEvalControl(cellManager, request);
|
|
79
|
-
if (options.proxyExecutor)
|
|
87
|
+
if (options.proxyExecutor) {
|
|
88
|
+
// A proxy runs cells elsewhere and knows nothing of sandbox cells: an isolated cell must never run unisolated.
|
|
89
|
+
if ("isolate" in request && request.isolate === true)
|
|
90
|
+
throw new EvalIsolateInvalidError("isolate: true is not available through this eval proxy");
|
|
91
|
+
return await options.proxyExecutor(request, signal);
|
|
92
|
+
}
|
|
80
93
|
if (!languages.includes(request.language))
|
|
81
94
|
throw new RangeError(
|
|
82
95
|
`Unsupported eval language "${request.language}". Enabled languages: ${languages.join(", ")}`,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export type LoadedCell =
|
|
6
|
+
| { readonly ok: true; readonly code: string; readonly sourceFile: string }
|
|
7
|
+
| { readonly ok: false; readonly message: string };
|
|
8
|
+
|
|
9
|
+
export interface LoadCellOptions {
|
|
10
|
+
readonly cwd: string;
|
|
11
|
+
readonly artifactsDir: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MAX_LOAD_BYTES = 8 * 1024 * 1024;
|
|
15
|
+
const URL_SCHEME = /^([a-z][a-z0-9+.-]*):\/\//i;
|
|
16
|
+
|
|
17
|
+
function errorCode(error: unknown): string | undefined {
|
|
18
|
+
return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
|
|
19
|
+
? error.code
|
|
20
|
+
: undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveTarget(target: string, options: LoadCellOptions): { path: string } | { message: string } {
|
|
24
|
+
const scheme = URL_SCHEME.exec(target)?.[1]?.toLowerCase();
|
|
25
|
+
try {
|
|
26
|
+
if (scheme === "file") return { path: fileURLToPath(target) };
|
|
27
|
+
if (scheme === "local") {
|
|
28
|
+
if (options.artifactsDir === undefined)
|
|
29
|
+
return { message: "%load local:// needs a session artifacts directory" };
|
|
30
|
+
const root = join(options.artifactsDir, "local");
|
|
31
|
+
const path = resolve(root, decodeURIComponent(target.slice("local://".length)));
|
|
32
|
+
const inside = relative(root, path);
|
|
33
|
+
if (inside.startsWith("..") || isAbsolute(inside))
|
|
34
|
+
return { message: `%load path escapes local://: ${target}` };
|
|
35
|
+
return { path };
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// fileURLToPath and decodeURIComponent throw on a malformed URL; the message names the user's own text.
|
|
39
|
+
return { message: `%load could not read the path in ${target}` };
|
|
40
|
+
}
|
|
41
|
+
if (scheme !== undefined) {
|
|
42
|
+
return {
|
|
43
|
+
message: `%load reads local files only (a path, local:// or file://); it does not fetch ${scheme}:// URLs`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return { path: resolve(options.cwd, target) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reads a `%load` target when its cell's turn comes in the kernel's queue, so a file the previous cell wrote is
|
|
51
|
+
* there. Every failure is a refusal that names the target as the user wrote it, never an absolute path.
|
|
52
|
+
*/
|
|
53
|
+
/** Reads from `fd` until EOF or `limit` bytes, whichever comes first; a file still growing cannot exceed it. */
|
|
54
|
+
export function readAtMost(fd: number, limit: number): Buffer {
|
|
55
|
+
const buffer = Buffer.alloc(limit);
|
|
56
|
+
let filled = 0;
|
|
57
|
+
while (filled < limit) {
|
|
58
|
+
const read = readSync(fd, buffer, filled, limit - filled, null);
|
|
59
|
+
if (read === 0) break;
|
|
60
|
+
filled += read;
|
|
61
|
+
}
|
|
62
|
+
return buffer.subarray(0, filled);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function loadCell(target: string, options: LoadCellOptions): LoadedCell {
|
|
66
|
+
const resolved = resolveTarget(target, options);
|
|
67
|
+
if ("message" in resolved) return { ok: false, message: resolved.message };
|
|
68
|
+
let fd: number | undefined;
|
|
69
|
+
try {
|
|
70
|
+
// One non-blocking open, then checks on that same handle: a FIFO or device swapped in after a check can
|
|
71
|
+
// neither block the host nor be read, and the size cap keeps a huge file from stalling the event loop.
|
|
72
|
+
fd = openSync(resolved.path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
73
|
+
const info = fstatSync(fd);
|
|
74
|
+
if (!info.isFile()) return { ok: false, message: `not a file: ${target}` };
|
|
75
|
+
const tooLarge = { ok: false as const, message: `%load reads files up to 8 MiB: ${target}` };
|
|
76
|
+
if (info.size > MAX_LOAD_BYTES) return tooLarge;
|
|
77
|
+
const bytes = readAtMost(fd, MAX_LOAD_BYTES + 1);
|
|
78
|
+
if (bytes.length > MAX_LOAD_BYTES) return tooLarge;
|
|
79
|
+
return { ok: true, code: bytes.toString("utf8"), sourceFile: resolved.path };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const code = errorCode(error);
|
|
82
|
+
if (code === "ENOENT" || code === "ENOTDIR") return { ok: false, message: `file not found: ${target}` };
|
|
83
|
+
if (code === "EACCES" || code === "EPERM") return { ok: false, message: `permission denied: ${target}` };
|
|
84
|
+
return { ok: false, message: `%load could not read ${target}${code === undefined ? "" : ` (${code})`}` };
|
|
85
|
+
} finally {
|
|
86
|
+
if (fd !== undefined) closeSync(fd);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { JsEnvironments } from "../environments/js-environments.ts";
|
|
2
|
+
import type { PythonEnvironments } from "../environments/python-environments.ts";
|
|
3
|
+
import { TIMEOUT_PAUSE_OP, TIMEOUT_RESUME_OP } from "../timeouts/bridge-timeout.ts";
|
|
4
|
+
import { MagicCellError, parseMagicCell } from "./magic-cells.ts";
|
|
5
|
+
import type { EvalLanguage, HostCellExecutor } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export type MagicCellPlan =
|
|
8
|
+
| { readonly kind: "ordinary" }
|
|
9
|
+
| { readonly kind: "host"; readonly executor: HostCellExecutor }
|
|
10
|
+
| { readonly kind: "refused"; readonly message: string }
|
|
11
|
+
| { readonly kind: "load"; readonly target: string };
|
|
12
|
+
|
|
13
|
+
export function planMagicCell(
|
|
14
|
+
language: EvalLanguage,
|
|
15
|
+
code: string,
|
|
16
|
+
environments: PythonEnvironments | undefined,
|
|
17
|
+
jsEnvironments?: JsEnvironments,
|
|
18
|
+
): MagicCellPlan {
|
|
19
|
+
let magic: ReturnType<typeof parseMagicCell>;
|
|
20
|
+
try {
|
|
21
|
+
magic = parseMagicCell(language, code);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof MagicCellError) return { kind: "refused", message: error.message };
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
if (magic === undefined) return { kind: "ordinary" };
|
|
27
|
+
if (magic.kind === "load") return { kind: "load", target: magic.target };
|
|
28
|
+
if (language === "js") return planJsMagic(magic, jsEnvironments);
|
|
29
|
+
if (environments === undefined) {
|
|
30
|
+
return {
|
|
31
|
+
kind: "refused",
|
|
32
|
+
message: "environment_installer_unavailable: this session has no Python interpreter to install packages for",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (magic.kind === "environment") {
|
|
36
|
+
const mode = magic.mode;
|
|
37
|
+
return {
|
|
38
|
+
kind: "host",
|
|
39
|
+
executor: async () => {
|
|
40
|
+
await environments.setMode(mode);
|
|
41
|
+
const where = mode === "project" ? "the session directory" : "this session's managed revisions";
|
|
42
|
+
return { ok: true, valueRepr: `environment: ${mode} (${where})` };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const requirements = magic.args;
|
|
47
|
+
return {
|
|
48
|
+
kind: "host",
|
|
49
|
+
// Install time is parked time for the run budget, the same accounting as a bridge call.
|
|
50
|
+
executor: async ({ signal, emit }) => {
|
|
51
|
+
emit({ type: "status", event: { op: TIMEOUT_PAUSE_OP } });
|
|
52
|
+
try {
|
|
53
|
+
const receipt = await environments.install(requirements, signal, (stream, data) =>
|
|
54
|
+
emit({ type: "text", stream, data }),
|
|
55
|
+
);
|
|
56
|
+
const packages = receipt.resolved.length > 0 ? receipt.resolved.join(", ") : "nothing new";
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
valueRepr: `installed ${packages} into ${receipt.mode} (revision ${receipt.revision}); already-imported modules stay cached until reset`,
|
|
60
|
+
};
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
return { ok: false, error: { message } };
|
|
64
|
+
} finally {
|
|
65
|
+
emit({ type: "status", event: { op: TIMEOUT_RESUME_OP } });
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function planJsMagic(
|
|
72
|
+
magic: Exclude<NonNullable<ReturnType<typeof parseMagicCell>>, { kind: "load" }>,
|
|
73
|
+
environments: JsEnvironments | undefined,
|
|
74
|
+
): MagicCellPlan {
|
|
75
|
+
if (environments === undefined) {
|
|
76
|
+
return {
|
|
77
|
+
kind: "refused",
|
|
78
|
+
message: "environment_installer_unavailable: this session has no JavaScript package environment",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (magic.kind === "environment") {
|
|
82
|
+
const mode = magic.mode;
|
|
83
|
+
return {
|
|
84
|
+
kind: "host",
|
|
85
|
+
executor: async () => {
|
|
86
|
+
await environments.setMode(mode);
|
|
87
|
+
const where = mode === "project" ? "the session directory" : "this session's managed revisions";
|
|
88
|
+
return { ok: true, valueRepr: `environment: ${mode} (${where})` };
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (magic.kind !== "js-add") return { kind: "refused", message: "this magic is not available in JavaScript" };
|
|
93
|
+
const requested = magic.args;
|
|
94
|
+
return {
|
|
95
|
+
kind: "host",
|
|
96
|
+
executor: async ({ signal, emit }) => {
|
|
97
|
+
emit({ type: "status", event: { op: TIMEOUT_PAUSE_OP } });
|
|
98
|
+
try {
|
|
99
|
+
const receipt = await environments.install(
|
|
100
|
+
requested,
|
|
101
|
+
signal,
|
|
102
|
+
(stream, data) => emit({ type: "text", stream, data }),
|
|
103
|
+
magic.installer,
|
|
104
|
+
);
|
|
105
|
+
const added = receipt.added.length > 0 ? receipt.added.join(", ") : "nothing new";
|
|
106
|
+
const where =
|
|
107
|
+
receipt.revision === undefined ? receipt.mode : `${receipt.mode} (revision ${receipt.revision})`;
|
|
108
|
+
const shadow =
|
|
109
|
+
receipt.shadowed.length > 0
|
|
110
|
+
? `; environment_resolution_conflict: ${receipt.shadowed.join(", ")} still ${receipt.shadowed.length === 1 ? "resolves" : "resolve"} from the project's node_modules first`
|
|
111
|
+
: "";
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
valueRepr: `added ${added} with ${receipt.installer} into ${where}${shadow}; already-imported modules stay cached until reset`,
|
|
115
|
+
};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
|
|
118
|
+
} finally {
|
|
119
|
+
emit({ type: "status", event: { op: TIMEOUT_RESUME_OP } });
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { EvalLanguage } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export type MagicCell =
|
|
4
|
+
| { readonly kind: "pip"; readonly args: string }
|
|
5
|
+
| { readonly kind: "environment"; readonly mode: "managed" | "project" }
|
|
6
|
+
| { readonly kind: "load"; readonly target: string }
|
|
7
|
+
| { readonly kind: "js-add"; readonly installer: "bun" | "npm"; readonly args: string };
|
|
8
|
+
|
|
9
|
+
const HOST_MAGICS = ["pip", "environment", "load", "bun", "npm"] as const;
|
|
10
|
+
const LOAD_LANGUAGES: ReadonlySet<EvalLanguage> = new Set(["py", "js"]);
|
|
11
|
+
const COMMENT_PREFIX: Partial<Record<EvalLanguage, string>> = { py: "#", js: "//" };
|
|
12
|
+
type HostMagic = (typeof HOST_MAGICS)[number];
|
|
13
|
+
|
|
14
|
+
export class MagicCellError extends Error {
|
|
15
|
+
readonly name = "MagicCellError";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function joinContinuations(lines: readonly string[]): string[] {
|
|
19
|
+
const joined: string[] = [];
|
|
20
|
+
let pending: string | undefined;
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
const current = pending === undefined ? line : `${pending} ${line.trim()}`;
|
|
23
|
+
if (current.trimEnd().endsWith("\\")) pending = current.trimEnd().slice(0, -1).trimEnd();
|
|
24
|
+
else {
|
|
25
|
+
joined.push(current);
|
|
26
|
+
pending = undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (pending !== undefined) joined.push(pending);
|
|
30
|
+
return joined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hostMagicOf(language: EvalLanguage, line: string): HostMagic | undefined {
|
|
34
|
+
const match = /^%([A-Za-z]+)(?:\s|$)/.exec(line.trim());
|
|
35
|
+
const name = match?.[1];
|
|
36
|
+
const magic = HOST_MAGICS.find((candidate) => candidate === name);
|
|
37
|
+
if (magic === "load") return LOAD_LANGUAGES.has(language) ? magic : undefined;
|
|
38
|
+
if (magic === "bun" || magic === "npm") return language === "js" ? magic : undefined;
|
|
39
|
+
if (magic === "environment") return language === "py" || language === "js" ? magic : undefined;
|
|
40
|
+
return language === "py" ? magic : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A cell whose first code line (blank and comment lines skipped) is a host magic runs on the host instead of
|
|
45
|
+
* the interpreter: `%pip` and `%environment` in Python, `%load` in Python and JavaScript. In Python a trailing
|
|
46
|
+
* backslash continues the line. Any other cell is ordinary code, so a magic-looking line later in the cell
|
|
47
|
+
* (say, inside a string) is left alone. A magic followed by more code is refused, because the host step must
|
|
48
|
+
* finish before the code that depends on it runs.
|
|
49
|
+
*/
|
|
50
|
+
export function parseMagicCell(language: EvalLanguage, code: string): MagicCell | undefined {
|
|
51
|
+
const comment = COMMENT_PREFIX[language];
|
|
52
|
+
// Comment lines go before Python's backslash continuations are joined: Python never continues a comment line.
|
|
53
|
+
const raw = code.split("\n").filter((line) => comment === undefined || !line.trim().startsWith(comment));
|
|
54
|
+
const lines = (language === "py" ? joinContinuations(raw) : raw).filter((line) => line.trim() !== "");
|
|
55
|
+
const first = lines[0] ?? "";
|
|
56
|
+
const magic = hostMagicOf(language, first);
|
|
57
|
+
if (magic === undefined) return undefined;
|
|
58
|
+
if (lines.length > 1)
|
|
59
|
+
throw new MagicCellError(`put %${magic} on its own cell, then run the code that uses it in the next cell`);
|
|
60
|
+
const args = first.trim().slice(`%${magic}`.length).trim();
|
|
61
|
+
if (magic === "pip") return { kind: "pip", args };
|
|
62
|
+
if (magic === "bun" || magic === "npm") {
|
|
63
|
+
const [verb, ...rest] = args.split(/\s+/);
|
|
64
|
+
if (verb !== "add" && verb !== "install") {
|
|
65
|
+
throw new MagicCellError(`%${magic} supports only add: %${magic} add <package ...>`);
|
|
66
|
+
}
|
|
67
|
+
return { kind: "js-add", installer: magic, args: rest.join(" ") };
|
|
68
|
+
}
|
|
69
|
+
if (magic === "load") {
|
|
70
|
+
if (args === "") throw new MagicCellError("%load takes one argument: the path of the file to run");
|
|
71
|
+
return { kind: "load", target: args };
|
|
72
|
+
}
|
|
73
|
+
if (args === "managed" || args === "project") return { kind: "environment", mode: args };
|
|
74
|
+
throw new MagicCellError("%environment takes one argument: managed or project");
|
|
75
|
+
}
|
|
@@ -7,12 +7,14 @@ import {
|
|
|
7
7
|
kernelToolsStorage,
|
|
8
8
|
} from "@code-yeongyu/senpi";
|
|
9
9
|
import type { KernelToHostMessage } from "../bridge/protocol.ts";
|
|
10
|
+
import { resolveSandbox } from "../config/feature-settings.ts";
|
|
10
11
|
import { DEFAULT_FOREGROUND_WINDOW_SECONDS, defaultCodemodeSettings } from "../config/settings.ts";
|
|
11
12
|
import {
|
|
12
13
|
KERNEL_TOOLS_CAPABILITIES,
|
|
13
14
|
type KernelToolsCapability,
|
|
14
15
|
type KernelToolsDescribeResult,
|
|
15
16
|
} from "../kernels/js/kernel-tools-types.ts";
|
|
17
|
+
import { sandboxCellExecutor } from "../kernels/sandbox/sandbox-cell.ts";
|
|
16
18
|
import { TIMEOUT_PAUSE_OP, TIMEOUT_RESUME_OP } from "../timeouts/bridge-timeout.ts";
|
|
17
19
|
import { abortError, CellExecution, defaultTimeoutFactory } from "./cell-execution.ts";
|
|
18
20
|
import { CellHandler, type CellState } from "./cell-handler.ts";
|
|
@@ -23,6 +25,8 @@ import { EvalKernelResetRefusedError } from "./eval-kernel-reset-refused-error.t
|
|
|
23
25
|
import { evalTimeoutBehavior } from "./eval-request.ts";
|
|
24
26
|
import type { CreateEvalToolOptions, EvalCellInvocation } from "./eval-tool-options.ts";
|
|
25
27
|
import { describeTimeoutState } from "./interrupt-note.ts";
|
|
28
|
+
import { loadCell } from "./load-cell.ts";
|
|
29
|
+
import { planMagicCell } from "./magic-cell-host.ts";
|
|
26
30
|
import type { EvalKernel, EvalToolDetails } from "./types.ts";
|
|
27
31
|
|
|
28
32
|
export async function runEvalCell(
|
|
@@ -205,11 +209,21 @@ async function executeCell(
|
|
|
205
209
|
const pending = handler.handle(message);
|
|
206
210
|
void pending.catch((error: unknown) => execution.cancel(error));
|
|
207
211
|
};
|
|
212
|
+
let releaseCellKernelTools: (() => void) | undefined;
|
|
208
213
|
try {
|
|
209
214
|
const kernel = await execution.wait(options.kernelManager.getKernel(invocation.input.language, onMessage));
|
|
210
215
|
// Computed before the handler so its construction-time snapshot captures this cell's capability.
|
|
211
216
|
// Worker messages later restore that snapshot before calling host tools (#1754, #2512).
|
|
212
217
|
const kernelTools = kernelToolsFor(kernel);
|
|
218
|
+
// Subprocess kernels call the host over the bridge, outside this async context. Their calls carry a fresh secret
|
|
219
|
+
// minted for this run and sent only to this cell's kernel, never the model-visible cell id, so code in another
|
|
220
|
+
// kernel cannot name this cell into its tools. JS calls already run inside this cell's scope (#1754).
|
|
221
|
+
const bridgeCellToken =
|
|
222
|
+
kernelTools === undefined || invocation.input.language === "js" ? undefined : randomUUID();
|
|
223
|
+
releaseCellKernelTools =
|
|
224
|
+
kernelTools === undefined || bridgeCellToken === undefined
|
|
225
|
+
? undefined
|
|
226
|
+
: options.kernelManager.bindCellKernelTools?.(bridgeCellToken, kernelTools);
|
|
213
227
|
const runBound = async (): Promise<AgentToolResult<EvalToolDetails>> => {
|
|
214
228
|
const queue = kernel.queueSnapshot();
|
|
215
229
|
state.queuedBehind = [...(queue.activeCellId === null ? [] : [queue.activeCellId]), ...queue.queuedCellIds];
|
|
@@ -249,10 +263,51 @@ async function executeCell(
|
|
|
249
263
|
}
|
|
250
264
|
if (invocation.input.reset) await execution.wait(kernel.reset());
|
|
251
265
|
execution.setKernel(kernel);
|
|
266
|
+
const magic = planMagicCell(
|
|
267
|
+
invocation.input.language,
|
|
268
|
+
invocation.input.code,
|
|
269
|
+
options.pythonEnvironments,
|
|
270
|
+
options.jsEnvironments,
|
|
271
|
+
);
|
|
272
|
+
// Resolved when the cell's turn comes in the kernel's queue: a %load reads the file the cells ahead of it
|
|
273
|
+
// wrote, and a refusal settles in queue order like any cell.
|
|
274
|
+
const loadOptions = { cwd: invocation.ctx.cwd, artifactsDir: options.artifactsDir };
|
|
275
|
+
// An isolated cell never runs a file outside its sandbox: %load there is refused.
|
|
276
|
+
const isolatedLoad = invocation.input.isolate === true && magic.kind === "load";
|
|
277
|
+
const resolveAtStart = isolatedLoad
|
|
278
|
+
? () => ({
|
|
279
|
+
ok: false as const,
|
|
280
|
+
message:
|
|
281
|
+
"isolate: true cannot run a %load cell: an isolated cell sees no host files, and loading one would break that isolation. Run %load in a normal cell, or paste the code into the isolated cell.",
|
|
282
|
+
})
|
|
283
|
+
: magic.kind === "load"
|
|
284
|
+
? () => loadCell(magic.target, loadOptions)
|
|
285
|
+
: magic.kind === "refused"
|
|
286
|
+
? () => ({ ok: false as const, message: magic.message })
|
|
287
|
+
: undefined;
|
|
288
|
+
const isolated =
|
|
289
|
+
invocation.input.isolate === true && resolveAtStart === undefined
|
|
290
|
+
? sandboxCellExecutor(invocation.input.code, {
|
|
291
|
+
sandbox: resolveSandbox(options.settings ?? {}),
|
|
292
|
+
executeTool: options.executeTool,
|
|
293
|
+
toolNames: () => (options.listTools?.() ?? []).map((tool) => tool.name),
|
|
294
|
+
describeTool: (name) => options.listTools?.().find((tool) => tool.name === name)?.description,
|
|
295
|
+
})
|
|
296
|
+
: undefined;
|
|
297
|
+
const environments = invocation.input.language === "py" ? options.pythonEnvironments : undefined;
|
|
298
|
+
const envRoot = environments === undefined ? undefined : () => environments.activeRoot ?? "";
|
|
299
|
+
const jsEnvironments = invocation.input.language === "js" ? options.jsEnvironments : undefined;
|
|
300
|
+
const packageRoot = jsEnvironments === undefined ? undefined : () => jsEnvironments.packageRoot;
|
|
252
301
|
const result = await execution.wait(
|
|
253
302
|
kernel.run({
|
|
254
303
|
cellId: invocation.cellId,
|
|
255
304
|
code: invocation.input.code,
|
|
305
|
+
...(resolveAtStart === undefined ? {} : { resolveAtStart }),
|
|
306
|
+
...(magic.kind === "host" ? { host: magic.executor } : {}),
|
|
307
|
+
...(isolated === undefined ? {} : { host: isolated }),
|
|
308
|
+
...(envRoot === undefined ? {} : { envRoot }),
|
|
309
|
+
...(packageRoot === undefined ? {} : { packageRoot }),
|
|
310
|
+
...(bridgeCellToken === undefined ? {} : { bridgeCellToken }),
|
|
256
311
|
kernelPreludes: options.kernelPreludes?.(),
|
|
257
312
|
onMessage,
|
|
258
313
|
onStarted: () => {
|
|
@@ -285,6 +340,8 @@ async function executeCell(
|
|
|
285
340
|
if (error instanceof Error && error.name === "TimeoutError") throw await describeTimeoutState(error, execution);
|
|
286
341
|
throw error;
|
|
287
342
|
} finally {
|
|
343
|
+
// First, so nothing below can skip it: from here on a host call carrying this run's secret gets no kernel tools.
|
|
344
|
+
releaseCellKernelTools?.();
|
|
288
345
|
state.active = false;
|
|
289
346
|
bridgeAbortController.abort();
|
|
290
347
|
execution.finish();
|
package/src/tool/types.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
DEFAULT_RUN_BUDGET_SECONDS,
|
|
9
9
|
defaultCodemodeSettings,
|
|
10
10
|
} from "../config/settings.ts";
|
|
11
|
+
import type { KernelToolsCapability } from "../kernels/js/kernel-tools-types.ts";
|
|
11
12
|
import type { TruncationMeta } from "../output/output-meta.ts";
|
|
12
13
|
|
|
13
14
|
export const evalLanguageOrder = ["js", "py", "rb", "jl"] as const;
|
|
@@ -51,6 +52,8 @@ export interface EvalToolInput {
|
|
|
51
52
|
readonly timeout?: number;
|
|
52
53
|
readonly on_timeout?: "detach" | "error";
|
|
53
54
|
readonly reset?: boolean;
|
|
55
|
+
/** Run in a fresh sandbox VM instead of the persistent kernel; accepted only while `sandbox.enabled` is on. */
|
|
56
|
+
readonly isolate?: boolean;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
export interface EvalListInput {
|
|
@@ -116,14 +119,26 @@ const fullEvalInputSchema = Type.Object(
|
|
|
116
119
|
/** Runtime accepts a discriminated run/control union. */
|
|
117
120
|
export type EvalInputSchema = TUnsafe<EvalToolRequest> & Pick<typeof fullEvalInputSchema, "properties">;
|
|
118
121
|
|
|
122
|
+
const ISOLATE_FIELD_DESCRIPTION =
|
|
123
|
+
"js only. Fresh sandbox per call: no persistence, no fs/net/process/timers; only tool.* plus print/display.";
|
|
124
|
+
|
|
119
125
|
export function createEvalInputSchema(
|
|
120
126
|
enabled: EnabledEvalLanguages,
|
|
121
127
|
deadlines: EvalDeadlineSeconds = defaultEvalDeadlineSeconds,
|
|
128
|
+
options: { readonly sandbox?: boolean } = {},
|
|
122
129
|
): EvalInputSchema {
|
|
123
130
|
const languages = enabledLanguageList(enabled);
|
|
124
131
|
if (languages.length === 0) throw new Error("eval requires at least one enabled language");
|
|
125
132
|
const languageSchema = evalLanguageUnion(languages);
|
|
126
|
-
|
|
133
|
+
// The sandbox field exists in the schema only while sandbox cells are turned on, so the default schema
|
|
134
|
+
// (and the prompt built from it) stays exactly what it was.
|
|
135
|
+
const properties =
|
|
136
|
+
options.sandbox === true && languages.includes("js")
|
|
137
|
+
? {
|
|
138
|
+
...evalInputProperties(languageSchema, deadlines),
|
|
139
|
+
isolate: Type.Optional(Type.Boolean({ description: ISOLATE_FIELD_DESCRIPTION })),
|
|
140
|
+
}
|
|
141
|
+
: evalInputProperties(languageSchema, deadlines);
|
|
127
142
|
return Type.Unsafe<EvalToolRequest>(
|
|
128
143
|
Type.Object(properties, {
|
|
129
144
|
// Keep branches self-contained for Mistral-hosted GLM (#2240).
|
|
@@ -167,8 +182,35 @@ export interface EvalKernelRunInput {
|
|
|
167
182
|
readonly onMessage?: (message: KernelToHostMessage) => void;
|
|
168
183
|
/** Globals of the tools active when the cell was submitted; kernels without preludes ignore them. */
|
|
169
184
|
readonly kernelPreludes?: readonly KernelPreludeContribution[];
|
|
185
|
+
/**
|
|
186
|
+
* Python only: read when the cell starts running (not when it was queued), so a cell queued behind `%pip`
|
|
187
|
+
* sees the revision it published. `""` means no environment: the previous revision leaves the import path.
|
|
188
|
+
*/
|
|
189
|
+
readonly envRoot?: () => string;
|
|
190
|
+
/** The file a `%load` cell runs: tracebacks name it and its relative imports resolve from its directory. */
|
|
191
|
+
readonly sourceFile?: string;
|
|
192
|
+
/**
|
|
193
|
+
* JavaScript only: the session's managed package revision; bare imports that do not resolve from cwd fall back to
|
|
194
|
+
* it. Read when the cell starts running (not when it was queued), so a cell queued behind `%bun add` resolves the
|
|
195
|
+
* revision that install published.
|
|
196
|
+
*/
|
|
197
|
+
readonly packageRoot?: () => string | undefined;
|
|
198
|
+
/**
|
|
199
|
+
* Called when the cell's turn comes in the kernel's queue, never earlier: what to run instead of `code` (a `%load`
|
|
200
|
+
* cell's file), or a refusal that settles the cell as failed in queue order.
|
|
201
|
+
*/
|
|
202
|
+
readonly resolveAtStart?: () => CellSourceAtStart;
|
|
203
|
+
/**
|
|
204
|
+
* Subprocess kernels attach this to the cell's host calls, and the host gives those calls the cell's kernel tools.
|
|
205
|
+
* A fresh secret per run, sent only to the kernel that runs the cell; never a model-visible id.
|
|
206
|
+
*/
|
|
207
|
+
readonly bridgeCellToken?: string;
|
|
170
208
|
}
|
|
171
209
|
|
|
210
|
+
export type CellSourceAtStart =
|
|
211
|
+
| { readonly ok: true; readonly code: string; readonly sourceFile?: string }
|
|
212
|
+
| { readonly ok: false; readonly message: string };
|
|
213
|
+
|
|
172
214
|
export interface KernelInterruptHandle {
|
|
173
215
|
/** Resolves once the kernel knows whether user state survived the interrupt. */
|
|
174
216
|
readonly stateRetained: Promise<boolean>;
|
|
@@ -205,6 +247,11 @@ export interface EvalKernelManager {
|
|
|
205
247
|
* Identity-checked, so releasing a superseded listener never unbinds a newer cell's listener.
|
|
206
248
|
*/
|
|
207
249
|
releaseKernelListener?(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): void;
|
|
250
|
+
/**
|
|
251
|
+
* Binds a running cell's kernel-tools capability to the host calls that carry `token` (subprocess kernels reach
|
|
252
|
+
* the host over the bridge, outside the cell's async context). The returned release runs when the cell settles.
|
|
253
|
+
*/
|
|
254
|
+
bindCellKernelTools?(token: string, capability: KernelToolsCapability): () => void;
|
|
208
255
|
}
|
|
209
256
|
|
|
210
257
|
export type ExecuteTool = (
|