@code-yeongyu/senpi-codemode 2026.7.31-2 → 2026.8.1
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 +18 -0
- package/README.md +6 -3
- package/package.json +3 -3
- package/src/tool/cell-execution.ts +146 -0
- package/src/tool/cell-handler.ts +21 -119
- package/src/tool/cell-runtime.ts +157 -0
- package/src/tool/detached-cell-manager.ts +55 -210
- package/src/tool/detached-cell-notification.ts +98 -0
- package/src/tool/detached-cell-snapshot.ts +81 -0
- package/src/tool/detached-cell-state.ts +24 -0
- package/src/tool/detached-eval-result.ts +140 -0
- package/src/tool/detached-notification-queue.ts +53 -0
- package/src/tool/eval-request.ts +45 -0
- package/src/tool/eval-tool-options.ts +45 -0
- package/src/tool/eval-tool.ts +20 -342
|
@@ -1,27 +1,32 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
1
|
import type { AgentToolResult } from "@code-yeongyu/senpi";
|
|
2
|
+
import { detachedNotificationSpillPath } from "./detached-cell-notification.ts";
|
|
3
|
+
import { currentDetachedResult, detachedErrorResult, snapshotDetachedCell } from "./detached-cell-snapshot.ts";
|
|
4
|
+
import {
|
|
5
|
+
activeDetachedCellReuseError,
|
|
6
|
+
allowsDetachedCellTransition,
|
|
7
|
+
detachedCellIsActive,
|
|
8
|
+
} from "./detached-cell-state.ts";
|
|
9
|
+
import { DetachedNotificationQueue } from "./detached-notification-queue.ts";
|
|
4
10
|
import type { EvalKernel, EvalLanguage, EvalToolDetails, EvalToolInput } from "./types.ts";
|
|
5
11
|
|
|
6
|
-
const NOTIFICATION_TAIL_BYTES = 512;
|
|
7
|
-
|
|
8
12
|
export type EvalDetachedCellState = "running" | "detached" | "completed" | "failed" | "cancelled";
|
|
9
13
|
|
|
14
|
+
type LiveResultProvider = () => AgentToolResult<EvalToolDetails>;
|
|
15
|
+
|
|
10
16
|
type ManagedCell = {
|
|
11
17
|
readonly cellId: string;
|
|
12
18
|
readonly input: EvalToolInput;
|
|
13
19
|
readonly spillPath: string | undefined;
|
|
14
20
|
readonly startedAtMs: number;
|
|
21
|
+
readonly terminal: PromiseWithResolvers<EvalDetachedCellSnapshot>;
|
|
15
22
|
state: EvalDetachedCellState;
|
|
16
23
|
canDetach: boolean;
|
|
17
24
|
wasDetached: boolean;
|
|
18
25
|
kernel: EvalKernel | undefined;
|
|
19
|
-
/** Set after an interrupt-driven stop; undefined until the kernel reports its fate. */
|
|
20
26
|
stateRetained: boolean | undefined;
|
|
21
|
-
|
|
22
|
-
|
|
27
|
+
liveResult: LiveResultProvider | undefined;
|
|
28
|
+
terminalResult: AgentToolResult<EvalToolDetails> | undefined;
|
|
23
29
|
notificationQueued: boolean;
|
|
24
|
-
readonly terminal: PromiseWithResolvers<EvalDetachedCellSnapshot>;
|
|
25
30
|
};
|
|
26
31
|
|
|
27
32
|
export interface EvalDetachedCellSnapshot {
|
|
@@ -29,7 +34,7 @@ export interface EvalDetachedCellSnapshot {
|
|
|
29
34
|
readonly language: EvalLanguage;
|
|
30
35
|
readonly state: EvalDetachedCellState;
|
|
31
36
|
readonly outputTail: string;
|
|
32
|
-
readonly result: AgentToolResult<EvalToolDetails
|
|
37
|
+
readonly result: AgentToolResult<EvalToolDetails>;
|
|
33
38
|
readonly stateRetained: boolean | undefined;
|
|
34
39
|
}
|
|
35
40
|
|
|
@@ -42,70 +47,53 @@ export interface EvalDetachedCellNotifier {
|
|
|
42
47
|
notify(cells: readonly EvalDetachedCellNotification[]): void;
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
/** One live detached cell, as shown in the footer status line. */
|
|
46
50
|
export interface EvalDetachedCellStatusEntry {
|
|
47
51
|
readonly cellId: string;
|
|
48
52
|
readonly language: EvalLanguage;
|
|
49
53
|
readonly title?: string;
|
|
50
|
-
/** Epoch milliseconds when the cell was created; feeds the footer's live elapsed label. */
|
|
51
54
|
readonly startedAtMs: number;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
export interface EvalDetachedCellManagerOptions {
|
|
55
58
|
readonly artifactsDir?: string;
|
|
56
59
|
readonly notifier?: EvalDetachedCellNotifier;
|
|
57
|
-
/** Called with every detached cell whenever that set changes; empty clears the status. */
|
|
58
60
|
readonly onStatusChange?: (entries: readonly EvalDetachedCellStatusEntry[]) => void;
|
|
59
|
-
/** Injectable clock for tests; defaults to Date.now. */
|
|
60
61
|
readonly now?: () => number;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
/**
|
|
64
|
-
* Session-owned lifecycle owner for eval cells that outlive their tool call.
|
|
65
|
-
*
|
|
66
|
-
* All state changes are synchronous and funnel through #transition. That is the
|
|
67
|
-
* atomic boundary for timeout, kernel completion, explicit stop, and session
|
|
68
|
-
* disposal races; exactly one path can release a language's detached busy mark.
|
|
69
|
-
*/
|
|
70
64
|
export class EvalDetachedCellManager {
|
|
71
65
|
readonly #artifactsDir: string | undefined;
|
|
72
|
-
readonly #notifier: EvalDetachedCellNotifier | undefined;
|
|
73
66
|
readonly #onStatusChange: ((entries: readonly EvalDetachedCellStatusEntry[]) => void) | undefined;
|
|
74
67
|
readonly #cells = new Map<string, ManagedCell>();
|
|
75
68
|
readonly #detachedByLanguage = new Map<EvalLanguage, ManagedCell>();
|
|
69
|
+
readonly #notificationQueue: DetachedNotificationQueue;
|
|
76
70
|
readonly #now: () => number;
|
|
77
|
-
#notificationQueue: ManagedCell[] = [];
|
|
78
|
-
#notificationFlush: Promise<void> | undefined;
|
|
79
71
|
|
|
80
72
|
constructor(options: EvalDetachedCellManagerOptions = {}) {
|
|
81
73
|
this.#artifactsDir = options.artifactsDir;
|
|
82
|
-
this.#notifier = options.notifier;
|
|
83
74
|
this.#onStatusChange = options.onStatusChange;
|
|
75
|
+
this.#notificationQueue = new DetachedNotificationQueue(options.notifier, options.artifactsDir);
|
|
84
76
|
this.#now = options.now ?? Date.now;
|
|
85
77
|
}
|
|
86
78
|
|
|
87
79
|
create(cellId: string, input: EvalToolInput): ManagedCell {
|
|
88
80
|
const existing = this.#cells.get(cellId);
|
|
89
81
|
if (existing !== undefined) {
|
|
90
|
-
if (existing.state
|
|
82
|
+
if (detachedCellIsActive(existing.state)) throw activeDetachedCellReuseError(existing);
|
|
91
83
|
this.#cells.delete(cellId);
|
|
92
84
|
}
|
|
93
|
-
const spillPath =
|
|
94
|
-
this.#artifactsDir === undefined
|
|
95
|
-
? undefined
|
|
96
|
-
: join(this.#artifactsDir, "local", `detached-eval-${safeCellId(cellId)}.log`);
|
|
97
85
|
const cell: ManagedCell = {
|
|
98
86
|
cellId,
|
|
99
87
|
input,
|
|
100
|
-
spillPath,
|
|
88
|
+
spillPath: detachedNotificationSpillPath(this.#artifactsDir, cellId),
|
|
101
89
|
startedAtMs: this.#now(),
|
|
102
90
|
state: "running",
|
|
103
91
|
canDetach: false,
|
|
104
92
|
wasDetached: false,
|
|
105
93
|
kernel: undefined,
|
|
106
94
|
stateRetained: undefined,
|
|
107
|
-
|
|
108
|
-
|
|
95
|
+
liveResult: undefined,
|
|
96
|
+
terminalResult: undefined,
|
|
109
97
|
notificationQueued: false,
|
|
110
98
|
terminal: Promise.withResolvers<EvalDetachedCellSnapshot>(),
|
|
111
99
|
};
|
|
@@ -113,15 +101,16 @@ export class EvalDetachedCellManager {
|
|
|
113
101
|
return cell;
|
|
114
102
|
}
|
|
115
103
|
|
|
116
|
-
markRunning(cell: ManagedCell, kernel: EvalKernel,
|
|
104
|
+
markRunning(cell: ManagedCell, kernel: EvalKernel, liveResult: LiveResultProvider): void {
|
|
117
105
|
if (cell.state !== "running") return;
|
|
118
106
|
cell.kernel = kernel;
|
|
119
|
-
cell.
|
|
107
|
+
cell.liveResult = liveResult;
|
|
120
108
|
cell.canDetach = true;
|
|
121
109
|
}
|
|
122
110
|
|
|
123
111
|
detach(cell: ManagedCell): boolean {
|
|
124
|
-
if (!cell.canDetach || !
|
|
112
|
+
if (!cell.canDetach || !allowsDetachedCellTransition(cell.state, "detached")) return false;
|
|
113
|
+
cell.state = "detached";
|
|
125
114
|
cell.wasDetached = true;
|
|
126
115
|
this.#detachedByLanguage.set(cell.input.language, cell);
|
|
127
116
|
this.#emitStatus();
|
|
@@ -129,22 +118,19 @@ export class EvalDetachedCellManager {
|
|
|
129
118
|
}
|
|
130
119
|
|
|
131
120
|
complete(cell: ManagedCell, result: AgentToolResult<EvalToolDetails>): boolean {
|
|
132
|
-
cell.
|
|
133
|
-
return this.#transition(cell, result.details.isError === true ? "failed" : "completed");
|
|
121
|
+
return this.#settle(cell, result.details.isError === true ? "failed" : "completed", result);
|
|
134
122
|
}
|
|
135
123
|
|
|
136
124
|
fail(cell: ManagedCell, error: Error): boolean {
|
|
137
|
-
|
|
138
|
-
cell.result = result;
|
|
139
|
-
return this.#transition(cell, "failed");
|
|
125
|
+
return this.#settle(cell, "failed", detachedErrorResult(cell, error));
|
|
140
126
|
}
|
|
141
127
|
|
|
142
128
|
async stop(cellId: string, reason = "Stopped detached eval cell"): Promise<EvalDetachedCellSnapshot> {
|
|
143
129
|
const cell = this.#get(cellId);
|
|
144
|
-
if (cell.state === "detached"
|
|
145
|
-
const
|
|
146
|
-
if (kernel !== undefined) {
|
|
147
|
-
const handle = await kernel.interrupt(reason);
|
|
130
|
+
if (cell.state === "detached") {
|
|
131
|
+
const claimed = this.#settle(cell, "cancelled", currentDetachedResult(cell));
|
|
132
|
+
if (claimed && cell.kernel !== undefined) {
|
|
133
|
+
const handle = await cell.kernel.interrupt(reason);
|
|
148
134
|
cell.stateRetained = await handle.stateRetained;
|
|
149
135
|
}
|
|
150
136
|
}
|
|
@@ -169,31 +155,40 @@ export class EvalDetachedCellManager {
|
|
|
169
155
|
await Promise.allSettled(
|
|
170
156
|
detached.map(async (cell) => await this.stop(cell.cellId, "Session ended; detached eval cell cancelled")),
|
|
171
157
|
);
|
|
172
|
-
await this.
|
|
158
|
+
await this.#notificationQueue.flush();
|
|
173
159
|
}
|
|
174
160
|
|
|
175
161
|
async flushNotifications(): Promise<void> {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (cell.
|
|
162
|
+
await this.#notificationQueue.flush();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#settle(
|
|
166
|
+
cell: ManagedCell,
|
|
167
|
+
state: "completed" | "failed" | "cancelled",
|
|
168
|
+
result: AgentToolResult<EvalToolDetails>,
|
|
169
|
+
): boolean {
|
|
170
|
+
if (!allowsDetachedCellTransition(cell.state, state)) return false;
|
|
171
|
+
cell.state = state;
|
|
172
|
+
cell.terminalResult = result;
|
|
173
|
+
cell.liveResult = undefined;
|
|
174
|
+
cell.terminal.resolve(this.#snapshot(cell));
|
|
175
|
+
if (cell.wasDetached) {
|
|
185
176
|
if (this.#detachedByLanguage.get(cell.input.language) === cell)
|
|
186
177
|
this.#detachedByLanguage.delete(cell.input.language);
|
|
187
178
|
this.#emitStatus();
|
|
188
|
-
|
|
179
|
+
if (!cell.notificationQueued) {
|
|
180
|
+
cell.notificationQueued = true;
|
|
181
|
+
this.#notificationQueue.enqueue({
|
|
182
|
+
snapshot: () => this.#snapshot(cell),
|
|
183
|
+
spillPath: cell.spillPath,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
189
186
|
}
|
|
190
187
|
return true;
|
|
191
188
|
}
|
|
192
189
|
|
|
193
190
|
#emitStatus(): void {
|
|
194
|
-
|
|
195
|
-
if (emit === undefined) return;
|
|
196
|
-
emit(
|
|
191
|
+
this.#onStatusChange?.(
|
|
197
192
|
[...this.#detachedByLanguage.values()].map((cell) => ({
|
|
198
193
|
cellId: cell.cellId,
|
|
199
194
|
language: cell.input.language,
|
|
@@ -203,52 +198,8 @@ export class EvalDetachedCellManager {
|
|
|
203
198
|
);
|
|
204
199
|
}
|
|
205
200
|
|
|
206
|
-
#
|
|
207
|
-
|
|
208
|
-
cell.notificationQueued = true;
|
|
209
|
-
this.#notificationQueue.push(cell);
|
|
210
|
-
this.#scheduleNotificationFlush();
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
#scheduleNotificationFlush(): void {
|
|
214
|
-
if (this.#notificationFlush !== undefined) return;
|
|
215
|
-
const flush = Promise.resolve().then(async () => {
|
|
216
|
-
const cells = this.#notificationQueue.splice(0);
|
|
217
|
-
const notifications = await Promise.all(cells.map(async (candidate) => await this.#notification(candidate)));
|
|
218
|
-
this.#notifier?.notify(notifications);
|
|
219
|
-
});
|
|
220
|
-
this.#notificationFlush = flush;
|
|
221
|
-
void flush.then(
|
|
222
|
-
() => this.#finishNotificationFlush(flush),
|
|
223
|
-
() => this.#finishNotificationFlush(flush),
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
#finishNotificationFlush(flush: Promise<void>): void {
|
|
228
|
-
if (this.#notificationFlush !== flush) return;
|
|
229
|
-
this.#notificationFlush = undefined;
|
|
230
|
-
if (this.#notificationQueue.length > 0) this.#scheduleNotificationFlush();
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async #notification(cell: ManagedCell): Promise<EvalDetachedCellNotification> {
|
|
234
|
-
const snapshot = this.#snapshot(cell);
|
|
235
|
-
const body = notificationBody(snapshot);
|
|
236
|
-
const overflow = Buffer.byteLength(body, "utf8") > NOTIFICATION_TAIL_BYTES;
|
|
237
|
-
let spillNotice = "";
|
|
238
|
-
if (overflow && cell.spillPath !== undefined) {
|
|
239
|
-
try {
|
|
240
|
-
await mkdir(dirname(cell.spillPath), { recursive: true });
|
|
241
|
-
await writeFile(cell.spillPath, body, "utf8");
|
|
242
|
-
spillNotice = `\nBuffered output overflowed; full output: ${localUri(cell.spillPath, this.#artifactsDir)}`;
|
|
243
|
-
} catch (error) {
|
|
244
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
245
|
-
spillNotice = `\nBuffered output overflow could not be spilled: ${message}`;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
return {
|
|
249
|
-
cellId: cell.cellId,
|
|
250
|
-
content: `${overflow ? notificationPreview(snapshot) : body}${overflow ? "\n[…notification tail capped…]" : ""}${spillNotice}`,
|
|
251
|
-
};
|
|
201
|
+
#snapshot(cell: ManagedCell): EvalDetachedCellSnapshot {
|
|
202
|
+
return snapshotDetachedCell(cell, this.#now());
|
|
252
203
|
}
|
|
253
204
|
|
|
254
205
|
#get(cellId: string): ManagedCell {
|
|
@@ -256,110 +207,4 @@ export class EvalDetachedCellManager {
|
|
|
256
207
|
if (cell === undefined) throw new Error(`Unknown detached eval cell "${cellId}"`);
|
|
257
208
|
return cell;
|
|
258
209
|
}
|
|
259
|
-
|
|
260
|
-
#snapshot(cell: ManagedCell): EvalDetachedCellSnapshot {
|
|
261
|
-
return {
|
|
262
|
-
cellId: cell.cellId,
|
|
263
|
-
language: cell.input.language,
|
|
264
|
-
state: cell.state,
|
|
265
|
-
outputTail: cell.outputTail?.() ?? "",
|
|
266
|
-
result: cell.result,
|
|
267
|
-
stateRetained: cell.stateRetained,
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function activeCellReuseError(cell: ManagedCell): Error {
|
|
273
|
-
return new Error(
|
|
274
|
-
`Eval cell ${cell.cellId} from a previous call is still ${cell.state} in the ${cell.input.language} kernel. Use eval({ action: "peek", cell_id: "${cell.cellId}" }) to read it or eval({ action: "stop", cell_id: "${cell.cellId}" }) to end it before its id can be reused.`,
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function allowsTransition(from: EvalDetachedCellState, to: EvalDetachedCellState): boolean {
|
|
279
|
-
if (from === "running") return to === "detached" || to === "completed" || to === "failed" || to === "cancelled";
|
|
280
|
-
if (from === "detached") return to === "completed" || to === "failed" || to === "cancelled";
|
|
281
|
-
return false;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function errorResult(cell: ManagedCell, error: Error): AgentToolResult<EvalToolDetails> {
|
|
285
|
-
const output = cell.outputTail?.() ?? "";
|
|
286
|
-
return {
|
|
287
|
-
content: [{ type: "text", text: output.length > 0 ? `${output}\n${error.message}` : error.message }],
|
|
288
|
-
details: {
|
|
289
|
-
language: cell.input.language,
|
|
290
|
-
languages: [cell.input.language],
|
|
291
|
-
durationMs: 0,
|
|
292
|
-
toolCalls: [],
|
|
293
|
-
truncated: false,
|
|
294
|
-
isError: true,
|
|
295
|
-
cells: [
|
|
296
|
-
{
|
|
297
|
-
index: 0,
|
|
298
|
-
code: cell.input.code,
|
|
299
|
-
language: cell.input.language,
|
|
300
|
-
output,
|
|
301
|
-
status: "error",
|
|
302
|
-
},
|
|
303
|
-
],
|
|
304
|
-
},
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function notificationBody(cell: EvalDetachedCellSnapshot): string {
|
|
309
|
-
const outcome = cell.state === "completed" ? "completed" : cell.state === "cancelled" ? "cancelled" : "failed";
|
|
310
|
-
const resultText =
|
|
311
|
-
cell.result?.content
|
|
312
|
-
.filter((part) => part.type === "text")
|
|
313
|
-
.map((part) => part.text)
|
|
314
|
-
.join("\n") ?? cell.outputTail;
|
|
315
|
-
const stateNote =
|
|
316
|
-
cell.state === "cancelled" && cell.language === "js"
|
|
317
|
-
? "JavaScript worker was restarted; VM state was lost."
|
|
318
|
-
: cell.state === "cancelled" && cell.language === "py"
|
|
319
|
-
? "Python kernel was interrupted; its existing variables are preserved."
|
|
320
|
-
: "Kernel state updated - variables are available to the next eval cell.";
|
|
321
|
-
return [
|
|
322
|
-
`<system-reminder>Detached eval cell ${cell.cellId} (${cell.language}) ${outcome}.`,
|
|
323
|
-
resultText.length === 0 ? "(no output)" : resultText,
|
|
324
|
-
`${stateNote}</system-reminder>`,
|
|
325
|
-
].join("\n");
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function notificationPreview(cell: EvalDetachedCellSnapshot): string {
|
|
329
|
-
const outcome = cell.state === "completed" ? "completed" : cell.state === "cancelled" ? "cancelled" : "failed";
|
|
330
|
-
const resultText =
|
|
331
|
-
cell.result?.content
|
|
332
|
-
.filter((part) => part.type === "text")
|
|
333
|
-
.map((part) => part.text)
|
|
334
|
-
.join("\n") ?? cell.outputTail;
|
|
335
|
-
const stateNote =
|
|
336
|
-
cell.state === "cancelled" && cell.language === "js"
|
|
337
|
-
? "JavaScript worker was restarted; VM state was lost."
|
|
338
|
-
: cell.state === "cancelled" && cell.language === "py"
|
|
339
|
-
? "Python kernel was interrupted; its existing variables are preserved."
|
|
340
|
-
: "Kernel state updated - variables are available to the next eval cell.";
|
|
341
|
-
return [
|
|
342
|
-
`<system-reminder>Detached eval cell ${cell.cellId} (${cell.language}) ${outcome}.`,
|
|
343
|
-
"Buffered output tail:",
|
|
344
|
-
truncateTailUtf8(resultText, NOTIFICATION_TAIL_BYTES),
|
|
345
|
-
`${stateNote}</system-reminder>`,
|
|
346
|
-
].join("\n");
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function safeCellId(cellId: string): string {
|
|
350
|
-
return cellId.replace(/[^a-zA-Z0-9_-]/gu, "_");
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function localUri(path: string, artifactsDir: string | undefined): string {
|
|
354
|
-
if (artifactsDir === undefined) return `local://${path}`;
|
|
355
|
-
const root = join(artifactsDir, "local");
|
|
356
|
-
return path.startsWith(`${root}/`) ? `local://${path.slice(root.length + 1)}` : `local://${path}`;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function truncateTailUtf8(text: string, maxBytes: number): string {
|
|
360
|
-
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
361
|
-
const bytes = Buffer.from(text, "utf8");
|
|
362
|
-
let start = bytes.length - maxBytes;
|
|
363
|
-
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
|
|
364
|
-
return bytes.subarray(start).toString("utf8");
|
|
365
210
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type { EvalDetachedCellNotification, EvalDetachedCellSnapshot } from "./detached-cell-manager.ts";
|
|
4
|
+
|
|
5
|
+
const NOTIFICATION_TAIL_BYTES = 512;
|
|
6
|
+
|
|
7
|
+
export function detachedNotificationSpillPath(artifactsDir: string | undefined, cellId: string): string | undefined {
|
|
8
|
+
if (artifactsDir === undefined) return undefined;
|
|
9
|
+
return join(artifactsDir, "local", `detached-eval-${safeCellId(cellId)}.log`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function buildDetachedCellNotification(
|
|
13
|
+
snapshot: EvalDetachedCellSnapshot,
|
|
14
|
+
spillPath: string | undefined,
|
|
15
|
+
artifactsDir: string | undefined,
|
|
16
|
+
): Promise<EvalDetachedCellNotification> {
|
|
17
|
+
const body = notificationBody(snapshot);
|
|
18
|
+
const overflow = Buffer.byteLength(body, "utf8") > NOTIFICATION_TAIL_BYTES;
|
|
19
|
+
let spillNotice = "";
|
|
20
|
+
if (overflow && spillPath !== undefined) {
|
|
21
|
+
try {
|
|
22
|
+
await mkdir(dirname(spillPath), { recursive: true });
|
|
23
|
+
await writeFile(spillPath, body, "utf8");
|
|
24
|
+
spillNotice = `\nBuffered output overflowed; full output: ${localUri(spillPath, artifactsDir)}`;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
+
spillNotice = `\nBuffered output overflow could not be spilled: ${message}`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
cellId: snapshot.cellId,
|
|
32
|
+
content: `${overflow ? notificationPreview(snapshot) : body}${overflow ? "\n[…notification tail capped…]" : ""}${spillNotice}`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function notificationBody(cell: EvalDetachedCellSnapshot): string {
|
|
37
|
+
const outcome = outcomeOf(cell);
|
|
38
|
+
const resultText = textContent(cell);
|
|
39
|
+
const stateNote = stateNoteOf(cell);
|
|
40
|
+
return [
|
|
41
|
+
`<system-reminder>Detached eval cell ${cell.cellId} (${cell.language}) ${outcome}.`,
|
|
42
|
+
resultText.length === 0 ? "(no output)" : resultText,
|
|
43
|
+
`${stateNote}</system-reminder>`,
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function notificationPreview(cell: EvalDetachedCellSnapshot): string {
|
|
48
|
+
const outcome = outcomeOf(cell);
|
|
49
|
+
const resultText = textContent(cell);
|
|
50
|
+
const stateNote = stateNoteOf(cell);
|
|
51
|
+
return [
|
|
52
|
+
`<system-reminder>Detached eval cell ${cell.cellId} (${cell.language}) ${outcome}.`,
|
|
53
|
+
"Buffered output tail:",
|
|
54
|
+
truncateTailUtf8(resultText, NOTIFICATION_TAIL_BYTES),
|
|
55
|
+
`${stateNote}</system-reminder>`,
|
|
56
|
+
].join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function textContent(cell: EvalDetachedCellSnapshot): string {
|
|
60
|
+
return (
|
|
61
|
+
cell.result.content
|
|
62
|
+
.filter((part) => part.type === "text")
|
|
63
|
+
.map((part) => part.text)
|
|
64
|
+
.join("\n") || cell.outputTail
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function outcomeOf(cell: EvalDetachedCellSnapshot): string {
|
|
69
|
+
if (cell.state === "completed") return "completed";
|
|
70
|
+
if (cell.state === "cancelled") return "cancelled";
|
|
71
|
+
return "failed";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function stateNoteOf(cell: EvalDetachedCellSnapshot): string {
|
|
75
|
+
if (cell.state === "cancelled" && cell.language === "js")
|
|
76
|
+
return "JavaScript worker was restarted; VM state was lost.";
|
|
77
|
+
if (cell.state === "cancelled" && cell.language === "py")
|
|
78
|
+
return "Python kernel was interrupted; its existing variables are preserved.";
|
|
79
|
+
return "Kernel state updated - variables are available to the next eval cell.";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function safeCellId(cellId: string): string {
|
|
83
|
+
return cellId.replace(/[^a-zA-Z0-9_-]/gu, "_");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function localUri(path: string, artifactsDir: string | undefined): string {
|
|
87
|
+
if (artifactsDir === undefined) return `local://${path}`;
|
|
88
|
+
const root = join(artifactsDir, "local");
|
|
89
|
+
return path.startsWith(`${root}/`) ? `local://${path.slice(root.length + 1)}` : `local://${path}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function truncateTailUtf8(text: string, maxBytes: number): string {
|
|
93
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
94
|
+
const bytes = Buffer.from(text, "utf8");
|
|
95
|
+
let start = bytes.length - maxBytes;
|
|
96
|
+
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
|
|
97
|
+
return bytes.subarray(start).toString("utf8");
|
|
98
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@code-yeongyu/senpi";
|
|
2
|
+
import type { EvalDetachedCellSnapshot, EvalDetachedCellState } from "./detached-cell-manager.ts";
|
|
3
|
+
import { resultForDetachedState } from "./detached-eval-result.ts";
|
|
4
|
+
import type { EvalKernel, EvalToolDetails, EvalToolInput } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export interface DetachedCellResultSource {
|
|
7
|
+
readonly cellId: string;
|
|
8
|
+
readonly input: EvalToolInput;
|
|
9
|
+
readonly startedAtMs: number;
|
|
10
|
+
state: EvalDetachedCellState;
|
|
11
|
+
kernel: EvalKernel | undefined;
|
|
12
|
+
stateRetained: boolean | undefined;
|
|
13
|
+
liveResult: (() => AgentToolResult<EvalToolDetails>) | undefined;
|
|
14
|
+
terminalResult: AgentToolResult<EvalToolDetails> | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function snapshotDetachedCell(cell: DetachedCellResultSource, nowMs: number): EvalDetachedCellSnapshot {
|
|
18
|
+
const durationMs = Math.max(0, nowMs - cell.startedAtMs);
|
|
19
|
+
const result = resultForDetachedState(currentDetachedResult(cell), cell.state, durationMs);
|
|
20
|
+
return {
|
|
21
|
+
cellId: cell.cellId,
|
|
22
|
+
language: cell.input.language,
|
|
23
|
+
state: cell.state,
|
|
24
|
+
outputTail: detachedOutputTail(result),
|
|
25
|
+
result,
|
|
26
|
+
stateRetained: cell.stateRetained,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function currentDetachedResult(cell: DetachedCellResultSource): AgentToolResult<EvalToolDetails> {
|
|
31
|
+
return cell.terminalResult ?? cell.liveResult?.() ?? fallbackResult(cell.input);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function detachedErrorResult(cell: DetachedCellResultSource, error: Error): AgentToolResult<EvalToolDetails> {
|
|
35
|
+
const current = currentDetachedResult(cell);
|
|
36
|
+
const output = detachedOutputTail(current);
|
|
37
|
+
return {
|
|
38
|
+
content: [
|
|
39
|
+
{
|
|
40
|
+
type: "text",
|
|
41
|
+
text: output.length > 0 ? `${output}\n${error.message}` : error.message,
|
|
42
|
+
},
|
|
43
|
+
...current.content.filter((part) => part.type === "image"),
|
|
44
|
+
],
|
|
45
|
+
details: { ...current.details, isError: true },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fallbackResult(input: EvalToolInput): AgentToolResult<EvalToolDetails> {
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: "text", text: "(no output)" }],
|
|
52
|
+
details: {
|
|
53
|
+
language: input.language,
|
|
54
|
+
languages: [input.language],
|
|
55
|
+
...(input.title === undefined ? {} : { title: input.title }),
|
|
56
|
+
durationMs: 0,
|
|
57
|
+
toolCalls: [],
|
|
58
|
+
truncated: false,
|
|
59
|
+
cells: [
|
|
60
|
+
{
|
|
61
|
+
index: 0,
|
|
62
|
+
...(input.title === undefined ? {} : { title: input.title }),
|
|
63
|
+
code: input.code,
|
|
64
|
+
language: input.language,
|
|
65
|
+
output: "",
|
|
66
|
+
status: "running",
|
|
67
|
+
durationMs: 0,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function detachedOutputTail(result: AgentToolResult<EvalToolDetails>): string {
|
|
75
|
+
const cellOutput = result.details.cells?.[0]?.output;
|
|
76
|
+
if (cellOutput !== undefined) return cellOutput;
|
|
77
|
+
return result.content
|
|
78
|
+
.filter((part) => part.type === "text")
|
|
79
|
+
.map((part) => part.text)
|
|
80
|
+
.join("\n");
|
|
81
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { EvalDetachedCellState } from "./detached-cell-manager.ts";
|
|
2
|
+
import type { EvalToolInput } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
interface DetachedCellIdentity {
|
|
5
|
+
readonly cellId: string;
|
|
6
|
+
readonly input: EvalToolInput;
|
|
7
|
+
readonly state: EvalDetachedCellState;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function activeDetachedCellReuseError(cell: DetachedCellIdentity): Error {
|
|
11
|
+
return new Error(
|
|
12
|
+
`Eval cell ${cell.cellId} from a previous call is still ${cell.state} in the ${cell.input.language} kernel. Use eval({ action: "peek", cell_id: "${cell.cellId}" }) to read it or eval({ action: "stop", cell_id: "${cell.cellId}" }) to end it before its id can be reused.`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function allowsDetachedCellTransition(from: EvalDetachedCellState, to: EvalDetachedCellState): boolean {
|
|
17
|
+
if (from === "running") return to === "detached" || to === "completed" || to === "failed" || to === "cancelled";
|
|
18
|
+
if (from === "detached") return to === "completed" || to === "failed" || to === "cancelled";
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function detachedCellIsActive(state: EvalDetachedCellState): boolean {
|
|
23
|
+
return state === "running" || state === "detached";
|
|
24
|
+
}
|