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