@code-yeongyu/senpi-codemode 2026.10.8 → 2026.10.9
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 +20 -0
- package/README.md +51 -3
- package/package.json +4 -4
- 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/environments/py-installer.ts +64 -4
- package/src/environments/python-environments.ts +85 -0
- package/src/environments/revision-store.ts +18 -3
- 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 +12 -0
- package/src/extension/session-manager-proxy.ts +6 -0
- package/src/extension/session-manager.ts +10 -2
- package/src/index.ts +1 -0
- package/src/kernels/jl/prelude.jl +6 -1
- package/src/kernels/js/context-manager.ts +15 -1
- package/src/kernels/js/local-module-loader.ts +9 -4
- package/src/kernels/js/worker-runtime.js +3 -1
- package/src/kernels/js/workpool.js +5 -2
- package/src/kernels/py/kernel.ts +8 -2
- package/src/kernels/py/prelude.py +112 -9
- package/src/kernels/py/transport.ts +13 -1
- package/src/kernels/rb/workpool.rb +7 -1
- package/src/kernels/shared/cell-source-at-start.ts +18 -0
- package/src/tool/cell-handler.ts +1 -1
- package/src/tool/eval-tool-options.ts +3 -0
- package/src/tool/load-cell.ts +73 -0
- package/src/tool/magic-cell-host.ts +65 -0
- package/src/tool/magic-cells.ts +65 -0
- package/src/tool/run-eval-cell.ts +30 -0
- package/src/tool/types.ts +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,26 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.10.9] - 2026-10-04
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Python `agent(prompt, tools=[...])` grants the child the cell's `@tool` functions by name, like JavaScript's `agent(prompt, { tools })`; anything other than a list of names is refused with `invalid_tools` ([#2731](https://github.com/code-yeongyu/senpi/issues/2731)).
|
|
22
|
+
- `workpool(agent, name, {mode, tools})` forwards `tools`, a list of kernel-tool names the cell defined, to the host workpool unchanged in all four languages, so pool workers can call them; anything other than a list of names is refused with `invalid_tools` before reaching the host. Which kernels' tools a host accepts is the host's call: JavaScript `tool(fn)` tools work on omo today, while Python `@tool` tools need omo#9529 ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
|
|
23
|
+
- A Python or JavaScript cell that is only `%load <path>` runs that local file as the cell: its definitions persist, Python tracebacks name the file and its sibling modules import, and JavaScript resolves the file's relative imports from its directory; remote URLs are refused ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
|
|
24
|
+
- A Python cell that is only `%pip install <requirements>` installs packages without restarting the kernel; the next cell imports them. Packages go into the session's own environment (or `<cwd>/.senpi/python-packages` after `%environment project`), never the interpreter's site-packages or the user site, and a failed or cancelled install leaves the previous packages active ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- A Python cell can now grant its `@tool` functions to a child ([#2731](https://github.com/code-yeongyu/senpi/issues/2731)). Host calls from Python cells (`tool.task(..., tools=[...])`, `agent(..., tools=[...])`, `workpool(..., tools=[...])`) reached the host with no kernel-tools capability, so every such grant was refused as unavailable. Each call now carries its cell, and the host gives it that cell's capability while the cell runs; a call from another, unknown or finished cell gets none.
|
|
31
|
+
- `%pip` parsing follow-ups ([#2689](https://github.com/code-yeongyu/senpi/pull/2689)): a `%pip` or `%environment` line after code now says to put it on its own cell instead of "Unsupported line magic"; a comment line ending in a backslash no longer swallows the `%pip` line after it; inside double quotes a backslash is kept unless it escapes a quote, backslash, `$` or a backtick, as a POSIX shell does.
|
|
32
|
+
|
|
33
|
+
### Removed
|
|
34
|
+
|
|
15
35
|
## [2026.10.8] - 2026-10-04
|
|
16
36
|
|
|
17
37
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -100,6 +100,51 @@ getter and may name a file that does not exist yet. It honors session-directory
|
|
|
100
100
|
and in-memory sessions; it cannot be derived reliably from `PI_SESSION_FILE`. If the host
|
|
101
101
|
omits the getter, the variable is unset rather than inherited from the launching process.
|
|
102
102
|
|
|
103
|
+
### Loading a file
|
|
104
|
+
|
|
105
|
+
A Python or JavaScript cell whose only line is `%load <path>` reads that file and
|
|
106
|
+
runs it as the cell, so its definitions persist like any cell. The path is relative
|
|
107
|
+
to the session directory, or a `local://` or `file://` URL; other URL schemes are
|
|
108
|
+
refused and nothing is fetched. The cell runs as its file: Python compiles it under
|
|
109
|
+
the file's name, so tracebacks name the file and line, and sets `__file__` and puts
|
|
110
|
+
the file's directory first on the import path, so `from sibling import g` resolves
|
|
111
|
+
next to it. JavaScript evaluates it under the file's name and resolves its relative
|
|
112
|
+
imports from the file's directory. The file is read when the cell's turn comes in
|
|
113
|
+
the kernel's queue, so a `%load` queued behind the cell that writes the file runs
|
|
114
|
+
what that cell wrote. When the cell ends, Python takes the file's directory back off
|
|
115
|
+
the import path and restores `__file__`, so later cells import as before. A file that
|
|
116
|
+
cannot be read fails the cell with a message naming the path as written
|
|
117
|
+
(`file not found: <path>`, `permission denied: <path>`); files over 8 MiB, and
|
|
118
|
+
anything that is not a regular file, are refused.
|
|
119
|
+
|
|
120
|
+
### Python packages
|
|
121
|
+
|
|
122
|
+
A Python cell whose only line is `%pip install <requirements>` installs packages
|
|
123
|
+
without restarting the kernel: variables, the process and its working directory
|
|
124
|
+
stay as they were. The cell runs in the kernel's queue like any other cell (it waits
|
|
125
|
+
for the cell ahead of it, can be cancelled while queued, and detaches past the
|
|
126
|
+
foreground window), but the install runs on the host, so its time does not count
|
|
127
|
+
against the run budget. The next cell imports the new packages; modules a cell
|
|
128
|
+
already imported stay cached until `reset`.
|
|
129
|
+
|
|
130
|
+
Packages go into the session's own environment, never into the interpreter's
|
|
131
|
+
site-packages or the user site: pip always runs as
|
|
132
|
+
`<kernel interpreter> -m pip install --target <environment>`, and flags that pick
|
|
133
|
+
another destination (`--target`, `--user`, `--prefix`, `--root`, `--home`,
|
|
134
|
+
`-e`) are refused. Each install builds a new revision of the environment and
|
|
135
|
+
publishes it only when pip succeeds, so a failed or cancelled install leaves the
|
|
136
|
+
previous packages active and importable. A cell that mixes `%pip` with code is
|
|
137
|
+
refused; put `%pip` on its own cell.
|
|
138
|
+
|
|
139
|
+
`%environment managed` (the default) keeps the environment under the session's
|
|
140
|
+
artifacts directory, or `environments.managedRoot` when set; `%environment project`
|
|
141
|
+
installs into `<cwd>/.senpi/python-packages` instead, shared by every session in
|
|
142
|
+
that project, with concurrent installs serialised by a lock. Setting
|
|
143
|
+
`environments.autoProvision` to `false` turns installs off
|
|
144
|
+
(`environment_installer_unavailable`). Failures carry pip's error output and one
|
|
145
|
+
of `environment_install_failed`, `environment_install_cancelled`,
|
|
146
|
+
`environment_installer_unavailable` or `environment_resolution_conflict`.
|
|
147
|
+
|
|
103
148
|
## Settings
|
|
104
149
|
|
|
105
150
|
Configuration is loaded in this order:
|
|
@@ -207,7 +252,7 @@ options object and asynchronous helpers are `await`-able.
|
|
|
207
252
|
| `agent(prompt, ...)` | Delegates to the configured active `taskTools.task` tool. Supports background handles and structured JSON results. |
|
|
208
253
|
| `wait(handles, timeout?, mode?)` | Blocks the cell until the given handles settle (agent handle records, `handle()` views, completion handles, closed workpools, or saved `{kind, id, run_epoch}` references). `mode` is `all` (values in input order; the first failed, cancelled, or lost handle raises), `any` (`{index, ref, value}` of the first success), or `settled` (one outcome per input slot). `timeout` is wall-clock seconds from entry; on expiry `eval_wait_timeout` is raised and nothing is cancelled. Rides the bridge-call path, so the run budget pauses while parked. Agent and workpool handles need the host's `EvalHandleHost` capability (`eval_wait_unavailable` without it); completion handles always work. Julia extends `Base.wait` for handle views (`wait(handle(node))`). Details: `tool_schema("eval:wait")`. |
|
|
209
254
|
| `handle(node \| ref \| {pool_id})` | Returns a rich view: the legacy record's fields plus a non-enumerable `control` (Python: attribute on a `dict` subclass; Ruby: singleton method) with `status()`, `output(format?, offset?, limit?)`, `send(message)` (agent handles only), `cancel()` (idempotent for that run epoch; never touches a successor run), and `wait(timeout?)`. Every control call is fenced by owner, id, and `run_epoch` inside the task owner (`eval_handle_stale`, `eval_handle_forbidden`). `completion(prompt, handle: true)` returns such a view for a host completion. The `agent(..., handle: true)` record itself is unchanged. Details: `tool_schema("eval:helpers")`. |
|
|
210
|
-
| `workpool(agent, name, mode?)` | Creates a thin adapter over the normal host `workpool` tool; exposes `pool_id`, `push(items)`, `close()`, `inspect()`, and `cancel()`. JS awaits creation and operations. |
|
|
255
|
+
| `workpool(agent, name, mode?, tools?)` | Creates a thin adapter over the normal host `workpool` tool; exposes `pool_id`, `push(items)`, `close()`, `inspect()`, and `cancel()`. JS awaits creation and operations. `tools` is a list of kernel-tool names this cell defined; the pool's workers may call exactly those, the host refuses any name the caller doesn't hold, and a value that isn't a list of names raises `invalid_tools`. Ruby and Julia define no kernel tools, so they have nothing to grant. |
|
|
211
256
|
| `output(ids, format?, offset?, limit?)` | Delegates transcript retrieval to the configured active `taskTools.output` tool. |
|
|
212
257
|
| `parallel(thunks)` | Runs thunks through the configured bounded pool while preserving input order. |
|
|
213
258
|
| `pipeline(items, ...stages)` | Applies stages left to right with a barrier between stages. |
|
|
@@ -256,8 +301,11 @@ Any isolation metadata supplied by the host on a handle is preserved as
|
|
|
256
301
|
`{subagent_type, prompt, model?}` as its plain-data agent spec. Mode is `fresh`
|
|
257
302
|
or `keep_alive`: pass `{mode: "fresh"}` in JS, `mode="fresh"` in Python/Julia,
|
|
258
303
|
or `mode: "fresh"` in Ruby. Omission is forwarded unchanged to the engine;
|
|
259
|
-
hosts without an approved default still require an explicit mode.
|
|
260
|
-
names
|
|
304
|
+
hosts without an approved default still require an explicit mode. `tools`
|
|
305
|
+
(kernel-tool names, for example from Python `@tool` or JavaScript `tool(fn)`) is
|
|
306
|
+
forwarded unchanged to the host's `create`, which validates it: `{tools: ["add"]}`
|
|
307
|
+
in JS, `tools=["add"]` in Python/Julia, `tools: ["add"]` in Ruby. There is no
|
|
308
|
+
`pool.wait()`; the host delivers the aggregate.
|
|
261
309
|
|
|
262
310
|
`push` forwards `[{key, input}]` and returns the host receipt without waiting
|
|
263
311
|
for admission. Operations return the same `{text, details, images?, hasError?}`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.9",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -32,15 +32,15 @@
|
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@babel/parser": "8.0.4",
|
|
35
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.
|
|
35
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.9",
|
|
36
36
|
"quickjs-wasi": "3.6.2",
|
|
37
37
|
"typebox": "1.3.34"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
40
|
+
"@code-yeongyu/senpi": "2026.10.9"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
43
|
+
"@code-yeongyu/senpi": "2026.10.9"
|
|
44
44
|
},
|
|
45
45
|
"keywords": [
|
|
46
46
|
"senpi",
|
|
@@ -8,6 +8,8 @@ const LOOPBACK_HOST = "127.0.0.1";
|
|
|
8
8
|
|
|
9
9
|
export interface BridgeHttpCallRequest {
|
|
10
10
|
callId: string;
|
|
11
|
+
/** The calling run's secret; the host resolves that run's kernel-tools capability from it. */
|
|
12
|
+
cellToken?: string;
|
|
11
13
|
toolName: string;
|
|
12
14
|
args: unknown;
|
|
13
15
|
signal: AbortSignal;
|
|
@@ -132,7 +134,13 @@ async function dispatchCall(body: unknown, options: BridgeServerOptions, signal:
|
|
|
132
134
|
try {
|
|
133
135
|
return {
|
|
134
136
|
ok: true,
|
|
135
|
-
value: await options.onCall({
|
|
137
|
+
value: await options.onCall({
|
|
138
|
+
callId: body.callId,
|
|
139
|
+
...(typeof body.cellToken === "string" ? { cellToken: body.cellToken } : {}),
|
|
140
|
+
toolName: body.toolName,
|
|
141
|
+
args: body.args,
|
|
142
|
+
signal,
|
|
143
|
+
}),
|
|
136
144
|
};
|
|
137
145
|
} catch (error) {
|
|
138
146
|
return { ok: false, error: bridgeError(error) };
|
package/src/bridge/protocol.ts
CHANGED
|
@@ -44,6 +44,10 @@ const hostToKernelMessageSchema = Type.Union([
|
|
|
44
44
|
cellId: Type.String({ minLength: 1 }),
|
|
45
45
|
code: Type.String(),
|
|
46
46
|
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
47
|
+
/** Python only: the published environment revision to import from, applied before the cell runs; empty removes the previous one. */
|
|
48
|
+
envRoot: Type.Optional(Type.String()),
|
|
49
|
+
/** The file a `%load` cell runs; the runner compiles under its name and imports beside it. */
|
|
50
|
+
sourceFile: Type.Optional(Type.String({ minLength: 1 })),
|
|
47
51
|
/** Python only: active tool globals to install before the cell and deactivated ones to remove. */
|
|
48
52
|
preludes: Type.Optional(
|
|
49
53
|
Type.Object({
|
|
@@ -52,6 +52,7 @@ const helpersEntry: EvalSchemaResult = {
|
|
|
52
52
|
"control.status() goes through the host subscription; control.send/cancel/output are fenced by owner, id and run_epoch inside the task owner (stale -> eval_handle_stale, foreign -> eval_handle_forbidden); control.output() returns only that epoch's transcript. The plain output() helper and task tools are unchanged.",
|
|
53
53
|
"The legacy agent(..., {handle: true}) record is unchanged: .output stays a string and no enumerable key is added. A saved {kind, id, run_epoch} (or the record itself) can be rebound with handle(ref) in a later cell or after a kernel reset while the owner and run epoch are valid.",
|
|
54
54
|
"completion(prompt, {handle: true}) (py: handle=True) returns a control handle after preflight validation; provider failures surface in its outcome; its deadline derives from the creating cell's hard deadline. Completion handles are codemode-owned and need no host capability; once the session generation is dropped they fail with eval_handle_stale.",
|
|
55
|
+
"workpool(agent, name, {mode?, tools?}) (py/jl: mode=, tools=; rb: mode:, tools:) forwards tools (kernel-tool names, e.g. from @tool or tool(fn)) unchanged to the host workpool tool's create; the host checks them. There is no pool.wait(): the host delivers the aggregate and the kernel stays free to serve kernel tools.",
|
|
55
56
|
"See tool_schema('eval:wait') for the barrier semantics.",
|
|
56
57
|
].join("\n"),
|
|
57
58
|
parameters: {
|
|
@@ -61,11 +61,71 @@ const SHORT_ALIASES: Readonly<Record<string, string>> = {
|
|
|
61
61
|
const STDERR_TAIL_BYTES = 4_096;
|
|
62
62
|
const PIP_TREE_GRACE_MS = 2_000;
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Splits a %pip argument line the way the platform's shell would for these inputs: single and double quotes
|
|
66
|
+
* group (`"pkg[extra]>=1.0"`) and `#` at the start of a word begins a comment. A backslash escapes the next
|
|
67
|
+
* character only in POSIX mode; on Windows it is a path separator and stays as written. An unclosed quote is
|
|
68
|
+
* refused rather than guessed.
|
|
69
|
+
*/
|
|
70
|
+
const DOUBLE_QUOTE_ESCAPES: ReadonlySet<string> = new Set(['"', "\\", "$", "`"]);
|
|
71
|
+
|
|
72
|
+
export function splitShellWords(text: string, posix = process.platform !== "win32"): string[] {
|
|
73
|
+
const words: string[] = [];
|
|
74
|
+
let word = "";
|
|
75
|
+
let inWord = false;
|
|
76
|
+
let quote: "'" | '"' | undefined;
|
|
77
|
+
for (let index = 0; index < text.length; index++) {
|
|
78
|
+
const char = text[index] ?? "";
|
|
79
|
+
if (quote === "'") {
|
|
80
|
+
if (char === "'") quote = undefined;
|
|
81
|
+
else word += char;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (posix && char === "\\" && index + 1 < text.length) {
|
|
85
|
+
const next = text[index + 1] ?? "";
|
|
86
|
+
// A backslash-newline is a line continuation in a shell, inside double quotes or not: both characters go.
|
|
87
|
+
if (next === "\n") {
|
|
88
|
+
index += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// Inside double quotes a shell only escapes these; any other backslash stays as written.
|
|
92
|
+
if (quote === '"' && !DOUBLE_QUOTE_ESCAPES.has(next)) {
|
|
93
|
+
word += char;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
index += 1;
|
|
97
|
+
word += next;
|
|
98
|
+
inWord = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (quote === '"') {
|
|
102
|
+
if (char === '"') quote = undefined;
|
|
103
|
+
else word += char;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (char === "'" || char === '"') {
|
|
107
|
+
quote = char;
|
|
108
|
+
inWord = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (/\s/u.test(char)) {
|
|
112
|
+
if (inWord) words.push(word);
|
|
113
|
+
word = "";
|
|
114
|
+
inWord = false;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (char === "#" && !inWord) break;
|
|
118
|
+
word += char;
|
|
119
|
+
inWord = true;
|
|
120
|
+
}
|
|
121
|
+
if (quote !== undefined)
|
|
122
|
+
throw new EnvironmentError("environment_install_failed", `unclosed ${quote} quote in %pip arguments`);
|
|
123
|
+
if (inWord) words.push(word);
|
|
124
|
+
return words;
|
|
125
|
+
}
|
|
126
|
+
|
|
64
127
|
export function parsePipRequirements(text: string): string[] {
|
|
65
|
-
const args = text
|
|
66
|
-
.trim()
|
|
67
|
-
.split(/\s+/)
|
|
68
|
-
.filter((arg) => arg !== "");
|
|
128
|
+
const args = splitShellWords(text);
|
|
69
129
|
const command = args[0] === "install" ? args.slice(1) : undefined;
|
|
70
130
|
if (command === undefined) {
|
|
71
131
|
throw new EnvironmentError("environment_install_failed", "only `%pip install <requirements>` is supported");
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ResolvedCodemodeSettings } from "../config/settings.ts";
|
|
2
|
+
import {
|
|
3
|
+
type EnvironmentMode,
|
|
4
|
+
type InstallReceipt,
|
|
5
|
+
installPythonPackages,
|
|
6
|
+
managedEnvironmentBase,
|
|
7
|
+
projectPythonBase,
|
|
8
|
+
pythonAbiTag,
|
|
9
|
+
} from "./py-environment.ts";
|
|
10
|
+
import { EnvironmentError } from "./py-installer.ts";
|
|
11
|
+
import { readActiveRevisionSync } from "./revision-store.ts";
|
|
12
|
+
|
|
13
|
+
export interface PythonEnvironmentsOptions {
|
|
14
|
+
readonly artifactsDir: string;
|
|
15
|
+
readonly cwd: string;
|
|
16
|
+
readonly interpreter: string;
|
|
17
|
+
readonly settings: Pick<ResolvedCodemodeSettings, "environments">;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class PythonEnvironments {
|
|
21
|
+
readonly #options: PythonEnvironmentsOptions;
|
|
22
|
+
#mode: EnvironmentMode = "managed";
|
|
23
|
+
#currentBase: string | undefined;
|
|
24
|
+
#managedBase: Promise<string> | undefined;
|
|
25
|
+
|
|
26
|
+
constructor(options: PythonEnvironmentsOptions) {
|
|
27
|
+
this.#options = options;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get mode(): EnvironmentMode {
|
|
31
|
+
return this.#mode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The revision a Python cell imports from, read from the current mode's root each time a cell starts, so an
|
|
36
|
+
* install by another session sharing the project root is seen. Undefined while nothing is installed there.
|
|
37
|
+
*/
|
|
38
|
+
get activeRoot(): string | undefined {
|
|
39
|
+
return this.#currentBase === undefined ? undefined : readActiveRevisionSync(this.#currentBase)?.dir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async setMode(mode: EnvironmentMode): Promise<string> {
|
|
43
|
+
const base = await this.#base(mode);
|
|
44
|
+
this.#mode = mode;
|
|
45
|
+
this.#currentBase = base;
|
|
46
|
+
return base;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async install(
|
|
50
|
+
requirements: string,
|
|
51
|
+
signal: AbortSignal,
|
|
52
|
+
onOutput?: (stream: "stdout" | "stderr", data: string) => void,
|
|
53
|
+
): Promise<InstallReceipt> {
|
|
54
|
+
if (this.#options.settings.environments?.autoProvision === false) {
|
|
55
|
+
throw new EnvironmentError(
|
|
56
|
+
"environment_installer_unavailable",
|
|
57
|
+
"installs are turned off for this project (environments.autoProvision is false)",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const mode = this.#mode;
|
|
61
|
+
const base = await this.#base(mode);
|
|
62
|
+
const receipt = await installPythonPackages({
|
|
63
|
+
base,
|
|
64
|
+
mode,
|
|
65
|
+
interpreter: this.#options.interpreter,
|
|
66
|
+
requirements,
|
|
67
|
+
cwd: this.#options.cwd,
|
|
68
|
+
signal,
|
|
69
|
+
...(onOutput === undefined ? {} : { onOutput }),
|
|
70
|
+
});
|
|
71
|
+
if (this.#mode === mode) this.#currentBase = base;
|
|
72
|
+
return receipt;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#base(mode: EnvironmentMode): Promise<string> {
|
|
76
|
+
if (mode === "project") return Promise.resolve(projectPythonBase(this.#options.cwd));
|
|
77
|
+
const configured = this.#options.settings.environments?.managedRoot;
|
|
78
|
+
this.#managedBase ??= pythonAbiTag(this.#options.interpreter).then((abi) =>
|
|
79
|
+
configured === undefined
|
|
80
|
+
? managedEnvironmentBase(this.#options.artifactsDir, "py", abi)
|
|
81
|
+
: managedEnvironmentBase(configured, "py", abi),
|
|
82
|
+
);
|
|
83
|
+
return this.#managedBase;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { type LockWaitNotice, withRootLock } from "./install-lock.ts";
|
|
@@ -12,11 +13,25 @@ export interface Revision {
|
|
|
12
13
|
readonly dir: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
/** The same read, synchronous: the pointer is one short file, read when a Python cell starts running. */
|
|
17
|
+
export function readActiveRevisionSync(base: string): Revision | undefined {
|
|
18
|
+
try {
|
|
19
|
+
return parsePointer(base, readFileSync(join(base, POINTER), "utf8"));
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parsePointer(base: string, text: string): Revision | undefined {
|
|
27
|
+
const name = text.trim();
|
|
28
|
+
const match = REVISION.exec(name);
|
|
29
|
+
return match?.[1] === undefined ? undefined : { number: Number(match[1]), dir: join(base, name) };
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
export async function readActiveRevision(base: string): Promise<Revision | undefined> {
|
|
16
33
|
try {
|
|
17
|
-
|
|
18
|
-
const match = REVISION.exec(name);
|
|
19
|
-
return match?.[1] === undefined ? undefined : { number: Number(match[1]), dir: join(base, name) };
|
|
34
|
+
return parsePointer(base, await readFile(join(base, POINTER), "utf8"));
|
|
20
35
|
} catch (error) {
|
|
21
36
|
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
|
|
22
37
|
throw error;
|
|
@@ -8,6 +8,8 @@ export interface BridgeToolCallRequest {
|
|
|
8
8
|
readonly toolName: string;
|
|
9
9
|
readonly args: unknown;
|
|
10
10
|
readonly callId: string;
|
|
11
|
+
/** The calling run's secret; absent for a call made outside any run (a plain thread), which gets no kernel tools. */
|
|
12
|
+
readonly cellToken?: string;
|
|
11
13
|
readonly signal: AbortSignal;
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { kernelToolsStorage } from "@code-yeongyu/senpi";
|
|
2
|
+
import type { KernelToolsCapability } from "../kernels/js/kernel-tools-types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Kernel-tools capabilities of the subprocess-kernel cells running right now, keyed by a secret minted per run and
|
|
6
|
+
* sent only to the kernel running that cell. Those kernels (py/rb/jl) reach the host over the bridge, outside the
|
|
7
|
+
* submitting cell's async context, so a bridge call carries its run's secret and runs inside that run's capability:
|
|
8
|
+
* the same `kernelToolsStorage` scope the JS worker path enters per host call (#1754).
|
|
9
|
+
*/
|
|
10
|
+
export class CellKernelTools {
|
|
11
|
+
readonly #byToken = new Map<string, KernelToolsCapability>();
|
|
12
|
+
|
|
13
|
+
/** Makes `capability` the kernel tools of calls carrying `token` until the returned release runs. */
|
|
14
|
+
bind(token: string, capability: KernelToolsCapability): () => void {
|
|
15
|
+
this.#byToken.set(token, capability);
|
|
16
|
+
return () => {
|
|
17
|
+
if (this.#byToken.get(token) === capability) this.#byToken.delete(token);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Runs `call` with the capability bound to `token`; an absent, unknown or released token gets none. */
|
|
22
|
+
async run<T>(token: string | undefined, call: () => Promise<T>): Promise<T> {
|
|
23
|
+
const capability = token === undefined ? undefined : this.#byToken.get(token);
|
|
24
|
+
return capability === undefined
|
|
25
|
+
? await kernelToolsStorage.exit(call)
|
|
26
|
+
: await kernelToolsStorage.run(capability, call);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
KernelInterruptHandle,
|
|
14
14
|
PendingCell,
|
|
15
15
|
} from "../tool/types.ts";
|
|
16
|
+
import { hasKernelTools, type KernelToolsMethods } from "./kernel-tools-probe.ts";
|
|
16
17
|
|
|
17
18
|
export type StartKernel = (lifecycle: Required<KernelLifecycle>) => Promise<EvalKernel>;
|
|
18
19
|
|
|
@@ -50,10 +51,29 @@ export class ReplaceableKernel implements EvalKernel {
|
|
|
50
51
|
#closed = false;
|
|
51
52
|
#closeFailure: unknown;
|
|
52
53
|
|
|
54
|
+
// Present only when the instance has kernel tools, so a probe sees what it would see on the instance itself.
|
|
55
|
+
declare readonly describeKernelTools?: KernelToolsMethods["describeKernelTools"];
|
|
56
|
+
declare readonly invokeKernelTool?: KernelToolsMethods["invokeKernelTool"];
|
|
57
|
+
|
|
53
58
|
private constructor(language: EvalLanguage, start: StartKernel, first: EvalKernel) {
|
|
54
59
|
this.#language = language;
|
|
55
60
|
this.#start = start;
|
|
56
61
|
this.#kernel = first;
|
|
62
|
+
// Python is the one subprocess kernel with kernel tools; rb and jl only carry tools_unavailable stubs to hide.
|
|
63
|
+
if (language === "py" && hasKernelTools(first)) {
|
|
64
|
+
// Always the CURRENT instance: after a replacement the old definitions are gone with the old interpreter.
|
|
65
|
+
const tools: KernelToolsMethods = {
|
|
66
|
+
describeKernelTools: (names) => this.#currentTools().describeKernelTools(names),
|
|
67
|
+
invokeKernelTool: (request, options) => this.#currentTools().invokeKernelTool(request, options),
|
|
68
|
+
};
|
|
69
|
+
Object.assign(this, tools);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#currentTools(): KernelToolsMethods {
|
|
74
|
+
const current = this.#kernel;
|
|
75
|
+
if (!hasKernelTools(current)) throw new Error(`The ${this.#language} kernel has no kernel tools`);
|
|
76
|
+
return current;
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
/** Starts the first instance; one that cannot hand back its queue is returned as it is. */
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type ResolvedCodemodeSettings,
|
|
9
9
|
resolveEnabledLanguages,
|
|
10
10
|
} from "../config/settings.ts";
|
|
11
|
+
import { PythonEnvironments } from "../environments/python-environments.ts";
|
|
11
12
|
import type { HandleRegistry } from "../handles/handle-registry.ts";
|
|
12
13
|
import {
|
|
13
14
|
createInterpreterDetector,
|
|
@@ -47,6 +48,7 @@ export type SessionRuntime = {
|
|
|
47
48
|
readonly artifactsDir: string;
|
|
48
49
|
readonly executeTool: AgentExecuteTool;
|
|
49
50
|
readonly spawns: boolean;
|
|
51
|
+
readonly pythonEnvironments?: PythonEnvironments;
|
|
50
52
|
};
|
|
51
53
|
|
|
52
54
|
export async function createRuntime(
|
|
@@ -96,6 +98,16 @@ export async function createRuntime(
|
|
|
96
98
|
artifactsDir: artifacts.dir,
|
|
97
99
|
executeTool,
|
|
98
100
|
spawns: activeTools.has(settings.taskTools.task),
|
|
101
|
+
...(availability.py.detected.ok && enabledLanguages.py
|
|
102
|
+
? {
|
|
103
|
+
pythonEnvironments: new PythonEnvironments({
|
|
104
|
+
artifactsDir: artifacts.dir,
|
|
105
|
+
cwd: ctx.cwd,
|
|
106
|
+
interpreter: availability.py.detected.path,
|
|
107
|
+
settings,
|
|
108
|
+
}),
|
|
109
|
+
}
|
|
110
|
+
: {}),
|
|
99
111
|
};
|
|
100
112
|
}
|
|
101
113
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
2
2
|
import type { KernelToHostMessage } from "../bridge/protocol.ts";
|
|
3
3
|
import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
|
|
4
|
+
import type { KernelToolsCapability } from "../kernels/js/kernel-tools-types.ts";
|
|
4
5
|
import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
|
|
5
6
|
import {
|
|
6
7
|
CodemodeSessionDisposedError,
|
|
@@ -106,6 +107,11 @@ export class SessionManagerProxy implements CodemodeSessionManager, EvalExecutio
|
|
|
106
107
|
this.#current?.releaseKernelListener?.(language, onMessage);
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
bindCellKernelTools(token: string, capability: KernelToolsCapability): () => void {
|
|
111
|
+
// Bound to the generation that runs the cell: a later generation never serves that cell's host calls.
|
|
112
|
+
return this.#current?.bindCellKernelTools?.(token, capability) ?? (() => undefined);
|
|
113
|
+
}
|
|
114
|
+
|
|
109
115
|
async complete(request: CompletionRequest, ctx: ExtensionContext): Promise<CompletionResult> {
|
|
110
116
|
this.assertEvalExecutionAllowed();
|
|
111
117
|
const current = this.#current;
|
|
@@ -6,11 +6,12 @@ import type { CompletionRequest, CompletionResult } from "../completion/handler.
|
|
|
6
6
|
import { resolveKernelMemoryThresholds } from "../config/memory-settings.ts";
|
|
7
7
|
import { collectOrphanedChildren } from "../host-sdk.ts";
|
|
8
8
|
import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
|
|
9
|
-
import type { KernelToolsDescribeResult } from "../kernels/js/kernel-tools-types.ts";
|
|
9
|
+
import type { KernelToolsCapability, KernelToolsDescribeResult } from "../kernels/js/kernel-tools-types.ts";
|
|
10
10
|
import type { KernelLifecycle } from "../kernels/shared/kernel-death.ts";
|
|
11
11
|
import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
|
|
12
12
|
import { type BridgeDispatchContext, dispatchBridgeCompletion } from "./bridge-dispatch.ts";
|
|
13
13
|
import { type BridgeToolCallRequest, routeBridgeToolCall } from "./bridge-tool-call.ts";
|
|
14
|
+
import { CellKernelTools } from "./cell-kernel-tools.ts";
|
|
14
15
|
import { parkWhenIdle } from "./idle-parking-kernel.ts";
|
|
15
16
|
import {
|
|
16
17
|
javaScriptKernelMemory,
|
|
@@ -65,6 +66,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
65
66
|
readonly #registrations: SessionKernelRegistrations;
|
|
66
67
|
#kernelCreations = new Map<EvalLanguage, Promise<EvalKernel>>();
|
|
67
68
|
#onMessageRefs = new Map<EvalLanguage, (message: KernelToHostMessage) => void>();
|
|
69
|
+
readonly #cellKernelTools = new CellKernelTools();
|
|
68
70
|
#context: ExtensionContext | undefined;
|
|
69
71
|
#generation = 0;
|
|
70
72
|
#disposePromise: Promise<void> | undefined;
|
|
@@ -91,7 +93,13 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
async #call(request: BridgeToolCallRequest): Promise<unknown> {
|
|
94
|
-
return await
|
|
96
|
+
return await this.#cellKernelTools.run(request.cellToken, () =>
|
|
97
|
+
routeBridgeToolCall(this.#options, request, this.#context?.evalHandleHost),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
bindCellKernelTools(token: string, capability: KernelToolsCapability): () => void {
|
|
102
|
+
return this.#cellKernelTools.bind(token, capability);
|
|
95
103
|
}
|
|
96
104
|
|
|
97
105
|
async getKernel(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): Promise<EvalKernel> {
|
package/src/index.ts
CHANGED
|
@@ -157,6 +157,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
|
|
|
157
157
|
complete,
|
|
158
158
|
settings: runtime.settings,
|
|
159
159
|
artifactsDir: runtime.artifactsDir,
|
|
160
|
+
...(runtime.pythonEnvironments === undefined ? {} : { pythonEnvironments: runtime.pythonEnvironments }),
|
|
160
161
|
cellManager,
|
|
161
162
|
handles,
|
|
162
163
|
executionTracker: manager,
|
|
@@ -210,9 +210,14 @@ function Base.getproperty(pool::SenpiWorkpool, name::Symbol)
|
|
|
210
210
|
getfield(pool, name)
|
|
211
211
|
end
|
|
212
212
|
|
|
213
|
-
function workpool(agent::AbstractDict, name::AbstractString; mode=nothing)
|
|
213
|
+
function workpool(agent::AbstractDict, name::AbstractString; mode=nothing, tools=nothing)
|
|
214
214
|
args = Dict{String, Any}("op" => "create", "agent" => agent, "name" => name)
|
|
215
215
|
mode !== nothing && (args["mode"] = mode)
|
|
216
|
+
if tools !== nothing
|
|
217
|
+
(tools isa AbstractVector && all(name -> name isa AbstractString, tools)) ||
|
|
218
|
+
throw(SenpiBridgeError("workpool(tools=...) takes a vector of tool names; got $(typeof(tools))", "invalid_tools"))
|
|
219
|
+
args["tools"] = collect(String, tools)
|
|
220
|
+
end
|
|
216
221
|
result = senpi_workpool_call(args)
|
|
217
222
|
details = get(result, "details", nothing)
|
|
218
223
|
if details isa AbstractDict
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
2
|
import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
|
|
3
3
|
import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
4
|
+
import { inputAtStart } from "../shared/cell-source-at-start.ts";
|
|
4
5
|
import { KernelToolHostPump } from "../shared/kernel-tools-pump.ts";
|
|
5
6
|
import { ActiveCellControl } from "./active-cell-control.ts";
|
|
6
7
|
import { DEFAULT_INTERRUPT_BOUNDS, type WorkerRetirement } from "./interrupt-bounds.ts";
|
|
@@ -195,6 +196,19 @@ export class JavaScriptKernel {
|
|
|
195
196
|
if (this.#lifecycle !== "open" || this.#runs.active || !this.#slot.present) return;
|
|
196
197
|
const next = this.#runs.startNext(performance.now());
|
|
197
198
|
if (!next) return;
|
|
199
|
+
const input = inputAtStart(next.input);
|
|
200
|
+
if ("refused" in input) {
|
|
201
|
+
this.#runs.releaseActive(next);
|
|
202
|
+
this.#runs.settle(next, {
|
|
203
|
+
type: "result",
|
|
204
|
+
cellId: next.input.cellId,
|
|
205
|
+
ok: false,
|
|
206
|
+
error: { message: input.refused },
|
|
207
|
+
durationMs: 0,
|
|
208
|
+
});
|
|
209
|
+
this.#startNext();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
198
212
|
this.#activeCell.arm(next);
|
|
199
213
|
this.#slot.postMessage({
|
|
200
214
|
type: "kernel-tools-names",
|
|
@@ -204,7 +218,7 @@ export class JavaScriptKernel {
|
|
|
204
218
|
this.#slot.postMessage({
|
|
205
219
|
type: "run",
|
|
206
220
|
cellId: next.input.cellId,
|
|
207
|
-
code: this.#moduleLoader.prepareCell(
|
|
221
|
+
code: this.#moduleLoader.prepareCell(input.code, input.kernelPreludes, input.sourceFile),
|
|
208
222
|
timeoutMs: next.input.timeoutMs,
|
|
209
223
|
});
|
|
210
224
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join, resolve, sep } from "node:path";
|
|
1
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
3
|
import type { KernelPreludeContribution } from "@code-yeongyu/senpi";
|
|
4
4
|
import type { BridgeConnectionConfig } from "../../bridge/protocol.ts";
|
|
@@ -121,8 +121,13 @@ export class LocalModuleLoader {
|
|
|
121
121
|
this.#prelude = loaderPrelude(runtimeContext(options));
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
prepareCell(code: string, contributions: readonly KernelPreludeContribution[] = []): string {
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
prepareCell(code: string, contributions: readonly KernelPreludeContribution[] = [], sourceFile?: string): string {
|
|
125
|
+
// A %load cell resolves its relative imports from its own file's directory for that cell only.
|
|
126
|
+
const base =
|
|
127
|
+
sourceFile === undefined
|
|
128
|
+
? ""
|
|
129
|
+
: `\nglobalThis.__senpi_module_context__ = { ...globalThis.__senpi_module_context__, cwdUrl: ${JSON.stringify(directoryUrl(dirname(sourceFile)))} };`;
|
|
130
|
+
const prelude = `${this.#prelude}${base}\n${contributionPrelude(this.#contributions.plan(contributions))}`;
|
|
131
|
+
return `${PREPARED_CELL_PREFIX}${JSON.stringify({ prelude, code: rewriteImports(code), ...(sourceFile === undefined ? {} : { sourceFile }) })}`;
|
|
127
132
|
}
|
|
128
133
|
}
|
|
@@ -65,13 +65,15 @@ export class JsWorkerRuntime {
|
|
|
65
65
|
try {
|
|
66
66
|
let prelude = "";
|
|
67
67
|
let cellCode = code;
|
|
68
|
+
let sourceName = cellId;
|
|
68
69
|
if (code.startsWith(PREPARED_CELL_PREFIX)) {
|
|
69
70
|
const prepared = JSON.parse(code.slice(PREPARED_CELL_PREFIX.length));
|
|
70
71
|
if (!isPlainObject(prepared) || typeof prepared.prelude !== "string" || typeof prepared.code !== "string") throw new Error("Invalid prepared JavaScript cell payload");
|
|
71
72
|
({ prelude, code: cellCode } = prepared);
|
|
73
|
+
if (typeof prepared.sourceFile === "string") sourceName = prepared.sourceFile;
|
|
72
74
|
}
|
|
73
75
|
if (prelude) indirectEval(prelude, PRELUDE_SOURCE_URL);
|
|
74
|
-
const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)),
|
|
76
|
+
const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)), sourceName));
|
|
75
77
|
await this.#drainPendingDisplays();
|
|
76
78
|
return value;
|
|
77
79
|
} finally {
|