@code-yeongyu/senpi-codemode 2026.9.4 → 2026.9.5-3
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 +71 -0
- package/README.md +4 -2
- package/package.json +5 -5
- package/src/config/settings.ts +29 -0
- package/src/extension/session-manager.ts +8 -4
- package/src/index.ts +3 -1
- package/src/kernels/js/display-image.js +162 -0
- package/src/kernels/js/prelude.ts +2 -2
- package/src/kernels/js/worker-runtime.js +29 -60
- package/src/kernels/py/prelude.py +66 -1
- package/src/prompt/eval-prompt.ts +35 -96
- package/src/tool/cell-execution.ts +7 -0
- package/src/tool/eval-tool-options.ts +6 -0
- package/src/tool/eval-tool.ts +14 -2
- package/src/tool/types.ts +12 -7
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,76 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.9.5-3] - 2026-09-05
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
### Removed
|
|
26
|
+
|
|
27
|
+
## [2026.9.5-2] - 2026-09-05
|
|
28
|
+
|
|
29
|
+
### Breaking Changes
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- The GPT eval dialect now routes a wait or a long run through `tool.monitor` inside the cell (the subscription line precedes the detach note, and the `## Tool Guidelines` line says so when `monitor` is reachable), so a GPT model no longer reads "long cells detach" as the way to wait on a `--watch`.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
### Removed
|
|
40
|
+
|
|
41
|
+
## [2026.9.5] - 2026-09-05
|
|
42
|
+
|
|
43
|
+
### Breaking Changes
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
### Removed
|
|
52
|
+
|
|
53
|
+
## [2026.9.4-3] - 2026-09-04
|
|
54
|
+
|
|
55
|
+
### Breaking Changes
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- The package `test` script runs `vitest run test/` instead of `npx tsx …/vitest/dist/cli.js`, matching every other workspace package. The old form spawned npm and tsx to reach the vitest CLI that is already a direct dependency.
|
|
62
|
+
### Fixed
|
|
63
|
+
|
|
64
|
+
### Removed
|
|
65
|
+
|
|
66
|
+
## [2026.9.4-2] - 2026-09-04
|
|
67
|
+
|
|
68
|
+
### Breaking Changes
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- `foregroundWindowSeconds` codemode setting (default `60`, env `SENPI_CODEMODE_FOREGROUND_SECONDS`): the longest an interactive `eval` call blocks the turn before the cell detaches. A larger `timeout` now frees the turn at this window while the cell keeps running to the hard limit, instead of blocking the agent loop for the whole `timeout`.
|
|
73
|
+
|
|
74
|
+
### Changed
|
|
75
|
+
|
|
76
|
+
- The Bun kernel line of the `eval` description now names `new Bun.WebView()` as the headless browser and states when to reach for it (a page that needs JS, a login, or a screenshot) instead of `curl` or a browser CLI. The line previously advertised `Bun.*` builtins generically, so sessions on a Bun kernel resolved page work to `curl`/`fetch` and never discovered the in-process browser. Node kernels are unchanged.
|
|
77
|
+
- The `eval` tool description is dieted a second time: the `Fields:` list now defers to the parameter schema (its single home), the detach guidance is one paragraph, and helper lines keep every signature with fewer words. gpt/codex dialect 1,489 -> 1,087 o200k tokens (description + guidelines); claude 1,173, kimi 1,190, default 1,189. Also fixes the fused `jl` handle form in the all-languages render.
|
|
78
|
+
|
|
79
|
+
- The `eval` tool description is dieted from ~2002 to ~1588 tokens (codex dialect): the three reuse-chain JSON examples, the `<workflow>` graph prose, the repeated state-persistence rules, and the per-dialect wait-doctrine clause are removed or folded; every helper signature and dialect routing is kept. The workflow block's fused `handle=True{ handle: true }` is fixed into per-language correct forms.
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
|
|
83
|
+
### Removed
|
|
84
|
+
|
|
15
85
|
## [2026.9.4] - 2026-09-04
|
|
16
86
|
|
|
17
87
|
### Breaking Changes
|
|
@@ -247,6 +317,7 @@
|
|
|
247
317
|
|
|
248
318
|
### Fixed
|
|
249
319
|
|
|
320
|
+
- An explicit `timeout` no longer silently disables detach for interactive `eval` cells. Previously `timeout` was both the detach budget and the hard-limit extension with no cap, so a call like `timeout: 7000` (intended to keep a long detached cell alive) blocked the agent loop for ~2h before the hard limit killed it. The detach point is now capped at the foreground window; `on_timeout: "error"` (and print/json) keep `timeout` as the unclamped deadline, and the hard-limit extension (`max(hardLimitSeconds, timeout)`) is unchanged.
|
|
250
321
|
- Detached-eval same-language busy errors now name each idle enabled kernel and tell the agent to continue the step there (`continue this step in an idle kernel: js`), instead of only pointing at peek and the output tail. A busy Python kernel no longer reads as "eval is unavailable", which previously sent agents to `bash`+`python3` while JavaScript (or another idle kernel) was free. Single-language sessions and fully-busy sessions omit the idle-kernel claim.
|
|
251
322
|
- JavaScript eval cells now persist only top-level declarations, including destructuring bindings and uninitialized variables, without rewriting declaration-shaped text inside literals or comments.
|
|
252
323
|
- Eval completion and detached-cell handling retain explicit lifecycle observability: nested tool counts, wall/kernel timing, detach state, `peek`, `stop`, hard limits, and crash recovery remain bounded and machine-readable for hosts and telemetry consumers.
|
package/README.md
CHANGED
|
@@ -71,6 +71,7 @@ Configuration is loaded in this order:
|
|
|
71
71
|
"jl": false
|
|
72
72
|
},
|
|
73
73
|
"cellTimeoutSeconds": 30,
|
|
74
|
+
"foregroundWindowSeconds": 60,
|
|
74
75
|
"parallelPoolWidth": 4,
|
|
75
76
|
"taskTools": {
|
|
76
77
|
"task": "task",
|
|
@@ -88,6 +89,7 @@ Configuration is loaded in this order:
|
|
|
88
89
|
| --- | --- | --- |
|
|
89
90
|
| `languages` | `py`/`js` enabled; `rb`/`jl` disabled | Selects desired languages before interpreter detection. |
|
|
90
91
|
| `cellTimeoutSeconds` | `30` | Idle timeout for one cell unless the call supplies `timeout`; interactive calls detach by default and print/json calls error. |
|
|
92
|
+
| `foregroundWindowSeconds` | `60` | Longest an interactive (detach-behavior) call blocks the turn before the cell detaches, capping the `timeout` detach budget. A larger `timeout` still raises the hard limit and keeps the cell running, but the turn is freed at this window. `on_timeout: "error"` calls keep the full `timeout` as an uncapped deadline. Env override: `SENPI_CODEMODE_FOREGROUND_SECONDS`. |
|
|
91
93
|
| `parallelPoolWidth` | `4` | Maximum concurrent `parallel()` thunks. |
|
|
92
94
|
| `taskTools.task` | `"task"` | Registered tool name used by `agent()`. |
|
|
93
95
|
| `taskTools.output` | `"task_output"` | Registered tool name used by `output()`. |
|
|
@@ -110,12 +112,12 @@ options object and asynchronous helpers are `await`-able.
|
|
|
110
112
|
|
|
111
113
|
| Helper | Contract |
|
|
112
114
|
| --- | --- |
|
|
113
|
-
| `display(value)` | Emits text, structured JSON, markdown, or
|
|
115
|
+
| `display(value)` | Emits text, structured JSON, markdown, or image display data. Images reach the model only through `display`: pass a figure, raw image bytes (PNG/JPEG/GIF/WebP/BMP sniffed), a `data:` URL, a `Blob`-like or `Bun.Image` value, a marshalled tool result, or one of its `images[i]` frames. |
|
|
114
116
|
| `print(value, ...)` | Emits text output. |
|
|
115
117
|
| `read(path, offset?, limit?)` | Reads text with 1-indexed line slicing. `local://` paths resolve under the session artifact root. |
|
|
116
118
|
| `write(path, content)` | Creates parent directories and writes text. `local://` paths persist in the session artifact root. |
|
|
117
119
|
| `env(key?, value?)` | Reads all kernel environment values, one value, or sets one value. |
|
|
118
|
-
| `tool.<name>(args)` | Invokes an active Senpi tool through the normal `pi.executeTool` pipeline
|
|
120
|
+
| `tool.<name>(args)` | Invokes an active Senpi tool through the normal `pi.executeTool` pipeline and returns `{ text, images?, details?, hasError? }` in every kernel; image blocks arrive as `images[i] = { mimeType, dataBase64 }`. |
|
|
119
121
|
| `tool_schema(name?)` | Returns a tool's parameter schema without calling it; omit `name` to list tool names. |
|
|
120
122
|
| `completion(prompt, model?, system?, schema?)` | Requests a one-shot host completion; `schema` asks the host to parse structured output. |
|
|
121
123
|
| `agent(prompt, ...)` | Delegates to the configured active `taskTools.task` tool. Supports background handles and structured JSON results. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.5-3",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"LICENSE"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"test": "
|
|
19
|
+
"test": "vitest run test/",
|
|
20
20
|
"clean": "node -e \"\"",
|
|
21
21
|
"build": "node -e \"\""
|
|
22
22
|
},
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@babel/parser": "8.0.4",
|
|
33
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.
|
|
33
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.5-3",
|
|
34
34
|
"typebox": "1.3.18"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@code-yeongyu/senpi": "2026.9.
|
|
37
|
+
"@code-yeongyu/senpi": "2026.9.5-3"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@code-yeongyu/senpi": "2026.9.
|
|
40
|
+
"@code-yeongyu/senpi": "2026.9.5-3"
|
|
41
41
|
},
|
|
42
42
|
"keywords": [
|
|
43
43
|
"senpi",
|
package/src/config/settings.ts
CHANGED
|
@@ -19,6 +19,7 @@ export const codemodeSettingsSchema = Type.Object(
|
|
|
19
19
|
),
|
|
20
20
|
),
|
|
21
21
|
cellTimeoutSeconds: Type.Optional(Type.Number({ minimum: 1 })),
|
|
22
|
+
foregroundWindowSeconds: Type.Optional(Type.Number({ minimum: 1 })),
|
|
22
23
|
hardLimitSeconds: Type.Optional(Type.Number({ minimum: 1 })),
|
|
23
24
|
parallelPoolWidth: Type.Optional(Type.Number({ minimum: 1 })),
|
|
24
25
|
taskTools: Type.Optional(
|
|
@@ -64,6 +65,13 @@ export interface CodemodeSettings {
|
|
|
64
65
|
readonly jl: boolean;
|
|
65
66
|
};
|
|
66
67
|
readonly cellTimeoutSeconds: number;
|
|
68
|
+
/**
|
|
69
|
+
* Longest an interactive eval call blocks the agent loop before the cell detaches, independent
|
|
70
|
+
* of `timeout` (which becomes the detach budget only up to this window). A still-running cell
|
|
71
|
+
* keeps living up to the hard limit; this only frees the turn. Ignored for `on_timeout: "error"`
|
|
72
|
+
* (and print/json) calls, where `timeout` stays the unclamped deadline.
|
|
73
|
+
*/
|
|
74
|
+
readonly foregroundWindowSeconds: number;
|
|
67
75
|
/** Wall-clock kill deadline for a single cell; bounds detached cells too. */
|
|
68
76
|
readonly hardLimitSeconds: number;
|
|
69
77
|
readonly parallelPoolWidth: number;
|
|
@@ -98,6 +106,16 @@ export const DEFAULT_HARD_LIMIT_SECONDS = 1800;
|
|
|
98
106
|
|
|
99
107
|
export const HARD_LIMIT_ENVIRONMENT_FLAG = "SENPI_CODEMODE_HARD_LIMIT_SECONDS";
|
|
100
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Bash parity: `terminal/tools/foreground-window.ts` auto-detaches a still-running bash command to a
|
|
111
|
+
* background session at 60s regardless of its `timeout` kill deadline. An eval cell gets the same
|
|
112
|
+
* default foreground window so a large `timeout` extends the cell's lifetime without holding the turn
|
|
113
|
+
* hostage for hours.
|
|
114
|
+
*/
|
|
115
|
+
export const DEFAULT_FOREGROUND_WINDOW_SECONDS = 60;
|
|
116
|
+
|
|
117
|
+
export const FOREGROUND_WINDOW_ENVIRONMENT_FLAG = "SENPI_CODEMODE_FOREGROUND_SECONDS";
|
|
118
|
+
|
|
101
119
|
// OMP settings-schema.ts:3211-3299 has language/path settings only; eval.ts:427
|
|
102
120
|
// defaults timeout to 30s, and codemode pins concurrency-bridge.ts:30 width to 4.
|
|
103
121
|
export const defaultCodemodeSettings: ResolvedCodemodeSettings = {
|
|
@@ -108,6 +126,7 @@ export const defaultCodemodeSettings: ResolvedCodemodeSettings = {
|
|
|
108
126
|
jl: false,
|
|
109
127
|
},
|
|
110
128
|
cellTimeoutSeconds: 30,
|
|
129
|
+
foregroundWindowSeconds: DEFAULT_FOREGROUND_WINDOW_SECONDS,
|
|
111
130
|
hardLimitSeconds: DEFAULT_HARD_LIMIT_SECONDS,
|
|
112
131
|
parallelPoolWidth: 4,
|
|
113
132
|
taskTools: {
|
|
@@ -166,6 +185,15 @@ export function resolveHardLimitSeconds(settings: CodemodeSettings, env: Environ
|
|
|
166
185
|
return parsed;
|
|
167
186
|
}
|
|
168
187
|
|
|
188
|
+
/** Environment override wins over the settings file; a non-positive or malformed value is ignored. */
|
|
189
|
+
export function resolveForegroundWindowSeconds(settings: CodemodeSettings, env: Environment = process.env): number {
|
|
190
|
+
const override = env[FOREGROUND_WINDOW_ENVIRONMENT_FLAG];
|
|
191
|
+
if (override === undefined) return settings.foregroundWindowSeconds;
|
|
192
|
+
const parsed = Number.parseInt(override, 10);
|
|
193
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return settings.foregroundWindowSeconds;
|
|
194
|
+
return parsed;
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
async function loadSettingsFile(path: string): Promise<LoadedCodemodeSettings> {
|
|
170
198
|
const raw = await readFile(path, "utf8");
|
|
171
199
|
let parsed: unknown;
|
|
@@ -200,6 +228,7 @@ function mergeSettings(input: CodemodeSettingsInput): ResolvedCodemodeSettings {
|
|
|
200
228
|
jl: input.languages?.jl ?? defaultCodemodeSettings.languages.jl,
|
|
201
229
|
},
|
|
202
230
|
cellTimeoutSeconds: input.cellTimeoutSeconds ?? defaultCodemodeSettings.cellTimeoutSeconds,
|
|
231
|
+
foregroundWindowSeconds: input.foregroundWindowSeconds ?? defaultCodemodeSettings.foregroundWindowSeconds,
|
|
203
232
|
hardLimitSeconds: input.hardLimitSeconds ?? defaultCodemodeSettings.hardLimitSeconds,
|
|
204
233
|
parallelPoolWidth: input.parallelPoolWidth ?? defaultCodemodeSettings.parallelPoolWidth,
|
|
205
234
|
taskTools: {
|
|
@@ -92,12 +92,16 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
92
92
|
});
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
// Subprocess kernels (py/rb/jl) reach the host only through this route, so
|
|
96
|
-
//
|
|
97
|
-
//
|
|
95
|
+
// Subprocess kernels (py/rb/jl) reach the host only through this route, so every reply
|
|
96
|
+
// must match the in-process JS path in tool/cell-handler.ts: reserved helper names dispatch
|
|
97
|
+
// through runReservedTool (forwarding them made agent() fail with "Unknown tool __agent__"),
|
|
98
|
+
// and ordinary tool results are marshalled to { text, images, details, hasError } — the raw
|
|
99
|
+
// { content } shape left python cells unable to reach tool.read image blocks.
|
|
98
100
|
async #call(request: { toolName: string; args: unknown; callId: string; signal: AbortSignal }): Promise<unknown> {
|
|
99
101
|
if (!isReservedToolName(request.toolName)) {
|
|
100
|
-
return
|
|
102
|
+
return marshalToolResult(
|
|
103
|
+
await this.#options.executeTool(request.toolName, request.args, { signal: request.signal }),
|
|
104
|
+
);
|
|
101
105
|
}
|
|
102
106
|
const taskTools = this.#options.settings.taskTools ?? defaultCodemodeSettings.taskTools;
|
|
103
107
|
return await runReservedTool(request.toolName, {
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
|
3
3
|
import type { AgentExecuteTool } from "./bridges/agent-bridge.ts";
|
|
4
4
|
import type { EvalSchemaToolInfo } from "./bridges/schema-bridge.ts";
|
|
5
5
|
import { type CompletionRequest, type CompletionResult, createCompletionHandler } from "./completion/handler.ts";
|
|
6
|
-
import { defaultCodemodeSettings, resolveHardLimitSeconds } from "./config/settings.ts";
|
|
6
|
+
import { defaultCodemodeSettings, resolveForegroundWindowSeconds, resolveHardLimitSeconds } from "./config/settings.ts";
|
|
7
7
|
import { EvalNotifier } from "./extension/eval-notifier.ts";
|
|
8
8
|
import { EVAL_CELLS_STATUS_KEY } from "./extension/eval-status.ts";
|
|
9
9
|
import { EvalStatusTicker } from "./extension/eval-status-ticker.ts";
|
|
@@ -127,6 +127,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
|
|
|
127
127
|
enabledLanguages: runtime.enabledLanguages,
|
|
128
128
|
kernelManager: manager,
|
|
129
129
|
cellTimeoutSeconds: runtime.settings.cellTimeoutSeconds,
|
|
130
|
+
foregroundWindowSeconds: resolveForegroundWindowSeconds(runtime.settings),
|
|
130
131
|
executeTool: runtime.executeTool,
|
|
131
132
|
listTools: () => pi.getAllTools(),
|
|
132
133
|
complete,
|
|
@@ -161,6 +162,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
|
|
|
161
162
|
enabledLanguages: { py: true, js: true, rb: true, jl: true },
|
|
162
163
|
kernelManager: manager,
|
|
163
164
|
cellTimeoutSeconds: defaultCodemodeSettings.cellTimeoutSeconds,
|
|
165
|
+
foregroundWindowSeconds: resolveForegroundWindowSeconds(defaultCodemodeSettings),
|
|
164
166
|
executeTool: createExecuteTool(pi),
|
|
165
167
|
listTools: () => pi.getAllTools(),
|
|
166
168
|
complete,
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const BASE64_STRICT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
|
|
2
|
+
const DECIMAL_CSV_RE = /^\d{1,3}(?:,\d{1,3})*$/u;
|
|
3
|
+
const DATA_URL_RE = /^data:([^;,]+)(?:;[^,]*)?;base64,([\s\S]*)$/u;
|
|
4
|
+
|
|
5
|
+
const IMAGE_SIGNATURES = [
|
|
6
|
+
{ mimeType: "image/png", offset: 0, bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
|
7
|
+
{ mimeType: "image/jpeg", offset: 0, bytes: [0xff, 0xd8, 0xff] },
|
|
8
|
+
{ mimeType: "image/gif", offset: 0, bytes: [0x47, 0x49, 0x46, 0x38] },
|
|
9
|
+
{ mimeType: "image/webp", offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
|
|
10
|
+
{ mimeType: "image/bmp", offset: 0, bytes: [0x42, 0x4d] },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function sniffImageMimeType(bytes) {
|
|
14
|
+
for (const signature of IMAGE_SIGNATURES) {
|
|
15
|
+
if (bytes.length < signature.offset + signature.bytes.length) continue;
|
|
16
|
+
if (signature.bytes.every((byte, index) => bytes[signature.offset + index] === byte)) return signature.mimeType;
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isBinaryData(value) {
|
|
22
|
+
return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function bytesOf(value) {
|
|
26
|
+
if (value instanceof Uint8Array) return value;
|
|
27
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
28
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeBase64(text) {
|
|
33
|
+
const compact = text.replace(/\s+/gu, "").replace(/-/gu, "+").replace(/_/gu, "/");
|
|
34
|
+
const padded = compact.length % 4 === 0 ? compact : `${compact}${"=".repeat(4 - (compact.length % 4))}`;
|
|
35
|
+
return padded.length > 0 && BASE64_STRICT_RE.test(padded) ? padded : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function decimalCsvBase64(text) {
|
|
39
|
+
const parts = text.split(",");
|
|
40
|
+
const bytes = new Uint8Array(parts.length);
|
|
41
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
42
|
+
const byte = Number(parts[index]);
|
|
43
|
+
if (!Number.isInteger(byte) || byte < 0 || byte > 255) return undefined;
|
|
44
|
+
bytes[index] = byte;
|
|
45
|
+
}
|
|
46
|
+
return Buffer.from(bytes).toString("base64");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function serializedBufferBase64(data) {
|
|
50
|
+
const bytes = new Uint8Array(data.length);
|
|
51
|
+
for (let index = 0; index < data.length; index += 1) {
|
|
52
|
+
const byte = data[index];
|
|
53
|
+
if (typeof byte !== "number" || !Number.isInteger(byte) || byte < 0 || byte > 255) return undefined;
|
|
54
|
+
bytes[index] = byte;
|
|
55
|
+
}
|
|
56
|
+
return Buffer.from(bytes).toString("base64");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function imagePayload(data) {
|
|
60
|
+
if (typeof data === "string") {
|
|
61
|
+
const dataUrl = DATA_URL_RE.exec(data);
|
|
62
|
+
if (dataUrl) {
|
|
63
|
+
const dataBase64 = normalizeBase64(dataUrl[2]);
|
|
64
|
+
return dataBase64 === undefined ? undefined : { dataBase64, mimeType: dataUrl[1] };
|
|
65
|
+
}
|
|
66
|
+
const dataBase64 = normalizeBase64(data);
|
|
67
|
+
if (dataBase64 !== undefined) return { dataBase64 };
|
|
68
|
+
return DECIMAL_CSV_RE.test(data) ? wrap(decimalCsvBase64(data)) : undefined;
|
|
69
|
+
}
|
|
70
|
+
const bytes = bytesOf(data);
|
|
71
|
+
if (bytes !== undefined) return { dataBase64: Buffer.from(bytes).toString("base64"), mimeType: sniffImageMimeType(bytes) };
|
|
72
|
+
if (isSerializedBuffer(data)) return wrap(serializedBufferBase64(data.data));
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function wrap(dataBase64) {
|
|
77
|
+
return dataBase64 === undefined ? undefined : { dataBase64 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isSerializedBuffer(data) {
|
|
81
|
+
return (
|
|
82
|
+
typeof data === "object" &&
|
|
83
|
+
data !== null &&
|
|
84
|
+
Object.getPrototypeOf(data) === Object.prototype &&
|
|
85
|
+
data.type === "Buffer" &&
|
|
86
|
+
Array.isArray(data.data)
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function describeImageData(data) {
|
|
91
|
+
if (data === null) return "null";
|
|
92
|
+
if (data instanceof Uint8Array) return "Uint8Array";
|
|
93
|
+
if (data instanceof ArrayBuffer) return "ArrayBuffer";
|
|
94
|
+
if (ArrayBuffer.isView(data)) return data.constructor.name;
|
|
95
|
+
if (typeof data === "string") return `string(${data.length})`;
|
|
96
|
+
return typeof data;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isImageFrame(value) {
|
|
100
|
+
return (
|
|
101
|
+
typeof value === "object" &&
|
|
102
|
+
value !== null &&
|
|
103
|
+
typeof value.mimeType === "string" &&
|
|
104
|
+
typeof value.dataBase64 === "string"
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isMarshalledToolResult(value) {
|
|
109
|
+
return typeof value.text === "string" && Array.isArray(value.images) && value.images.every(isImageFrame);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isEncodableImage(value) {
|
|
113
|
+
return !isBinaryData(value) && (typeof value.arrayBuffer === "function" || typeof value.bytes === "function");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function frame(mimeType, dataBase64) {
|
|
117
|
+
return { kind: "frame", mimeType, dataBase64 };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function dropped(reason) {
|
|
121
|
+
return [{ kind: "text", text: `[display: image dropped — ${reason}]` }];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function resolveDisplayOps(value) {
|
|
125
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
126
|
+
if (value.type === "image" && typeof value.mimeType === "string") {
|
|
127
|
+
const payload = imagePayload(value.data);
|
|
128
|
+
return payload === undefined
|
|
129
|
+
? dropped(
|
|
130
|
+
`\`data\` must be a base64 string, data: URL, Uint8Array/Buffer, or ArrayBuffer; got ${describeImageData(value.data)}`,
|
|
131
|
+
)
|
|
132
|
+
: [frame(value.mimeType, payload.dataBase64)];
|
|
133
|
+
}
|
|
134
|
+
if (isImageFrame(value)) return [frame(value.mimeType, value.dataBase64)];
|
|
135
|
+
if (isMarshalledToolResult(value)) {
|
|
136
|
+
const frames = value.images.map((image) => frame(image.mimeType, image.dataBase64));
|
|
137
|
+
return value.text === "" ? frames : [{ kind: "text", text: value.text }, ...frames];
|
|
138
|
+
}
|
|
139
|
+
if (isBinaryData(value)) {
|
|
140
|
+
const payload = imagePayload(value);
|
|
141
|
+
return [frame(payload.mimeType ?? "application/octet-stream", payload.dataBase64)];
|
|
142
|
+
}
|
|
143
|
+
if (isEncodableImage(value)) return [{ kind: "encode", value }];
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function encodeDisplayImage(value) {
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = typeof value.bytes === "function" ? await value.bytes() : await value.arrayBuffer();
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (!(error instanceof Error)) throw error;
|
|
153
|
+
return dropped(`encoding failed: ${error.message}`);
|
|
154
|
+
}
|
|
155
|
+
const bytes = bytesOf(raw);
|
|
156
|
+
if (bytes === undefined) return dropped(`encoder returned ${describeImageData(raw)}, not bytes`);
|
|
157
|
+
const declared = typeof value.type === "string" && value.type.startsWith("image/") ? value.type : undefined;
|
|
158
|
+
const mimeType = declared ?? sniffImageMimeType(bytes);
|
|
159
|
+
return mimeType === undefined
|
|
160
|
+
? dropped("bytes carry no recognizable image signature")
|
|
161
|
+
: [frame(mimeType, Buffer.from(bytes).toString("base64"))];
|
|
162
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
export const JAVASCRIPT_KERNEL_PRELUDE = [
|
|
2
2
|
"print(...values): write stdout text.",
|
|
3
|
-
"display(value): emit JSON,
|
|
3
|
+
"display(value): emit JSON, markdown, or image display output; image bytes, data: URLs, Blob-like values, tool results, and their images[i] frames all render as images.",
|
|
4
4
|
"log(message): emit a progress log line.",
|
|
5
5
|
"phase(title): emit a progress phase.",
|
|
6
6
|
"env(key?, value?): read, set, or list environment values.",
|
|
7
7
|
"read(path, options?): read UTF-8 text; plain paths use cwd and local:// uses the session local root.",
|
|
8
8
|
"write(path, content): write UTF-8 or binary data and return the resolved path.",
|
|
9
|
-
"tool.<name>(args): request a host tool call through the bridge.",
|
|
9
|
+
"tool.<name>(args): request a host tool call through the bridge; resolves to { text, images?, details?, hasError? }.",
|
|
10
10
|
"completion(prompt, options?): request a host completion bridge call.",
|
|
11
11
|
"output(...ids, options?): retrieve task output through the reserved output bridge.",
|
|
12
12
|
"agent(prompt, options?): delegate work through the reserved agent bridge.",
|
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
|
|
4
4
|
import { inspect } from "node:util";
|
|
5
|
+
import { encodeDisplayImage, resolveDisplayOps } from "./display-image.js";
|
|
5
6
|
import { awaitMaybePromise, indirectEval, wrapUserCode } from "./worker-indirect-eval.js";
|
|
6
7
|
import { installShellCapture } from "./worker-shell-capture.js";
|
|
7
8
|
|
|
8
9
|
const PREPARED_CELL_PREFIX = "/*senpi:prepared-cell*/";
|
|
9
10
|
const INTERNAL_URL = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/iu;
|
|
10
|
-
const BASE64_STRICT_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
|
|
11
|
-
const DECIMAL_CSV_RE = /^\d{1,3}(?:,\d{1,3})*$/u;
|
|
12
11
|
|
|
13
12
|
export class JsWorkerRuntime {
|
|
14
13
|
#cwd;
|
|
@@ -16,6 +15,7 @@ export class JsWorkerRuntime {
|
|
|
16
15
|
#localRoots;
|
|
17
16
|
#env = new Map();
|
|
18
17
|
#hooks = null;
|
|
18
|
+
#pendingDisplays = [];
|
|
19
19
|
|
|
20
20
|
constructor(options) {
|
|
21
21
|
this.#cwd = options.cwd;
|
|
@@ -36,12 +36,23 @@ export class JsWorkerRuntime {
|
|
|
36
36
|
({ prelude, code: cellCode } = prepared);
|
|
37
37
|
}
|
|
38
38
|
if (prelude) indirectEval(prelude, `${cellId}:prelude`);
|
|
39
|
-
|
|
39
|
+
const value = await awaitMaybePromise(indirectEval(wrapUserCode(cellCode), cellId));
|
|
40
|
+
await this.#drainPendingDisplays();
|
|
41
|
+
return value;
|
|
40
42
|
} finally {
|
|
43
|
+
this.#pendingDisplays = [];
|
|
41
44
|
this.#hooks = null;
|
|
42
45
|
}
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
async #drainPendingDisplays() {
|
|
49
|
+
while (this.#pendingDisplays.length > 0) {
|
|
50
|
+
const pending = this.#pendingDisplays;
|
|
51
|
+
this.#pendingDisplays = [];
|
|
52
|
+
await Promise.all(pending);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
#installGlobals() {
|
|
46
57
|
globalThis.print = (...values) => this.#emitText("stdout", `${values.map(formatValue).join(" ")}\n`);
|
|
47
58
|
globalThis.display = value => this.#display(value);
|
|
@@ -112,22 +123,8 @@ export class JsWorkerRuntime {
|
|
|
112
123
|
this.#hooks?.emit({ type: "display", mimeType: "text/markdown", dataBase64: encodeBase64(value.text) });
|
|
113
124
|
return;
|
|
114
125
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (dataBase64 !== undefined) {
|
|
118
|
-
this.#hooks?.emit({ type: "display", mimeType: value.mimeType, dataBase64 });
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
this.#emitText(
|
|
122
|
-
"stdout",
|
|
123
|
-
`[display: image dropped — \`data\` must be a base64 string, Uint8Array/Buffer, or ArrayBuffer; got ${describeImageData(value.data)}]\n`,
|
|
124
|
-
);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
if (typeof value.mimeType === "string" && typeof value.dataBase64 === "string") {
|
|
128
|
-
this.#hooks?.emit({ type: "display", mimeType: value.mimeType, dataBase64: value.dataBase64 });
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
126
|
+
const ops = resolveDisplayOps(value);
|
|
127
|
+
if (ops !== undefined) return this.#applyDisplayOps(ops);
|
|
131
128
|
try {
|
|
132
129
|
this.#hooks?.emit({ type: "display", mimeType: "application/json", dataBase64: encodeBase64(JSON.stringify(value)) });
|
|
133
130
|
} catch (error) {
|
|
@@ -139,6 +136,19 @@ export class JsWorkerRuntime {
|
|
|
139
136
|
this.#emitText("stdout", `${String(value)}\n`);
|
|
140
137
|
}
|
|
141
138
|
|
|
139
|
+
#applyDisplayOps(ops) {
|
|
140
|
+
let pending;
|
|
141
|
+
for (const op of ops) {
|
|
142
|
+
if (op.kind === "frame") this.#hooks?.emit({ type: "display", mimeType: op.mimeType, dataBase64: op.dataBase64 });
|
|
143
|
+
else if (op.kind === "text") this.#emitText("stdout", `${op.text}\n`);
|
|
144
|
+
else {
|
|
145
|
+
pending = encodeDisplayImage(op.value).then((encoded) => this.#applyDisplayOps(encoded));
|
|
146
|
+
this.#pendingDisplays.push(pending);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return pending;
|
|
150
|
+
}
|
|
151
|
+
|
|
142
152
|
#envHelper(key, value) {
|
|
143
153
|
if (key === undefined || key === null || key === "") {
|
|
144
154
|
const merged = Object.fromEntries(Object.entries({ ...process.env, ...Object.fromEntries(this.#env) }).sort());
|
|
@@ -357,47 +367,6 @@ async function writeData(value) {
|
|
|
357
367
|
throw new TypeError("write() expects string, Blob, ArrayBuffer, or TypedArray data");
|
|
358
368
|
}
|
|
359
369
|
|
|
360
|
-
function imageBase64(data) {
|
|
361
|
-
if (typeof data === "string") {
|
|
362
|
-
if (isStrictBase64(data)) return data;
|
|
363
|
-
if (!DECIMAL_CSV_RE.test(data)) return undefined;
|
|
364
|
-
const parts = data.split(",");
|
|
365
|
-
const bytes = new Uint8Array(parts.length);
|
|
366
|
-
for (let index = 0; index < parts.length; index += 1) {
|
|
367
|
-
const byte = Number(parts[index]);
|
|
368
|
-
if (!Number.isInteger(byte) || byte < 0 || byte > 255) return undefined;
|
|
369
|
-
bytes[index] = byte;
|
|
370
|
-
}
|
|
371
|
-
return Buffer.from(bytes).toString("base64");
|
|
372
|
-
}
|
|
373
|
-
if (data instanceof Uint8Array) return Buffer.from(data).toString("base64");
|
|
374
|
-
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("base64");
|
|
375
|
-
if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("base64");
|
|
376
|
-
if (isPlainObject(data) && data.type === "Buffer" && Array.isArray(data.data)) {
|
|
377
|
-
const bytes = new Uint8Array(data.data.length);
|
|
378
|
-
for (let index = 0; index < data.data.length; index += 1) {
|
|
379
|
-
const byte = data.data[index];
|
|
380
|
-
if (typeof byte !== "number" || !Number.isInteger(byte) || byte < 0 || byte > 255) return undefined;
|
|
381
|
-
bytes[index] = byte;
|
|
382
|
-
}
|
|
383
|
-
return Buffer.from(bytes).toString("base64");
|
|
384
|
-
}
|
|
385
|
-
return undefined;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function isStrictBase64(value) {
|
|
389
|
-
return value.length > 0 && value.length % 4 === 0 && BASE64_STRICT_RE.test(value);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function describeImageData(data) {
|
|
393
|
-
if (data === null) return "null";
|
|
394
|
-
if (data instanceof Uint8Array) return "Uint8Array";
|
|
395
|
-
if (data instanceof ArrayBuffer) return "ArrayBuffer";
|
|
396
|
-
if (ArrayBuffer.isView(data)) return data.constructor.name;
|
|
397
|
-
if (typeof data === "string") return `string(${data.length})`;
|
|
398
|
-
return typeof data;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
370
|
function chunkToString(chunk, encoding) {
|
|
402
371
|
if (typeof chunk === "string") return chunk;
|
|
403
372
|
if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString(encoding ?? "utf8");
|
|
@@ -91,6 +91,68 @@ def b64_text(value: str) -> str:
|
|
|
91
91
|
return base64.b64encode(value.encode("utf-8")).decode("ascii")
|
|
92
92
|
|
|
93
93
|
|
|
94
|
+
_IMAGE_SIGNATURES: tuple[tuple[str, int, bytes], ...] = (
|
|
95
|
+
("image/png", 0, b"\x89PNG\r\n\x1a\n"),
|
|
96
|
+
("image/jpeg", 0, b"\xff\xd8\xff"),
|
|
97
|
+
("image/gif", 0, b"GIF8"),
|
|
98
|
+
("image/webp", 8, b"WEBP"),
|
|
99
|
+
("image/bmp", 0, b"BM"),
|
|
100
|
+
)
|
|
101
|
+
_DATA_URL_RE = re.compile(r"^data:([^;,]+)(?:;[^,]*)?;base64,(.*)$", re.DOTALL)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _sniff_image_mime_type(data: bytes) -> str | None:
|
|
105
|
+
for mime_type, offset, magic in _IMAGE_SIGNATURES:
|
|
106
|
+
if data[offset : offset + len(magic)] == magic:
|
|
107
|
+
return mime_type
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _image_base64(data: Any) -> tuple[str, str | None] | None:
|
|
112
|
+
if isinstance(data, (bytes, bytearray)):
|
|
113
|
+
raw = bytes(data)
|
|
114
|
+
return base64.b64encode(raw).decode("ascii"), _sniff_image_mime_type(raw)
|
|
115
|
+
if not isinstance(data, str):
|
|
116
|
+
return None
|
|
117
|
+
declared: str | None = None
|
|
118
|
+
match = _DATA_URL_RE.match(data)
|
|
119
|
+
if match:
|
|
120
|
+
declared, data = match.group(1), match.group(2)
|
|
121
|
+
compact = re.sub(r"\s+", "", data).replace("-", "+").replace("_", "/")
|
|
122
|
+
compact += "=" * (-len(compact) % 4)
|
|
123
|
+
try:
|
|
124
|
+
base64.b64decode(compact, validate=True)
|
|
125
|
+
except (ValueError, TypeError):
|
|
126
|
+
return None
|
|
127
|
+
return (compact, declared) if compact else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _display_image_dict(value: dict[str, Any]) -> bool:
|
|
131
|
+
mime_type = value.get("mimeType")
|
|
132
|
+
payload = value.get("dataBase64") if "dataBase64" in value else value.get("data")
|
|
133
|
+
if not isinstance(mime_type, str) or not mime_type.startswith("image/") or payload is None:
|
|
134
|
+
return False
|
|
135
|
+
encoded = _image_base64(payload)
|
|
136
|
+
if encoded is None:
|
|
137
|
+
print(f"[display: image dropped \u2014 `data` must be base64, a data: URL, or bytes; got {type(payload).__name__}]")
|
|
138
|
+
return True
|
|
139
|
+
emit({"type": "display", "mimeType": mime_type, "dataBase64": encoded[0]})
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _display_tool_result(value: dict[str, Any]) -> bool:
|
|
144
|
+
text, images = value.get("text"), value.get("images")
|
|
145
|
+
if not isinstance(text, str) or not isinstance(images, list):
|
|
146
|
+
return False
|
|
147
|
+
if not all(isinstance(image, dict) and isinstance(image.get("mimeType"), str) and isinstance(image.get("dataBase64"), str) for image in images):
|
|
148
|
+
return False
|
|
149
|
+
if text:
|
|
150
|
+
print(text)
|
|
151
|
+
for image in images:
|
|
152
|
+
emit({"type": "display", "mimeType": image["mimeType"], "dataBase64": image["dataBase64"]})
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
|
|
94
156
|
def _emit_display(mime_type: str, data: Any) -> None:
|
|
95
157
|
if isinstance(data, (bytes, bytearray)):
|
|
96
158
|
encoded = base64.b64encode(bytes(data)).decode("ascii")
|
|
@@ -188,11 +250,14 @@ def _rich_bundle(value: Any) -> dict[str, Any]:
|
|
|
188
250
|
|
|
189
251
|
|
|
190
252
|
def display(value: Any) -> None:
|
|
253
|
+
if isinstance(value, dict) and (_display_tool_result(value) or _display_image_dict(value)):
|
|
254
|
+
return
|
|
191
255
|
if isinstance(value, (dict, list, tuple)):
|
|
192
256
|
_emit_display("application/json", value)
|
|
193
257
|
return
|
|
194
258
|
if isinstance(value, (bytes, bytearray)):
|
|
195
|
-
|
|
259
|
+
raw = bytes(value)
|
|
260
|
+
_emit_display(_sniff_image_mime_type(raw) or "application/octet-stream", raw)
|
|
196
261
|
return
|
|
197
262
|
bundle = _rich_bundle(value)
|
|
198
263
|
if bundle and _display_bundle(bundle):
|
|
@@ -64,98 +64,55 @@ export function evalEmphasisStyle(modelId: string | undefined): EvalEmphasisStyl
|
|
|
64
64
|
|
|
65
65
|
type ContextValue = string | boolean;
|
|
66
66
|
type Context = Readonly<Record<string, ContextValue>>;
|
|
67
|
-
type EvalPromptExample = {
|
|
68
|
-
readonly caption: string;
|
|
69
|
-
readonly language: keyof EnabledLanguages;
|
|
70
|
-
readonly summary: string;
|
|
71
|
-
readonly code: string;
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
// senpi ToolDefinition has no examples field, so description embeds the examples.
|
|
75
|
-
// ADAPTATION: payloads diverge from omp's json-config chain to teach batch read,
|
|
76
|
-
// comprehension filtering, and parallel tool.<name> fan-out while keeping the
|
|
77
|
-
// three-cell reuse narrative.
|
|
78
|
-
const REUSE_CHAIN_EXAMPLES = [
|
|
79
|
-
{
|
|
80
|
-
caption: "First call — set up once",
|
|
81
|
-
language: "js",
|
|
82
|
-
summary: "Count all TypeScript source files under src/ excluding tests",
|
|
83
|
-
code: "import { readdir } from 'node:fs/promises'\nimport { extname } from 'node:path'\nconst files = (await readdir('src', { recursive: true })).filter(f => extname(f) === '.ts' && !f.includes('test'))\nprint(files.length)",
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
caption: "Second call — reuse `files`, fan out session tools in parallel",
|
|
87
|
-
language: "js",
|
|
88
|
-
summary: "Grep legacyClient per directory in one cell",
|
|
89
|
-
code: "const dirs = [...new Set(files.map(f => f.split('/')[0]))]\nconst hits = await Promise.all(dirs.map(d => tool.grep({ pattern: 'legacyClient', path: d })))\ndisplay(hits.map(h => h.matches?.length ?? 0))",
|
|
90
|
-
},
|
|
91
|
-
{
|
|
92
|
-
caption: "JS kernel is busy with a detached cell — continue in py",
|
|
93
|
-
language: "py",
|
|
94
|
-
summary: "Aggregate legacyClient hits while JS is busy",
|
|
95
|
-
code: "from pathlib import Path\nprint(sum('legacyClient' in read(p) for p in Path('src').rglob('*.ts')))",
|
|
96
|
-
},
|
|
97
|
-
] as const satisfies readonly EvalPromptExample[];
|
|
98
|
-
|
|
99
67
|
const EVAL_PROMPT_TEMPLATE = `Run one step of code in a persistent kernel.
|
|
100
68
|
|
|
101
69
|
<instruction>
|
|
102
|
-
**One eval call = one cell = one logical step.**
|
|
103
|
-
|
|
104
|
-
Work incrementally: imports in one call, define in the next, test, then use — each its own eval call. Re-run setup ONLY after \`reset\`, a kernel crash, or a \`NameError\`/\`ReferenceError\` proving the state is gone.
|
|
70
|
+
**One eval call = one cell = one logical step.** Top-level names persist per language across eval calls{{#if spawns}}, tool calls and \`task\` subagents{{else}} and tool calls{{/if}}: define helpers and clients once and reuse them instead of re-importing or re-reading. Rebuild state only after \`reset\`, a kernel restart, or a \`NameError\`/\`ReferenceError\`, and check a sentinel variable first so a re-run cannot duplicate side effects.
|
|
105
71
|
|
|
106
72
|
{{#if styleClaude}}<eval_first_batching>
|
|
107
73
|
\`eval\` is your default execution surface: if a step needs more than one tool call, write ONE cell that performs the whole step — never issue the calls one at a time.
|
|
108
74
|
- Enumerate every lookup the step needs, then run all independent ones simultaneously with \`parallel(thunks)\` inside the cell; keep calls sequential only when one result feeds the next.
|
|
109
75
|
- Write real code around the calls: loop or comprehend over file sets with \`read()\`/stdlib, branch per case, and wrap risky calls in try/except so one failure degrades only its item — recover or retry inside the cell, keep the batch alive.
|
|
110
76
|
- Post-process \`tool.<name>()\` results programmatically — filter, join, aggregate — and return distilled facts, not raw dumps.
|
|
111
|
-
{{#if monitor}}- Start long-running work (build, test run, deploy, or watch) through \`tool.monitor({ command, filter })\`, putting the decisive-line filter inside the same cell, then keep working until its event wakes the turn
|
|
77
|
+
{{#if monitor}}- Start long-running work (build, test run, deploy, or watch) through \`tool.monitor({ command, filter })\`, putting the decisive-line filter inside the same cell, then keep working until its event wakes the turn.{{/if}}
|
|
112
78
|
</eval_first_batching>{{/if}}{{#if styleGpt}}<gpt_eval_dialect>
|
|
113
79
|
GPT eval: compose multi-tool work inside one cell with \`tool.<name>(args)\` and \`parallel(thunks)\`; do not split a planned step into serial tool calls.
|
|
114
|
-
-
|
|
80
|
+
{{#if monitor}}- A wait or a long run (build, test run, deploy, watch) starts through \`tool.monitor({ command, filter })\` in that same cell with the decisive-line filter; its event wakes the turn, so no cell sits on the wait and no child is spawned for it.
|
|
81
|
+
{{/if}}- Long cells detach on timeout and notify on completion; do not poll or re-run them.
|
|
115
82
|
- Filter, join, and aggregate tool results in the cell; return only decision-relevant facts.
|
|
116
|
-
{{#if monitor}}- For long-running build, test run, deploy, or watch work, start \`tool.monitor({ command, filter })\` with the decisive-line filter in the same cell; keep working while its event wakes the turn; sleeping, timed retries, and repeated status reads are not waiting.{{/if}}
|
|
117
83
|
</gpt_eval_dialect>{{/if}}{{#if styleCodex}}Route multi-call steps through eval: one cell per step, independent lookups dispatched together via \`parallel(thunks)\`; keep work sequential only when one result determines the next action.
|
|
118
84
|
- Loop or comprehend over file sets with \`read()\`/stdlib instead of reading files one call at a time; post-process \`tool.<name>()\` results programmatically — filter, join, aggregate.
|
|
119
85
|
- Wrap failable calls in try/except inside the cell; a failed item degrades only itself. After two distinct failed strategies for the same fact, fall back to direct tool calls.
|
|
120
86
|
- Reduce large results in-kernel to the facts the task needs before returning.
|
|
121
|
-
{{#if monitor}}- Long-running build/test/deploy/watch work: start \`tool.monitor({ command, filter })\` with the decisive-line filter inside the same cell, then continue working
|
|
87
|
+
{{#if monitor}}- Long-running build/test/deploy/watch work: start \`tool.monitor({ command, filter })\` with the decisive-line filter inside the same cell, then continue working until its event wakes the turn.{{/if}}{{/if}}{{#if styleKimi}}**EVAL IS YOUR SUPERPOWER — MAKE IT YOUR DEFAULT WAY TO ACT.** Before any step, think: "how do I execute this WHOLE step in ONE parallelized cell?" — then write that ONE cell.
|
|
122
88
|
- **BATCH EVERYTHING AT ONCE:** enumerate EVERY independent lookup the step needs and dispatch them ALL simultaneously with \`parallel(thunks)\` in that cell; keep calls sequential only when one result feeds the next.
|
|
123
89
|
- **WRITE REAL CODE, NOT CALL CHAINS:** loop or comprehend over file sets with \`read()\`/stdlib, post-process \`tool.<name>()\` results programmatically, and put try/except around each risky call so the rest of the batch completes.
|
|
124
90
|
- **DISTILL IN-KERNEL:** filter, join, and aggregate \`tool.<name>()\` results in code, then return ONLY the distilled facts.
|
|
125
|
-
{{#if monitor}}- **DO start long-running build, test run, deploy, or watch work with \`tool.monitor({ command, filter })\`, put the decisive-line filter INSIDE THE SAME CELL, and KEEP WORKING until its event wakes the turn
|
|
91
|
+
{{#if monitor}}- **DO start long-running build, test run, deploy, or watch work with \`tool.monitor({ command, filter })\`, put the decisive-line filter INSIDE THE SAME CELL, and KEEP WORKING until its event wakes the turn.**{{/if}}{{/if}}{{#if styleDefault}}**EVAL IS YOUR PRIMARY EXECUTION SURFACE.** Any step that needs MORE THAN ONE tool call MUST be written as ONE cell — NEVER as a chain of single tool calls.
|
|
126
92
|
- **PLAN THE WHOLE STEP, THEN BATCH IT.** Enumerate every read/search/lookup the step needs and dispatch ALL independent ones through \`parallel(thunks)\` in one cell.
|
|
127
93
|
- **WRITE REAL CODE, NOT CALL LISTS.** Loop or comprehend over file sets with \`read()\`/stdlib, branch \`if\`/\`else\` per case, post-process \`tool.<name>()\` results programmatically, and wrap EVERY risky call in try/except so ONE failure NEVER kills the batch.
|
|
128
94
|
- **DISTILL IN-KERNEL.** Filter, join, diff, and aggregate in code before returning; return facts, NOT dumps.
|
|
129
|
-
{{#if monitor}}- **LONG-RUNNING build, test run, deploy, or watch work MUST start with \`tool.monitor({ command, filter })\`, with the decisive-line filter INSIDE THE SAME CELL; KEEP WORKING until its event wakes the turn
|
|
95
|
+
{{#if monitor}}- **LONG-RUNNING build, test run, deploy, or watch work MUST start with \`tool.monitor({ command, filter })\`, with the decisive-line filter INSIDE THE SAME CELL; KEEP WORKING until its event wakes the turn.**{{/if}}{{/if}}
|
|
130
96
|
{{#if hostLine}}
|
|
131
97
|
Host: {{hostLine}} — cells execute here. Size \`parallel(thunks)\` pools to its cores; \`tool.<name>()\` shell commands must fit this platform, even when the code you are writing targets another machine.
|
|
132
98
|
{{/if}}
|
|
133
99
|
|
|
134
|
-
|
|
100
|
+
\`language\`: {{#if py}}\`"py"\` IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}\`"js"\` persistent JavaScript VM{{/if}}{{#if rb}}{{#ifAny py js}}, {{/ifAny}}\`"rb"\` persistent Ruby kernel{{/if}}{{#if jl}}{{#ifAny py js rb}}, {{/ifAny}}\`"jl"\` persistent Julia kernel{{/if}}.
|
|
135
101
|
|
|
136
|
-
|
|
137
|
-
- \`code\` — cell body, verbatim. Newlines/quotes JSON-encoded; no fences, no headers.
|
|
138
|
-
- \`summary\` (REQUIRED for run) — ONE line in the USER'S conversational language stating WHAT this cell does and FOR WHAT PURPOSE (e.g. Korean conversation -> "src 전체에서 legacyClient 사용처 집계"); shown in the TUI while the cell runs; >80 chars is force-truncated.
|
|
139
|
-
- \`timeout\` (optional) — seconds. Raise only for heavy compute or long{{#if spawns}} non-agent{{/if}} tool calls.
|
|
140
|
-
- \`on_timeout\` (optional) — \`"detach"\` keeps pure computation running in interactive sessions (the default); \`"error"\` interrupts for deadline-sensitive work and is the print/json default.
|
|
141
|
-
- Every cell is killed at a wall-clock hard limit (default 1800s) that survives detach and is never paused by tool calls; a larger explicit \`timeout\` raises it, and a killed cell notifies you that it hit the limit.
|
|
142
|
-
- \`reset\` (optional) — wipe this language's kernel first.{{#ifAll py js}} Per-language: a \`py\` reset never touches the JS VM.{{/ifAll}}
|
|
143
|
-
- \`action\` (optional) — defaults to \`"run"\`. A detached cell returns its id: use \`eval({ action: "peek", cell_id })\` for buffered output/state or \`eval({ action: "stop", cell_id })\` to cancel it.
|
|
102
|
+
A cell that outlives the foreground window detaches: it keeps its language kernel busy (another language can continue) and completes as one notification with its value or error and buffered output. Do not re-run a detached cell; read or cancel it with \`eval({ action: "peek", cell_id })\` / \`eval({ action: "stop", cell_id })\`.
|
|
144
103
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
{{#if py}}Live event loop: use top-level \`await\` directly; \`asyncio.run(…)\` raises "cannot be called from a running event loop".{{/if}}
|
|
148
|
-
{{#if js}}{{#if jsBun}}JS runs in-process on Bun {{jsVersion}}: top-level \`await\`/\`return\` work; \`Bun.*\` builtins available.{{#if bunSkillPath}} MUST READ the bun-1-4 skill at {{bunSkillPath}} before your first js cell — its builtins replace the npm packages you would otherwise install.{{/if}}{{else}}JS runs under Node.js worker: top-level \`await\`/\`return\` work; \`fetch\`/\`Buffer\` available.{{/if}}{{/if}}
|
|
104
|
+
{{#if py}}Python runs on a live event loop: use top-level \`await\`; \`asyncio.run(…)\` raises.{{/if}}
|
|
105
|
+
{{#if js}}{{#if jsBun}}JS runs in-process on Bun {{jsVersion}}: top-level \`await\`/\`return\` work; \`Bun.*\` builtins available, including \`new Bun.WebView()\` — a headless browser (navigate/click/evaluate/screenshot) to reach for before \`curl\` or a browser CLI when a page needs JS, a login, or a screenshot.{{#if bunSkillPath}} MUST READ the bun-1-4 skill at {{bunSkillPath}} before your first js cell — its builtins replace the npm packages you would otherwise install.{{/if}}{{else}}JS runs under Node.js worker: top-level \`await\`/\`return\` work; \`fetch\`/\`Buffer\` available.{{/if}}{{/if}}
|
|
149
106
|
{{#if rb}}Ruby: synchronous; helper options are keyword args{{#if spawns}} (e.g. \`output("id", limit: 2)\`){{/if}}; the last expression auto-displays unless it is \`nil\`, an assignment, or a definition (like IRB).{{/if}}
|
|
150
107
|
{{#if jl}}Julia: synchronous; helper options are standard keyword args{{#if spawns}} (e.g. \`output("id", limit=2)\`){{/if}}; the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
|
|
151
|
-
On error, fix and re-run only the failing step
|
|
108
|
+
On error, fix and re-run only the failing step; a normal error keeps state, while a timeout or stop message says whether the kernel restarted.
|
|
152
109
|
</instruction>
|
|
153
110
|
|
|
154
111
|
<prelude>
|
|
155
112
|
{{#ifAll py js}}Same helpers + arg order, both runtimes. Python: sync, options = trailing kwargs. JS: async/\`await\`able, options = ONE trailing object literal, never positional (extras throw).{{else}}{{#if py}}Sync; options = trailing kwargs.{{/if}}{{#if js}}Async/\`await\`able; options = ONE trailing object literal, never positional (extras throw).{{/if}}{{/ifAll}}{{#if rb}} Ruby: sync, options = trailing keyword args.{{/if}}{{#if jl}} Julia: sync, options = trailing keyword args.{{/if}}
|
|
156
113
|
\`\`\`
|
|
157
114
|
display(value) → None
|
|
158
|
-
Cell output
|
|
115
|
+
Cell output. Images reach you only through display: pass a figure, image bytes, a tool result, or its \`images[i]\`.
|
|
159
116
|
print(value, ...) → None
|
|
160
117
|
Text output.
|
|
161
118
|
read(path, offset?=1, limit?=None) → str
|
|
@@ -163,25 +120,21 @@ read(path, offset?=1, limit?=None) → str
|
|
|
163
120
|
write(path, content) → str
|
|
164
121
|
Write file (creates parents) → resolved path. \`local://…\` persists across turns/subagents.
|
|
165
122
|
env(key?=None, value?=None) → str | None | dict
|
|
166
|
-
No args → full env dict; one → value
|
|
123
|
+
No args → full env dict; one → value; two → set \`key=value\`.
|
|
167
124
|
{{#if spawns}}output(*ids, format?="raw", offset?=None, limit?=None) → str | dict | list[dict]
|
|
168
|
-
Task/agent output by id. Reads immediately: running tasks return their status; \`format\`
|
|
169
|
-
{{/if}}tool.<name>(args) →
|
|
170
|
-
Invoke any session tool; \`
|
|
125
|
+
Task/agent output by id. Reads immediately: running tasks return their status; \`format\` \`"raw"\` = full, \`"tail"\` = trailing.
|
|
126
|
+
{{/if}}tool.<name>(args) → { text, images?, details?, hasError? }
|
|
127
|
+
Invoke any session tool; image results (e.g. \`tool.read\` on a png) arrive in \`images[i]\` as { mimeType, dataBase64 }.
|
|
171
128
|
tool_schema(name?) → dict
|
|
172
|
-
Parameter schema of a tool
|
|
173
|
-
Use it before calling a tool you have not called before — a failed call also
|
|
174
|
-
returns the expected parameters, so fix the args and retry in the next cell
|
|
175
|
-
instead of abandoning eval.
|
|
129
|
+
Parameter schema of a tool (omit \`name\` to list tool names); a failed \`tool.<name>()\` call also returns the expected parameters.
|
|
176
130
|
completion(prompt, model?="default", system?=None, schema?=None) → str | dict
|
|
177
|
-
Oneshot, stateless
|
|
131
|
+
Oneshot, stateless. \`model\`: \`"smol"\` fast | \`"default"\` session | \`"slow"\` most capable. \`schema\` (JSON-Schema) → parsed structured output.
|
|
178
132
|
{{#if spawns}}agent(prompt, agent?="{{spawnDefaultAgent}}", model?=None, label?=None, schema?=None, handle?=False) → str | dict
|
|
179
|
-
Run a subagent → final output. \`agent\` picks
|
|
180
|
-
{{
|
|
181
|
-
|
|
182
|
-
Thunks through a bounded pool (wide as a \`task\` batch — don't pre-shrink), input order kept; returns when all finish, a throwing thunk propagates.
|
|
133
|
+
Run a subagent → final output. \`agent\` picks a discovered agent. \`schema\` as in completion(). \`handle\` → workflow node { text, output, handle: \`agent://<id>\`, id, agent } (parsed under \`data\` with \`schema\`).
|
|
134
|
+
{{/if}}parallel(thunks) → list
|
|
135
|
+
Thunks through a bounded pool (as wide as a \`task\` batch), input order kept; a throwing thunk propagates.
|
|
183
136
|
pipeline(items, ...stages) → list
|
|
184
|
-
Map items through one-arg stages
|
|
137
|
+
Map items through one-arg stages with a barrier between stages; each stage receives the previous stage's result.
|
|
185
138
|
log(message) → None
|
|
186
139
|
Progress line above the status tree.
|
|
187
140
|
phase(title) → None
|
|
@@ -190,18 +143,10 @@ phase(title) → None
|
|
|
190
143
|
</prelude>
|
|
191
144
|
{{#if spawns}}
|
|
192
145
|
<workflow>
|
|
193
|
-
|
|
194
|
-
- **Name nodes.** Capture each \`agent(…, {{#if py}}handle=True{{/if}}{{#if js}}{ handle: true }{{/if}}{{#if jl}}handle=true{{/if}})\` result; carries \`handle\` (\`agent://<id>\`) + \`output\`.
|
|
195
|
-
- **Wire edges by reference.** Put an upstream node's \`handle\`/\`output\` in the dependent stage's prompt — large transcript never re-inlined. Bulk: \`write("local://<name>.md", …)\`, pass the URI.
|
|
196
|
-
- **\`pipeline(items, *stages)\` = staged waves**, barrier between stages (every item clears stage N before any enters N+1). **\`parallel(thunks)\` = one wave** of independent nodes.
|
|
197
|
-
- **Isolate failure.** A raising node re-raises the lowest-index error, aborts its wave; wrap risky nodes in try/except so a failure degrades only its dependent subtree, independent branches finish.
|
|
198
|
-
- **Acyclic only.** A node never waits on its own descendant.
|
|
146
|
+
Multi-agent work is an acyclic graph in code: one \`agent(…)\` node per step with its handle option ({{#if py}}\`handle=True\`{{/if}}{{#ifAll py js}} / {{/ifAll}}{{#if js}}\`{ handle: true }\`{{/if}}{{#if jl}}{{#ifAny py js}} / {{/ifAny}}\`handle=true\`{{/if}}), \`parallel(thunks)\` for independent nodes, \`pipeline(items, *stages)\` for staged waves. Pass an upstream node's \`handle\` or \`output\` (or a \`write("local://…")\` URI for bulk text) into dependents instead of re-inlining transcripts, and wrap risky nodes in try/except so a failure aborts only its subtree.
|
|
199
147
|
</workflow>
|
|
200
148
|
{{/if}}
|
|
201
|
-
|
|
202
|
-
<critical>
|
|
203
|
-
Prior top-level names (\`data\`, \`sessions\`, helpers, imports) survive into the next eval call — reuse them; NEVER re-import, re-require, or re-declare a helper. Re-read a file only if it may have changed since the last read.
|
|
204
|
-
</critical>`;
|
|
149
|
+
`;
|
|
205
150
|
|
|
206
151
|
export function buildEvalPrompt(
|
|
207
152
|
enabled: EnabledLanguages,
|
|
@@ -230,25 +175,14 @@ export function buildEvalPrompt(
|
|
|
230
175
|
jsVersion: options.jsRuntime?.version ?? "",
|
|
231
176
|
bunSkillPath: options.bunSkillPath ?? "",
|
|
232
177
|
};
|
|
233
|
-
const
|
|
234
|
-
.
|
|
235
|
-
|
|
236
|
-
return `### ${example.caption}\n\`\`\`json\n${JSON.stringify(call, null, 2)}\n\`\`\``;
|
|
237
|
-
})
|
|
238
|
-
.join("\n\n");
|
|
239
|
-
const description = [
|
|
240
|
-
renderTemplate(EVAL_PROMPT_TEMPLATE, context)
|
|
241
|
-
.replace(/\n{3,}/g, "\n\n")
|
|
242
|
-
.trim(),
|
|
243
|
-
examples === "" ? "" : `<examples>\n${examples}\n</examples>`,
|
|
244
|
-
]
|
|
245
|
-
.filter((part) => part !== "")
|
|
246
|
-
.join("\n\n");
|
|
178
|
+
const description = renderTemplate(EVAL_PROMPT_TEMPLATE, context)
|
|
179
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
180
|
+
.trim();
|
|
247
181
|
return {
|
|
248
182
|
description,
|
|
249
183
|
promptSnippet: "Run one incremental code cell in a persistent language kernel.",
|
|
250
184
|
promptGuidelines: [
|
|
251
|
-
BATCHING_GUIDELINES[style],
|
|
185
|
+
style === "gpt" && context.monitor === true ? GPT_MONITOR_BATCHING_GUIDELINE : BATCHING_GUIDELINES[style],
|
|
252
186
|
"Use eval reset only when a language kernel must be wiped; reset is scoped to the selected language.",
|
|
253
187
|
],
|
|
254
188
|
};
|
|
@@ -257,8 +191,13 @@ export function buildEvalPrompt(
|
|
|
257
191
|
/**
|
|
258
192
|
* System-prompt guideline per emphasis dialect. The default dialect carries
|
|
259
193
|
* maximum emphasis so unmapped models still batch through eval; the others are
|
|
260
|
-
* tuned to what steers that family reliably.
|
|
194
|
+
* tuned to what steers that family reliably. The GPT line routes waits to the
|
|
195
|
+
* subscription when `monitor` is reachable, because a GPT model that reads
|
|
196
|
+
* "long cells detach" as the way to wait awaits a `--watch` inside a cell.
|
|
261
197
|
*/
|
|
198
|
+
const GPT_MONITOR_BATCHING_GUIDELINE =
|
|
199
|
+
"Use eval to compose tool work in one cell; a wait or a long run starts through `tool.monitor` in that cell, so no cell sits on it and nothing polls.";
|
|
200
|
+
|
|
262
201
|
const BATCHING_GUIDELINES: Record<EvalEmphasisStyle, string> = {
|
|
263
202
|
default:
|
|
264
203
|
"**EVAL FIRST.** Any step needing MORE THAN ONE tool call MUST be ONE eval cell: run independent calls in parallel, wrap risky calls in try/except, and return distilled facts — NEVER a chain of single tool calls.",
|
|
@@ -17,6 +17,12 @@ export interface CellExecutionOptions {
|
|
|
17
17
|
readonly callerSignal: AbortSignal;
|
|
18
18
|
readonly cellId: string;
|
|
19
19
|
readonly timeoutMs: number;
|
|
20
|
+
/**
|
|
21
|
+
* Caps how long a host-bridge pause may suspend the idle watchdog. When the cell will detach on
|
|
22
|
+
* timeout this is set to the foreground window so a bridge-parked cell still frees the turn at the
|
|
23
|
+
* window; left undefined (error mode) it keeps the idle-timeout default grace.
|
|
24
|
+
*/
|
|
25
|
+
readonly maxPauseGraceMs?: number;
|
|
20
26
|
readonly timeoutFactory: EvalTimeoutFactory;
|
|
21
27
|
readonly onTimeout: (error: Error) => void;
|
|
22
28
|
readonly onAbort: (error: Error) => void;
|
|
@@ -46,6 +52,7 @@ export class CellExecution {
|
|
|
46
52
|
this.#watchdog = options.timeoutFactory.create({
|
|
47
53
|
cellId: options.cellId,
|
|
48
54
|
timeoutMs: options.timeoutMs,
|
|
55
|
+
...(options.maxPauseGraceMs === undefined ? {} : { maxPauseGraceMs: options.maxPauseGraceMs }),
|
|
49
56
|
onTimeout: ({ error }) => options.onTimeout(error),
|
|
50
57
|
});
|
|
51
58
|
this.#callerSignal.addEventListener("abort", this.#handleCallerAbort, {
|
|
@@ -21,6 +21,12 @@ export interface CreateEvalToolOptions {
|
|
|
21
21
|
readonly enabledLanguages: EnabledEvalLanguages;
|
|
22
22
|
readonly kernelManager: EvalKernelManager;
|
|
23
23
|
readonly cellTimeoutSeconds: number;
|
|
24
|
+
/**
|
|
25
|
+
* Longest an interactive (detach-behavior) call blocks the agent loop before the cell detaches,
|
|
26
|
+
* capping the `timeout` detach budget. Defaults to {@link DEFAULT_FOREGROUND_WINDOW_SECONDS}.
|
|
27
|
+
* Does not affect `on_timeout: "error"` calls or the wall-clock hard limit.
|
|
28
|
+
*/
|
|
29
|
+
readonly foregroundWindowSeconds?: number;
|
|
24
30
|
/** Wall-clock kill deadline applied to every cell; only used when this factory creates its own manager. */
|
|
25
31
|
readonly hardLimitSeconds?: number;
|
|
26
32
|
readonly executeTool: ExecuteTool;
|
package/src/tool/eval-tool.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { AgentToolResult, ExtensionContext, ToolDefinition } from "@code-yeongyu/senpi";
|
|
4
|
-
import { defaultCodemodeSettings } from "../config/settings.ts";
|
|
4
|
+
import { DEFAULT_FOREGROUND_WINDOW_SECONDS, defaultCodemodeSettings } from "../config/settings.ts";
|
|
5
5
|
import { buildEvalPrompt } from "../prompt/eval-prompt.ts";
|
|
6
6
|
import { TIMEOUT_PAUSE_OP, TIMEOUT_RESUME_OP } from "../timeouts/bridge-timeout.ts";
|
|
7
7
|
import { abortError, CellExecution, defaultTimeoutFactory } from "./cell-execution.ts";
|
|
@@ -101,8 +101,19 @@ async function runEvalCell(
|
|
|
101
101
|
invocation: EvalCellInvocation,
|
|
102
102
|
): Promise<AgentToolResult<EvalToolDetails>> {
|
|
103
103
|
if (invocation.signal.aborted) throw abortError(invocation.signal.reason);
|
|
104
|
-
const timeoutMs = Math.floor((invocation.input.timeout ?? options.cellTimeoutSeconds) * 1_000);
|
|
105
104
|
const timeoutBehavior = evalTimeoutBehavior(invocation.input, invocation.ctx);
|
|
105
|
+
const requestedTimeoutMs = Math.floor((invocation.input.timeout ?? options.cellTimeoutSeconds) * 1_000);
|
|
106
|
+
// The `timeout` (and its `cellTimeoutSeconds` default) is the detach budget for interactive calls.
|
|
107
|
+
// Cap it at the foreground window so a large `timeout` — whose real purpose is to raise the
|
|
108
|
+
// wall-clock hard limit (see EvalDetachedCellManager) — frees the turn at the window instead of
|
|
109
|
+
// blocking the agent loop for its full duration. `on_timeout: "error"` (and print/json) keep the
|
|
110
|
+
// unclamped deadline, since there the cell is killed rather than detached.
|
|
111
|
+
const foregroundWindowMs = (options.foregroundWindowSeconds ?? DEFAULT_FOREGROUND_WINDOW_SECONDS) * 1_000;
|
|
112
|
+
const timeoutMs =
|
|
113
|
+
timeoutBehavior === "detach" ? Math.min(requestedTimeoutMs, foregroundWindowMs) : requestedTimeoutMs;
|
|
114
|
+
// A cell that pauses its watchdog for a host bridge call would otherwise wait the full pause grace
|
|
115
|
+
// (~10 min) before detaching; cap the grace at the foreground window too so the detach guarantee
|
|
116
|
+
// holds for bridge-parked cells. Error mode keeps the default grace (its timeout is the deadline).
|
|
106
117
|
const bridgeAbortController = new AbortController();
|
|
107
118
|
const cellSignal = AbortSignal.any([invocation.signal, bridgeAbortController.signal]);
|
|
108
119
|
const bridgeContext: ExtensionContext = { ...invocation.ctx, signal: cellSignal };
|
|
@@ -131,6 +142,7 @@ async function runEvalCell(
|
|
|
131
142
|
callerSignal: invocation.signal,
|
|
132
143
|
cellId: invocation.cellId,
|
|
133
144
|
timeoutMs,
|
|
145
|
+
...(timeoutBehavior === "detach" ? { maxPauseGraceMs: foregroundWindowMs } : {}),
|
|
134
146
|
timeoutFactory: options.timeoutFactory ?? defaultTimeoutFactory,
|
|
135
147
|
onTimeout: (error) => {
|
|
136
148
|
if (timeoutBehavior === "detach" && cellManager.detach(cell)) {
|
package/src/tool/types.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type TUnsafe, Type } from "typebox";
|
|
|
3
3
|
import type { HostToKernelMessage, KernelToHostMessage } from "../bridge/protocol.ts";
|
|
4
4
|
import type { TruncationMeta } from "../output/output-meta.ts";
|
|
5
5
|
|
|
6
|
-
export const evalLanguageOrder = ["
|
|
6
|
+
export const evalLanguageOrder = ["js", "py", "rb", "jl"] as const;
|
|
7
7
|
export type EvalLanguage = (typeof evalLanguageOrder)[number];
|
|
8
8
|
export type EnabledEvalLanguages = Readonly<Record<EvalLanguage, boolean>>;
|
|
9
9
|
|
|
@@ -13,6 +13,12 @@ export function enabledLanguageList(enabled: EnabledEvalLanguages): EvalLanguage
|
|
|
13
13
|
|
|
14
14
|
export const EVAL_SUMMARY_MAX_LENGTH = 80;
|
|
15
15
|
|
|
16
|
+
const TIMEOUT_FIELD_DESCRIPTION =
|
|
17
|
+
"Seconds the cell may block the turn before it detaches, and the amount by which it raises the wall-clock hard limit. In interactive sessions the detach point is capped at the foreground window (default 60s), so a large value frees the turn at the window while the cell keeps running; on_timeout:'error' (and print/json) keep the full value as the uncapped deadline.";
|
|
18
|
+
|
|
19
|
+
const ON_TIMEOUT_FIELD_DESCRIPTION =
|
|
20
|
+
"Timeout behavior. Interactive sessions detach by default (at the foreground window); print/json sessions error by default. 'error' uses the full timeout as an uncapped deadline.";
|
|
21
|
+
|
|
16
22
|
export interface EvalToolInput {
|
|
17
23
|
readonly language: EvalLanguage;
|
|
18
24
|
readonly code: string;
|
|
@@ -37,7 +43,7 @@ const fullEvalInputSchema = Type.Object({
|
|
|
37
43
|
}),
|
|
38
44
|
),
|
|
39
45
|
language: Type.Optional(
|
|
40
|
-
Type.Union([Type.Literal("
|
|
46
|
+
Type.Union([Type.Literal("js"), Type.Literal("py"), Type.Literal("rb"), Type.Literal("jl")]),
|
|
41
47
|
),
|
|
42
48
|
code: Type.Optional(Type.String({ description: "Cell body, verbatim." })),
|
|
43
49
|
summary: Type.Optional(
|
|
@@ -47,10 +53,10 @@ const fullEvalInputSchema = Type.Object({
|
|
|
47
53
|
"REQUIRED for run. ONE line in the USER'S conversational language (Korean conversation -> Korean summary) stating WHAT this cell does and FOR WHAT PURPOSE; shown in the TUI while the cell runs. Longer values are force-truncated to 80 chars.",
|
|
48
54
|
}),
|
|
49
55
|
),
|
|
50
|
-
timeout: Type.Optional(Type.Number({ minimum: 1, description:
|
|
56
|
+
timeout: Type.Optional(Type.Number({ minimum: 1, description: TIMEOUT_FIELD_DESCRIPTION })),
|
|
51
57
|
on_timeout: Type.Optional(
|
|
52
58
|
Type.Union([Type.Literal("detach"), Type.Literal("error")], {
|
|
53
|
-
description:
|
|
59
|
+
description: ON_TIMEOUT_FIELD_DESCRIPTION,
|
|
54
60
|
}),
|
|
55
61
|
),
|
|
56
62
|
reset: Type.Optional(Type.Boolean({ description: "Reset this language kernel before running." })),
|
|
@@ -83,11 +89,10 @@ export function createEvalInputSchema(enabled: EnabledEvalLanguages): EvalInputS
|
|
|
83
89
|
"REQUIRED for run. ONE line in the USER'S conversational language (Korean conversation -> Korean summary) stating WHAT this cell does and FOR WHAT PURPOSE; shown in the TUI while the cell runs. Longer values are force-truncated to 80 chars.",
|
|
84
90
|
}),
|
|
85
91
|
),
|
|
86
|
-
timeout: Type.Optional(Type.Number({ minimum: 1, description:
|
|
92
|
+
timeout: Type.Optional(Type.Number({ minimum: 1, description: TIMEOUT_FIELD_DESCRIPTION })),
|
|
87
93
|
on_timeout: Type.Optional(
|
|
88
94
|
Type.Union([Type.Literal("detach"), Type.Literal("error")], {
|
|
89
|
-
description:
|
|
90
|
-
"Timeout behavior. Interactive sessions detach by default; print/json sessions error by default.",
|
|
95
|
+
description: ON_TIMEOUT_FIELD_DESCRIPTION,
|
|
91
96
|
}),
|
|
92
97
|
),
|
|
93
98
|
reset: Type.Optional(Type.Boolean({ description: "Reset this language kernel before running." })),
|