@code-yeongyu/senpi-codemode 2026.9.13-2 → 2026.9.15-2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/package.json +4 -4
- package/src/bridge/reserved.ts +2 -0
- package/src/kernels/js/context-manager.ts +62 -13
- package/src/kernels/js/process-tree-host.ts +158 -0
- package/src/kernels/js/process-tree.d.ts +24 -0
- package/src/kernels/js/process-tree.js +151 -0
- package/src/kernels/js/worker-core.js +3 -1
- package/src/kernels/js/worker-runtime.js +35 -7
- package/src/kernels/js/worker-shell-capture.d.ts +2 -1
- package/src/kernels/js/worker-shell-capture.js +1 -1
- package/src/kernels/py/prelude.py +35 -1
- package/src/kernels/py/process.ts +20 -0
- package/src/kernels/py/transport.ts +5 -1
- package/src/kernels/shared/subprocess-queue.ts +7 -1
- package/src/tool/detached-cell-manager.ts +36 -71
- package/src/tool/image-resize.ts +52 -0
- package/src/tool/image.ts +37 -72
- package/src/tool/managed-cell.ts +78 -0
- package/src/tool/render.ts +30 -5
- package/src/tool/terminal-snapshot-store.ts +35 -0
- package/src/tool/tool-result-marshal.ts +23 -0
- package/src/tool/tool-widgets.ts +16 -1
- package/src/tool/types.ts +1 -0
|
@@ -154,7 +154,7 @@ function capturedSpawn(originalSpawn, options, pinEnv) {
|
|
|
154
154
|
? originalSpawn(...args)
|
|
155
155
|
: originalSpawn(effective);
|
|
156
156
|
}
|
|
157
|
-
options.onChild?.(child);
|
|
157
|
+
options.onChild?.(child, Array.isArray(first) ? second : first);
|
|
158
158
|
return child;
|
|
159
159
|
};
|
|
160
160
|
}
|
|
@@ -23,7 +23,7 @@ import uuid
|
|
|
23
23
|
from collections.abc import Iterable
|
|
24
24
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
25
25
|
from pathlib import Path
|
|
26
|
-
from threading import Lock
|
|
26
|
+
from threading import Lock, Thread
|
|
27
27
|
from typing import Any, Callable
|
|
28
28
|
from urllib.parse import unquote
|
|
29
29
|
|
|
@@ -1022,14 +1022,48 @@ def handle(message: dict[str, Any]) -> bool:
|
|
|
1022
1022
|
return True
|
|
1023
1023
|
|
|
1024
1024
|
|
|
1025
|
+
def _terminate_process_group() -> None:
|
|
1026
|
+
# The kernel is spawned into its own session (setsid), so its pid is its process
|
|
1027
|
+
# group id and a cell's subprocesses inherit that group. Killing the group takes
|
|
1028
|
+
# those children down with the kernel instead of orphaning them to init.
|
|
1029
|
+
if os.name != "posix":
|
|
1030
|
+
return
|
|
1031
|
+
with contextlib.suppress(OSError):
|
|
1032
|
+
os.killpg(os.getpgrp(), signal.SIGKILL)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _watch_parent(initial_ppid: int) -> None:
|
|
1036
|
+
# A cell blocked in the main thread (for example a multiprocessing pool) never
|
|
1037
|
+
# returns to the stdin loop, so it cannot notice the host closing its pipe. This
|
|
1038
|
+
# daemon thread notices the reparenting instead and takes the whole group down.
|
|
1039
|
+
while True:
|
|
1040
|
+
time.sleep(1.0)
|
|
1041
|
+
if os.getppid() != initial_ppid:
|
|
1042
|
+
_terminate_process_group()
|
|
1043
|
+
return
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _start_parent_watch() -> None:
|
|
1047
|
+
if os.name != "posix":
|
|
1048
|
+
return
|
|
1049
|
+
Thread(target=_watch_parent, args=(os.getppid(),), name="senpi-parent-watch", daemon=True).start()
|
|
1050
|
+
|
|
1051
|
+
|
|
1025
1052
|
def main() -> None:
|
|
1026
1053
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
1054
|
+
_start_parent_watch()
|
|
1055
|
+
host_closed = False
|
|
1027
1056
|
for raw in sys.stdin:
|
|
1028
1057
|
try:
|
|
1029
1058
|
if not handle(json.loads(raw)):
|
|
1059
|
+
host_closed = True
|
|
1030
1060
|
break
|
|
1031
1061
|
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — process boundary serializes malformed input and interrupts.
|
|
1032
1062
|
emit({"type": "init-failed", "error": bridge_error(exc)})
|
|
1063
|
+
# Reaching here without a close frame means the host's pipe hit EOF: it is gone,
|
|
1064
|
+
# so retire any subprocess the last cell left running before the interpreter exits.
|
|
1065
|
+
if not host_closed:
|
|
1066
|
+
_terminate_process_group()
|
|
1033
1067
|
|
|
1034
1068
|
|
|
1035
1069
|
if __name__ == "__main__":
|
|
@@ -45,6 +45,12 @@ export function splitCommand(commandLine: string): { readonly command: string; r
|
|
|
45
45
|
return { command, args };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function errorCode(error: unknown): string | undefined {
|
|
49
|
+
if (typeof error !== "object" || error === null) return undefined;
|
|
50
|
+
const code = Reflect.get(error, "code");
|
|
51
|
+
return typeof code === "string" ? code : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
export function numberOrNull(value: unknown): number | null {
|
|
49
55
|
return typeof value === "number" ? value : null;
|
|
50
56
|
}
|
|
@@ -80,6 +86,20 @@ export async function waitForExit(child: KernelChild, timeoutMs: number): Promis
|
|
|
80
86
|
});
|
|
81
87
|
}
|
|
82
88
|
|
|
89
|
+
// A cell's own subprocess is spawned into the detached kernel's process group. When the
|
|
90
|
+
// leader exits gracefully on close, kill the group so a Popen the cell left running does
|
|
91
|
+
// not outlive the kernel; the group is addressed by the leader pid (== pgid) while members live.
|
|
92
|
+
export function sweepProcessGroup(child: KernelChild): void {
|
|
93
|
+
if (child.pid === undefined || process.platform === "win32") return;
|
|
94
|
+
try {
|
|
95
|
+
process.kill(-child.pid, "SIGKILL");
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// ESRCH means the group is already gone (the success case). Any other error
|
|
98
|
+
// (for example EPERM on a member we cannot signal) must not fail a graceful close.
|
|
99
|
+
if (errorCode(error) !== "ESRCH") return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
83
103
|
export async function hardKill(child: KernelChild, timeoutMs: number): Promise<void> {
|
|
84
104
|
await new Promise<void>((resolve, reject) => {
|
|
85
105
|
let timer: NodeJS.Timeout | undefined;
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
numberOrNull,
|
|
20
20
|
signalOrNull,
|
|
21
21
|
splitCommand,
|
|
22
|
+
sweepProcessGroup,
|
|
22
23
|
waitForExit,
|
|
23
24
|
withTimeout,
|
|
24
25
|
} from "./process.ts";
|
|
@@ -145,7 +146,10 @@ export class PythonKernelTransport {
|
|
|
145
146
|
} catch (error) {
|
|
146
147
|
if (!(error instanceof Error)) throw error;
|
|
147
148
|
}
|
|
148
|
-
|
|
149
|
+
// hardKill kills the whole group; a graceful leader exit does not, so sweep it
|
|
150
|
+
// to retire any subprocess the cell left running in the kernel's process group.
|
|
151
|
+
if (await exited) sweepProcessGroup(this.#child);
|
|
152
|
+
else await hardKill(this.#child, hardKillWaitMs);
|
|
149
153
|
}
|
|
150
154
|
|
|
151
155
|
retire(): Promise<void> {
|
|
@@ -2,6 +2,9 @@ import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
|
2
2
|
import type { KernelResult, KernelRunInput, ToolCallMessage } from "./subprocess-contract.ts";
|
|
3
3
|
import { createPendingRun, failureResult, type PendingRun, settlePendingRun } from "./subprocess-run.ts";
|
|
4
4
|
|
|
5
|
+
// Same bound as the JS kernel's pull-API fallback queue (context-manager.ts); clears happen at run teardown.
|
|
6
|
+
const MAX_PENDING_TOOL_CALLS = 256;
|
|
7
|
+
|
|
5
8
|
export class SubprocessRunQueue {
|
|
6
9
|
readonly #queue: PendingRun[] = [];
|
|
7
10
|
readonly #pendingCalls: ToolCallMessage[] = [];
|
|
@@ -59,7 +62,10 @@ export class SubprocessRunQueue {
|
|
|
59
62
|
pushToolCall(message: ToolCallMessage): void {
|
|
60
63
|
const waiter = this.#callWaiters.shift();
|
|
61
64
|
if (waiter) waiter(message);
|
|
62
|
-
else
|
|
65
|
+
else {
|
|
66
|
+
this.#pendingCalls.push(message);
|
|
67
|
+
if (this.#pendingCalls.length > MAX_PENDING_TOOL_CALLS) this.#pendingCalls.shift();
|
|
68
|
+
}
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
handleMessage(
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import type { AgentToolResult } from "@code-yeongyu/senpi";
|
|
2
2
|
import { DEFAULT_HARD_LIMIT_SECONDS, DEFAULT_RUN_BUDGET_SECONDS } from "../config/settings.ts";
|
|
3
3
|
import type { WakeSourceState } from "../extension/wake-source-state.ts";
|
|
4
|
-
import {
|
|
4
|
+
import type { CellDeadlineExpiry } from "./cell-deadlines.ts";
|
|
5
5
|
import type {
|
|
6
6
|
EvalDetachedCellManagerOptions,
|
|
7
7
|
EvalDetachedCellSnapshot,
|
|
8
|
-
EvalDetachedCellState,
|
|
9
8
|
EvalDetachedCellStatusEntry,
|
|
10
9
|
} from "./detached-cell-contract.ts";
|
|
11
|
-
import { detachedNotificationSpillPath } from "./detached-cell-notification.ts";
|
|
12
10
|
import { currentDetachedResult, detachedErrorResult, snapshotDetachedCell } from "./detached-cell-snapshot.ts";
|
|
13
11
|
import {
|
|
14
12
|
activeDetachedCellReuseError,
|
|
@@ -17,6 +15,8 @@ import {
|
|
|
17
15
|
} from "./detached-cell-state.ts";
|
|
18
16
|
import { detachedStatusEntries, detachedWakeSourceState } from "./detached-cell-status.ts";
|
|
19
17
|
import { DetachedNotificationQueue } from "./detached-notification-queue.ts";
|
|
18
|
+
import { createManagedCell, type LiveResultProvider, type ManagedCell } from "./managed-cell.ts";
|
|
19
|
+
import { TerminalSnapshotStore } from "./terminal-snapshot-store.ts";
|
|
20
20
|
import type { EvalKernel, EvalLanguage, EvalToolDetails, EvalToolInput } from "./types.ts";
|
|
21
21
|
|
|
22
22
|
export type {
|
|
@@ -28,40 +28,13 @@ export type {
|
|
|
28
28
|
EvalDetachedCellStatusEntry,
|
|
29
29
|
} from "./detached-cell-contract.ts";
|
|
30
30
|
|
|
31
|
-
type LiveResultProvider = () => AgentToolResult<EvalToolDetails>;
|
|
32
|
-
|
|
33
|
-
type ManagedCell = {
|
|
34
|
-
readonly cellId: string;
|
|
35
|
-
readonly input: EvalToolInput;
|
|
36
|
-
readonly spillPath: string | undefined;
|
|
37
|
-
readonly startedAtMs: number;
|
|
38
|
-
readonly terminal: PromiseWithResolvers<EvalDetachedCellSnapshot>;
|
|
39
|
-
state: EvalDetachedCellState;
|
|
40
|
-
canDetach: boolean;
|
|
41
|
-
wasDetached: boolean;
|
|
42
|
-
kernel: EvalKernel | undefined;
|
|
43
|
-
stateRetained: boolean | undefined;
|
|
44
|
-
interruptNote: string | undefined;
|
|
45
|
-
/** Holds the completion notification until the interrupt has reported whether kernel state survived. */
|
|
46
|
-
interruptOutcome: PromiseWithResolvers<void> | undefined;
|
|
47
|
-
liveResult: LiveResultProvider | undefined;
|
|
48
|
-
terminalResult: AgentToolResult<EvalToolDetails> | undefined;
|
|
49
|
-
notificationQueued: boolean;
|
|
50
|
-
readonly deadlines: CellDeadlines;
|
|
51
|
-
readonly hardLimitSeconds: number;
|
|
52
|
-
readonly runBudgetSeconds: number;
|
|
53
|
-
hardLimited: boolean;
|
|
54
|
-
runBudgetExhausted: boolean;
|
|
55
|
-
/** Foreground killer: the still-awaited CellExecution owns interrupting and rejecting its own call; bound from creation so a deadline firing during kernel boot still ends it. */
|
|
56
|
-
onKill: ((error: Error) => void) | undefined;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
31
|
export class EvalDetachedCellManager {
|
|
60
32
|
readonly #artifactsDir: string | undefined;
|
|
61
33
|
readonly #onStatusChange: ((entries: readonly EvalDetachedCellStatusEntry[]) => void) | undefined;
|
|
62
34
|
readonly #onWakeSourceState: ((state: WakeSourceState) => void) | undefined;
|
|
63
35
|
readonly #cells = new Map<string, ManagedCell>();
|
|
64
36
|
readonly #detachedByLanguage = new Map<EvalLanguage, ManagedCell>();
|
|
37
|
+
readonly #terminalSnapshots = new TerminalSnapshotStore();
|
|
65
38
|
readonly #notificationQueue: DetachedNotificationQueue;
|
|
66
39
|
readonly #now: () => number;
|
|
67
40
|
readonly #hardLimitSeconds: number;
|
|
@@ -83,40 +56,20 @@ export class EvalDetachedCellManager {
|
|
|
83
56
|
if (detachedCellIsActive(existing.state)) throw activeDetachedCellReuseError(existing);
|
|
84
57
|
this.#cells.delete(cellId);
|
|
85
58
|
}
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
const runBudgetSeconds = input.timeout ?? this.#runBudgetSeconds;
|
|
89
|
-
const cell: ManagedCell = {
|
|
59
|
+
this.#terminalSnapshots.delete(cellId);
|
|
60
|
+
const cell = createManagedCell({
|
|
90
61
|
cellId,
|
|
91
62
|
input,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
wasDetached: false,
|
|
97
|
-
kernel: undefined,
|
|
98
|
-
stateRetained: undefined,
|
|
99
|
-
interruptNote: undefined,
|
|
100
|
-
interruptOutcome: undefined,
|
|
101
|
-
liveResult: undefined,
|
|
102
|
-
terminalResult: undefined,
|
|
103
|
-
notificationQueued: false,
|
|
104
|
-
deadlines: new CellDeadlines({
|
|
105
|
-
cellId,
|
|
106
|
-
hardLimitSeconds,
|
|
107
|
-
runBudgetSeconds,
|
|
108
|
-
onExpire: (expiry) => {
|
|
109
|
-
const managed = this.#cells.get(cellId);
|
|
110
|
-
if (managed !== undefined) void this.#expireDeadline(managed, expiry);
|
|
111
|
-
},
|
|
112
|
-
}),
|
|
113
|
-
hardLimitSeconds,
|
|
114
|
-
runBudgetSeconds,
|
|
115
|
-
hardLimited: false,
|
|
116
|
-
runBudgetExhausted: false,
|
|
63
|
+
artifactsDir: this.#artifactsDir,
|
|
64
|
+
now: this.#now,
|
|
65
|
+
defaultHardLimitSeconds: this.#hardLimitSeconds,
|
|
66
|
+
defaultRunBudgetSeconds: this.#runBudgetSeconds,
|
|
117
67
|
onKill,
|
|
118
|
-
|
|
119
|
-
|
|
68
|
+
onExpire: (expiredId, expiry) => {
|
|
69
|
+
const managed = this.#cells.get(expiredId);
|
|
70
|
+
if (managed !== undefined) void this.#expireDeadline(managed, expiry);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
120
73
|
this.#cells.set(cellId, cell);
|
|
121
74
|
return cell;
|
|
122
75
|
}
|
|
@@ -166,13 +119,15 @@ export class EvalDetachedCellManager {
|
|
|
166
119
|
}
|
|
167
120
|
|
|
168
121
|
async stop(cellId: string, reason = "Stopped detached eval cell"): Promise<EvalDetachedCellSnapshot> {
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
171
|
-
|
|
122
|
+
const live = this.#cells.get(cellId);
|
|
123
|
+
if (live === undefined) return this.#terminal(cellId);
|
|
124
|
+
if (live.state === "detached") await this.#cancel(live, reason);
|
|
125
|
+
return this.#snapshot(live);
|
|
172
126
|
}
|
|
173
127
|
|
|
174
128
|
peek(cellId: string): EvalDetachedCellSnapshot {
|
|
175
|
-
|
|
129
|
+
const live = this.#cells.get(cellId);
|
|
130
|
+
return live === undefined ? this.#terminal(cellId) : this.#snapshot(live);
|
|
176
131
|
}
|
|
177
132
|
|
|
178
133
|
busyFor(language: EvalLanguage): EvalDetachedCellSnapshot | undefined {
|
|
@@ -181,7 +136,8 @@ export class EvalDetachedCellManager {
|
|
|
181
136
|
}
|
|
182
137
|
|
|
183
138
|
async waitForTerminal(cellId: string): Promise<EvalDetachedCellSnapshot> {
|
|
184
|
-
|
|
139
|
+
const live = this.#cells.get(cellId);
|
|
140
|
+
return live === undefined ? this.#terminal(cellId) : await live.terminal.promise;
|
|
185
141
|
}
|
|
186
142
|
|
|
187
143
|
async dispose(): Promise<void> {
|
|
@@ -191,6 +147,8 @@ export class EvalDetachedCellManager {
|
|
|
191
147
|
);
|
|
192
148
|
if (detached.length === 0) this.#emitWakeSourceState([]);
|
|
193
149
|
await this.#notificationQueue.flush();
|
|
150
|
+
this.#cells.clear();
|
|
151
|
+
this.#terminalSnapshots.clear();
|
|
194
152
|
}
|
|
195
153
|
|
|
196
154
|
async flushNotifications(): Promise<void> {
|
|
@@ -213,6 +171,8 @@ export class EvalDetachedCellManager {
|
|
|
213
171
|
cell.terminalResult = result;
|
|
214
172
|
cell.liveResult = undefined;
|
|
215
173
|
cell.terminal.resolve(this.#snapshot(cell));
|
|
174
|
+
this.#cells.delete(cell.cellId);
|
|
175
|
+
this.#refreshTerminalSnapshot(cell);
|
|
216
176
|
if (cell.wasDetached) {
|
|
217
177
|
if (this.#detachedByLanguage.get(cell.input.language) === cell)
|
|
218
178
|
this.#detachedByLanguage.delete(cell.input.language);
|
|
@@ -257,9 +217,14 @@ export class EvalDetachedCellManager {
|
|
|
257
217
|
cell.stateRetained = await handle.stateRetained;
|
|
258
218
|
} finally {
|
|
259
219
|
outcome.resolve();
|
|
220
|
+
this.#refreshTerminalSnapshot(cell);
|
|
260
221
|
}
|
|
261
222
|
}
|
|
262
223
|
|
|
224
|
+
#refreshTerminalSnapshot(cell: ManagedCell): void {
|
|
225
|
+
if (!this.#cells.has(cell.cellId)) this.#terminalSnapshots.remember(this.#snapshot(cell));
|
|
226
|
+
}
|
|
227
|
+
|
|
263
228
|
#emitStatus(): void {
|
|
264
229
|
const liveCells = [...this.#detachedByLanguage.values()];
|
|
265
230
|
this.#onStatusChange?.(detachedStatusEntries(liveCells));
|
|
@@ -274,9 +239,9 @@ export class EvalDetachedCellManager {
|
|
|
274
239
|
return snapshotDetachedCell(cell, this.#now());
|
|
275
240
|
}
|
|
276
241
|
|
|
277
|
-
#
|
|
278
|
-
const
|
|
279
|
-
if (
|
|
280
|
-
return
|
|
242
|
+
#terminal(cellId: string): EvalDetachedCellSnapshot {
|
|
243
|
+
const snapshot = this.#terminalSnapshots.get(cellId);
|
|
244
|
+
if (snapshot === undefined) throw new Error(`Unknown detached eval cell "${cellId}"`);
|
|
245
|
+
return snapshot;
|
|
281
246
|
}
|
|
282
247
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
2
|
+
import { convertToPng, formatDimensionNote, resizeImage } from "@code-yeongyu/senpi";
|
|
3
|
+
|
|
4
|
+
export interface EvalImageContent {
|
|
5
|
+
readonly type: "image";
|
|
6
|
+
readonly data: string;
|
|
7
|
+
readonly mimeType: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface EvalImageResizeResult {
|
|
11
|
+
readonly image: EvalImageContent;
|
|
12
|
+
readonly dimensionNote?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type EvalImageResizer = (
|
|
16
|
+
image: EvalImageContent,
|
|
17
|
+
model: ExtensionContext["model"],
|
|
18
|
+
) => Promise<EvalImageResizeResult>;
|
|
19
|
+
|
|
20
|
+
type WebpModel = { readonly provider: string; readonly api: string } | undefined;
|
|
21
|
+
|
|
22
|
+
export function webpExclusionForModel(model: WebpModel): true | undefined {
|
|
23
|
+
if (model === undefined) return undefined;
|
|
24
|
+
return model.provider === "ollama" ||
|
|
25
|
+
model.provider === "ollama-cloud" ||
|
|
26
|
+
model.provider === "llama.cpp" ||
|
|
27
|
+
model.provider === "lm-studio" ||
|
|
28
|
+
model.provider === "local-server" ||
|
|
29
|
+
model.api === "ollama-chat"
|
|
30
|
+
? true
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const resizeEvalImage: EvalImageResizer = async (image, model) => {
|
|
35
|
+
const excludeWebP = webpExclusionForModel(model);
|
|
36
|
+
const forceWebpConversion = excludeWebP === true && image.mimeType === "image/webp";
|
|
37
|
+
const resized = await resizeImage(
|
|
38
|
+
Buffer.from(image.data, "base64"),
|
|
39
|
+
image.mimeType,
|
|
40
|
+
forceWebpConversion ? { maxBytes: Buffer.byteLength(image.data, "utf8") } : undefined,
|
|
41
|
+
);
|
|
42
|
+
let output: EvalImageContent =
|
|
43
|
+
resized === null ? image : { type: "image", data: resized.data, mimeType: resized.mimeType };
|
|
44
|
+
if (excludeWebP === true && output.mimeType === "image/webp") {
|
|
45
|
+
const converted = await convertToPng(output.data, output.mimeType);
|
|
46
|
+
if (converted === null)
|
|
47
|
+
throw new TypeError(`Unable to convert ${output.mimeType} display output for the active model`);
|
|
48
|
+
output = { type: "image", data: converted.data, mimeType: converted.mimeType };
|
|
49
|
+
}
|
|
50
|
+
const dimensionNote = resized === null ? undefined : formatDimensionNote(resized);
|
|
51
|
+
return { image: output, ...(dimensionNote === undefined ? {} : { dimensionNote }) };
|
|
52
|
+
};
|
package/src/tool/image.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { writeFile } from "node:fs/promises";
|
|
2
|
-
import type {
|
|
3
|
-
import { convertToPng, formatDimensionNote, resizeImage } from "@code-yeongyu/senpi";
|
|
2
|
+
import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
4
3
|
import type { KernelToHostMessage } from "../bridge/protocol.ts";
|
|
5
4
|
import type { TruncationMeta } from "../output/output-meta.ts";
|
|
6
5
|
import {
|
|
@@ -12,24 +11,24 @@ import {
|
|
|
12
11
|
TailBuffer,
|
|
13
12
|
truncateTail,
|
|
14
13
|
} from "../output/streaming-output.ts";
|
|
14
|
+
import { type EvalImageContent, type EvalImageResizer, resizeEvalImage } from "./image-resize.ts";
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
16
|
+
export {
|
|
17
|
+
type EvalImageContent,
|
|
18
|
+
type EvalImageResizeResult,
|
|
19
|
+
type EvalImageResizer,
|
|
20
|
+
resizeEvalImage,
|
|
21
|
+
webpExclusionForModel,
|
|
22
|
+
} from "./image-resize.ts";
|
|
23
|
+
export { marshalToolResult, toolResultIsError } from "./tool-result-marshal.ts";
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
readonly image: EvalImageContent;
|
|
26
|
-
readonly dimensionNote?: string;
|
|
27
|
-
}
|
|
25
|
+
const MAX_DISPLAY_TEXT_BYTES = 8_000;
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
// Per-cell display caps: display payloads are retained for the whole result lifetime, so an unbounded
|
|
28
|
+
// burst pins base64 originals plus resized copies on the session heap (#1695).
|
|
29
|
+
const MAX_DISPLAY_IMAGES_PER_CELL = 8;
|
|
30
|
+
const MAX_DISPLAY_IMAGE_BYTES_PER_CELL = 24 * 1024 * 1024;
|
|
31
|
+
const MAX_JSON_OUTPUTS_PER_CELL = 64;
|
|
33
32
|
|
|
34
33
|
export interface EvalOutputOptions {
|
|
35
34
|
readonly artifactPath?: string;
|
|
@@ -51,7 +50,6 @@ export interface EvalOutputResult {
|
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
type DisplayMessage = Extract<KernelToHostMessage, { type: "display" }>;
|
|
54
|
-
type WebpModel = { readonly provider: string; readonly api: string } | undefined;
|
|
55
53
|
|
|
56
54
|
class DisplayPayloadError extends Error {
|
|
57
55
|
readonly name = "DisplayPayloadError";
|
|
@@ -69,6 +67,9 @@ export class EvalOutputCollector {
|
|
|
69
67
|
readonly #displayImages: EvalImageContent[] = [];
|
|
70
68
|
readonly #images: EvalImageContent[] = [];
|
|
71
69
|
readonly #jsonOutputs: unknown[] = [];
|
|
70
|
+
#displayImageBytes = 0;
|
|
71
|
+
#displayImagesElided = 0;
|
|
72
|
+
#jsonOutputsElided = 0;
|
|
72
73
|
#hasMarkdown = false;
|
|
73
74
|
#imagesProcessed = false;
|
|
74
75
|
|
|
@@ -92,11 +93,23 @@ export class EvalOutputCollector {
|
|
|
92
93
|
|
|
93
94
|
display(message: DisplayMessage): void {
|
|
94
95
|
if (message.mimeType.startsWith("image/")) {
|
|
96
|
+
if (
|
|
97
|
+
this.#displayImages.length >= MAX_DISPLAY_IMAGES_PER_CELL ||
|
|
98
|
+
this.#displayImageBytes + message.dataBase64.length > MAX_DISPLAY_IMAGE_BYTES_PER_CELL
|
|
99
|
+
) {
|
|
100
|
+
this.#displayImagesElided++;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
95
103
|
this.#displayImages.push({ type: "image", mimeType: message.mimeType, data: message.dataBase64 });
|
|
104
|
+
this.#displayImageBytes += message.dataBase64.length;
|
|
96
105
|
return;
|
|
97
106
|
}
|
|
98
107
|
const text = Buffer.from(message.dataBase64, "base64").toString("utf8");
|
|
99
108
|
if (message.mimeType === "application/json") {
|
|
109
|
+
if (this.#jsonOutputs.length >= MAX_JSON_OUTPUTS_PER_CELL) {
|
|
110
|
+
this.#jsonOutputsElided++;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
100
113
|
let value: unknown;
|
|
101
114
|
try {
|
|
102
115
|
value = JSON.parse(text);
|
|
@@ -118,6 +131,12 @@ export class EvalOutputCollector {
|
|
|
118
131
|
|
|
119
132
|
async finish(): Promise<EvalOutputResult> {
|
|
120
133
|
await this.#processImages();
|
|
134
|
+
const elided = this.#displayImagesElided + this.#jsonOutputsElided;
|
|
135
|
+
if (elided > 0) {
|
|
136
|
+
this.#sink.push(
|
|
137
|
+
`[${this.#displayImagesElided} display image(s) and ${this.#jsonOutputsElided} JSON output(s) elided beyond per-cell caps]\n`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
121
140
|
const summary = await this.#finalSummary();
|
|
122
141
|
const meta = truncationMetaFromSummary(summary, this.#options.maxColumns);
|
|
123
142
|
const notice = summary.artifactId === undefined ? undefined : artifactNotice(summary.artifactId);
|
|
@@ -171,38 +190,6 @@ export class EvalOutputCollector {
|
|
|
171
190
|
}
|
|
172
191
|
}
|
|
173
192
|
|
|
174
|
-
export function webpExclusionForModel(model: WebpModel): true | undefined {
|
|
175
|
-
if (model === undefined) return undefined;
|
|
176
|
-
return model.provider === "ollama" ||
|
|
177
|
-
model.provider === "ollama-cloud" ||
|
|
178
|
-
model.provider === "llama.cpp" ||
|
|
179
|
-
model.provider === "lm-studio" ||
|
|
180
|
-
model.provider === "local-server" ||
|
|
181
|
-
model.api === "ollama-chat"
|
|
182
|
-
? true
|
|
183
|
-
: undefined;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export const resizeEvalImage: EvalImageResizer = async (image, model) => {
|
|
187
|
-
const excludeWebP = webpExclusionForModel(model);
|
|
188
|
-
const forceWebpConversion = excludeWebP === true && image.mimeType === "image/webp";
|
|
189
|
-
const resized = await resizeImage(
|
|
190
|
-
Buffer.from(image.data, "base64"),
|
|
191
|
-
image.mimeType,
|
|
192
|
-
forceWebpConversion ? { maxBytes: Buffer.byteLength(image.data, "utf8") } : undefined,
|
|
193
|
-
);
|
|
194
|
-
let output: EvalImageContent =
|
|
195
|
-
resized === null ? image : { type: "image", data: resized.data, mimeType: resized.mimeType };
|
|
196
|
-
if (excludeWebP === true && output.mimeType === "image/webp") {
|
|
197
|
-
const converted = await convertToPng(output.data, output.mimeType);
|
|
198
|
-
if (converted === null)
|
|
199
|
-
throw new TypeError(`Unable to convert ${output.mimeType} display output for the active model`);
|
|
200
|
-
output = { type: "image", data: converted.data, mimeType: converted.mimeType };
|
|
201
|
-
}
|
|
202
|
-
const dimensionNote = resized === null ? undefined : formatDimensionNote(resized);
|
|
203
|
-
return { image: output, ...(dimensionNote === undefined ? {} : { dimensionNote }) };
|
|
204
|
-
};
|
|
205
|
-
|
|
206
193
|
function formatDisplayJson(value: unknown): string {
|
|
207
194
|
let text: string;
|
|
208
195
|
try {
|
|
@@ -259,25 +246,3 @@ function truncationMetaFromSummary(summary: OutputSummary, maxColumns: number):
|
|
|
259
246
|
...artifact,
|
|
260
247
|
};
|
|
261
248
|
}
|
|
262
|
-
|
|
263
|
-
export function marshalToolResult(result: AgentToolResult<unknown>) {
|
|
264
|
-
const texts = result.content.filter((part) => part.type === "text").map((part) => part.text);
|
|
265
|
-
const images = result.content
|
|
266
|
-
.filter((part) => part.type === "image")
|
|
267
|
-
.map((part) => ({ mimeType: part.mimeType, dataBase64: part.data }));
|
|
268
|
-
const details =
|
|
269
|
-
typeof result.details === "object" &&
|
|
270
|
-
result.details !== null &&
|
|
271
|
-
!Array.isArray(result.details) &&
|
|
272
|
-
Object.keys(result.details).length === 0
|
|
273
|
-
? undefined
|
|
274
|
-
: result.details;
|
|
275
|
-
const hasError = toolResultIsError(result);
|
|
276
|
-
const text = texts.join("\n");
|
|
277
|
-
return images.length === 0 && details === undefined && !hasError ? { text } : { text, details, images, hasError };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
export function toolResultIsError(result: AgentToolResult<unknown>): boolean {
|
|
281
|
-
const details = result.details;
|
|
282
|
-
return typeof details === "object" && details !== null && "isError" in details && details.isError === true;
|
|
283
|
-
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@code-yeongyu/senpi";
|
|
2
|
+
import { type CellDeadlineExpiry, CellDeadlines } from "./cell-deadlines.ts";
|
|
3
|
+
import type { EvalDetachedCellSnapshot, EvalDetachedCellState } from "./detached-cell-contract.ts";
|
|
4
|
+
import { detachedNotificationSpillPath } from "./detached-cell-notification.ts";
|
|
5
|
+
import type { EvalKernel, EvalToolDetails, EvalToolInput } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export type LiveResultProvider = () => AgentToolResult<EvalToolDetails>;
|
|
8
|
+
|
|
9
|
+
export type ManagedCell = {
|
|
10
|
+
readonly cellId: string;
|
|
11
|
+
readonly input: EvalToolInput;
|
|
12
|
+
readonly spillPath: string | undefined;
|
|
13
|
+
readonly startedAtMs: number;
|
|
14
|
+
readonly terminal: PromiseWithResolvers<EvalDetachedCellSnapshot>;
|
|
15
|
+
state: EvalDetachedCellState;
|
|
16
|
+
canDetach: boolean;
|
|
17
|
+
wasDetached: boolean;
|
|
18
|
+
kernel: EvalKernel | undefined;
|
|
19
|
+
stateRetained: boolean | undefined;
|
|
20
|
+
interruptNote: string | undefined;
|
|
21
|
+
/** Holds the completion notification until the interrupt has reported whether kernel state survived. */
|
|
22
|
+
interruptOutcome: PromiseWithResolvers<void> | undefined;
|
|
23
|
+
liveResult: LiveResultProvider | undefined;
|
|
24
|
+
terminalResult: AgentToolResult<EvalToolDetails> | undefined;
|
|
25
|
+
notificationQueued: boolean;
|
|
26
|
+
readonly deadlines: CellDeadlines;
|
|
27
|
+
readonly hardLimitSeconds: number;
|
|
28
|
+
readonly runBudgetSeconds: number;
|
|
29
|
+
hardLimited: boolean;
|
|
30
|
+
runBudgetExhausted: boolean;
|
|
31
|
+
/** Foreground killer: the still-awaited CellExecution owns interrupting and rejecting its own call; bound from creation so a deadline firing during kernel boot still ends it. */
|
|
32
|
+
onKill: ((error: Error) => void) | undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface ManagedCellInit {
|
|
36
|
+
readonly cellId: string;
|
|
37
|
+
readonly input: EvalToolInput;
|
|
38
|
+
readonly artifactsDir: string | undefined;
|
|
39
|
+
readonly now: () => number;
|
|
40
|
+
readonly defaultHardLimitSeconds: number;
|
|
41
|
+
readonly defaultRunBudgetSeconds: number;
|
|
42
|
+
readonly onKill: ((error: Error) => void) | undefined;
|
|
43
|
+
readonly onExpire: (cellId: string, expiry: CellDeadlineExpiry) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createManagedCell(init: ManagedCellInit): ManagedCell {
|
|
47
|
+
// An explicit longer per-call timeout raises the deadline, mirroring bash keeping explicit timeouts.
|
|
48
|
+
const hardLimitSeconds = Math.max(init.defaultHardLimitSeconds, init.input.timeout ?? 0);
|
|
49
|
+
const runBudgetSeconds = init.input.timeout ?? init.defaultRunBudgetSeconds;
|
|
50
|
+
return {
|
|
51
|
+
cellId: init.cellId,
|
|
52
|
+
input: init.input,
|
|
53
|
+
spillPath: detachedNotificationSpillPath(init.artifactsDir, init.cellId),
|
|
54
|
+
startedAtMs: init.now(),
|
|
55
|
+
state: "running",
|
|
56
|
+
canDetach: false,
|
|
57
|
+
wasDetached: false,
|
|
58
|
+
kernel: undefined,
|
|
59
|
+
stateRetained: undefined,
|
|
60
|
+
interruptNote: undefined,
|
|
61
|
+
interruptOutcome: undefined,
|
|
62
|
+
liveResult: undefined,
|
|
63
|
+
terminalResult: undefined,
|
|
64
|
+
notificationQueued: false,
|
|
65
|
+
deadlines: new CellDeadlines({
|
|
66
|
+
cellId: init.cellId,
|
|
67
|
+
hardLimitSeconds,
|
|
68
|
+
runBudgetSeconds,
|
|
69
|
+
onExpire: (expiry) => init.onExpire(init.cellId, expiry),
|
|
70
|
+
}),
|
|
71
|
+
hardLimitSeconds,
|
|
72
|
+
runBudgetSeconds,
|
|
73
|
+
hardLimited: false,
|
|
74
|
+
runBudgetExhausted: false,
|
|
75
|
+
onKill: init.onKill,
|
|
76
|
+
terminal: Promise.withResolvers<EvalDetachedCellSnapshot>(),
|
|
77
|
+
};
|
|
78
|
+
}
|