@hicaru/pi-rlm 0.3.0 → 0.3.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/README.md +52 -5
- package/README.ru.md +5 -5
- package/README.zh-CN.md +5 -5
- package/package.json +1 -1
- package/src/bridge/handlers/await.ts +148 -0
- package/src/bridge/handlers/completion.ts +72 -0
- package/src/bridge/handlers/emitting.ts +104 -0
- package/src/bridge/handlers/finish.ts +45 -0
- package/src/bridge/handlers/index.ts +48 -0
- package/src/bridge/handlers/llm-query.ts +130 -0
- package/src/bridge/handlers/rlm-query.ts +227 -0
- package/src/bridge/handlers/task-registry.ts +202 -0
- package/src/bridge/handlers/types.ts +136 -0
- package/src/commands/rlm-config.ts +33 -14
- package/src/context/listing.ts +2 -2
- package/src/context/refresh.ts +141 -0
- package/src/core/engine.ts +16 -18
- package/src/core/types.ts +1 -3
- package/src/index.ts +95 -38
- package/src/mode/native-guards.ts +4 -4
- package/src/mode/subagent.ts +68 -0
- package/src/prompts/glossary.ts +71 -74
- package/src/prompts/native.ts +127 -85
- package/src/prompts/system.ts +29 -15
- package/src/sandbox/interrupts.ts +258 -68
- package/src/sandbox/protocol.ts +53 -30
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/hostio.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +15 -5
- package/src/sandbox/py/hostio.py +57 -0
- package/src/sandbox/py/retrieval.py +17 -8
- package/src/sandbox/py/tasks.py +1 -1
- package/src/sandbox/py/worker.py +109 -83
- package/src/sandbox/sandbox-manager.ts +26 -1
- package/src/sandbox/sandbox.ts +9 -2
- package/src/tool/background-tasks.ts +1 -1
- package/src/tool/repl-result.ts +2 -2
- package/src/tool/repl-tool.ts +13 -14
- package/src/ui/config-panel.ts +1 -1
- package/src/ui/intro.ts +1 -4
- package/src/ui/model-picker.ts +28 -2
- package/src/util/concurrency.ts +1 -1
- package/src/bridge/subcall-handlers.ts +0 -382
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
* The interrupt surface: what the worker can ask the host for mid-exec, and how each request is
|
|
3
3
|
* turned into a reply frame.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Canonical api_v5 kinds only: llm_query | llm_batch | rlm_query | rlm_batch | await | finish | add_context.
|
|
6
|
+
*
|
|
7
|
+
* Wire reply shapes the Python worker reduces:
|
|
8
|
+
* - single: { response: string } or { error }
|
|
9
|
+
* - batch: { responses: string[] } or { error }
|
|
10
|
+
*
|
|
11
|
+
* Host handlers may return either:
|
|
12
|
+
* - plain string / string[] (tests, sync stubs)
|
|
13
|
+
* - SpawnResult { task_id } (createSubcallHandlers) — this layer awaits to final content
|
|
7
14
|
*/
|
|
8
15
|
|
|
9
16
|
import type { WorkerInterrupt } from "./protocol.ts";
|
|
@@ -12,47 +19,40 @@ import { errorMessage, formatError } from "../util/errors.ts";
|
|
|
12
19
|
|
|
13
20
|
/** Result of a host-side pack requested by `add_context`. */
|
|
14
21
|
export interface AddContextResult {
|
|
15
|
-
readonly payload: unknown;
|
|
22
|
+
readonly payload: unknown;
|
|
16
23
|
readonly files?: number;
|
|
17
24
|
readonly chars: number;
|
|
18
25
|
readonly sourceId: string;
|
|
19
26
|
readonly pathPrefix: string;
|
|
20
|
-
/** Host already has this source — no pack, empty payload. */
|
|
21
27
|
readonly alreadyLoaded?: boolean;
|
|
22
|
-
/** Document-type files in the payload (fresh + cache hits). */
|
|
23
28
|
readonly documents?: number;
|
|
24
|
-
/** Documents freshly converted this call (cache hits excluded). */
|
|
25
29
|
readonly converted?: number;
|
|
26
|
-
/** Paths skipped during packing (model-facing). */
|
|
27
30
|
readonly skipped?: readonly { readonly path: string; readonly reason: string }[];
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
/**
|
|
31
|
-
* Per-interrupt routing context for the sub-LLM handlers.
|
|
32
|
-
*
|
|
33
|
-
* Only the four sub-call kinds can be spawned, so only they carry it; add_context is
|
|
34
|
-
* always synchronous within one exec.
|
|
35
|
-
*/
|
|
36
33
|
export interface SubcallOpts {
|
|
37
|
-
/** Started via `spawn()` — route to session-scoped state, not the current invocation. */
|
|
38
34
|
readonly detached: boolean;
|
|
39
|
-
/**
|
|
40
|
-
* `rlm_query(paths=[…])` — path prefixes narrowing the child's inherited context.
|
|
41
|
-
* Absent on every other path; `llm_query` never carries it.
|
|
42
|
-
*/
|
|
35
|
+
/** Path prefixes for rlm_query / rlm_batch child context. */
|
|
43
36
|
readonly paths?: readonly string[];
|
|
44
37
|
}
|
|
45
38
|
|
|
46
|
-
/** Handlers the bridge installs
|
|
39
|
+
/** Handlers the bridge installs — canonical names only. */
|
|
47
40
|
export interface SubLlmHandlers {
|
|
48
|
-
llmQuery(prompt: string,
|
|
49
|
-
|
|
50
|
-
rlmQuery(
|
|
51
|
-
|
|
41
|
+
llmQuery(prompt: string, depth: number, opts: SubcallOpts): Promise<unknown>;
|
|
42
|
+
llmBatch(prompts: readonly string[], depth: number, opts: SubcallOpts): Promise<unknown>;
|
|
43
|
+
rlmQuery(task: string, depth: number, opts: SubcallOpts): Promise<unknown>;
|
|
44
|
+
rlmBatch(tasks: readonly string[], depth: number, opts: SubcallOpts): Promise<unknown>;
|
|
45
|
+
awaitTask(
|
|
46
|
+
taskId: string | undefined,
|
|
47
|
+
taskIds: readonly string[] | undefined,
|
|
48
|
+
timeoutS: number | undefined,
|
|
49
|
+
depth: number,
|
|
50
|
+
opts: SubcallOpts,
|
|
51
|
+
): Promise<unknown>;
|
|
52
|
+
finishTask(summary: string, depth: number, opts: SubcallOpts): Promise<unknown>;
|
|
52
53
|
addContext(source: string, depth: number): Promise<AddContextResult>;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
/** Narrow an unknown JSON value to a frozen string array. Non-strings and blanks are dropped. */
|
|
56
56
|
function toStringArray(value: unknown): readonly string[] | undefined {
|
|
57
57
|
if (!Array.isArray(value)) return undefined;
|
|
58
58
|
const out = new Array<string>(value.length);
|
|
@@ -65,16 +65,23 @@ function toStringArray(value: unknown): readonly string[] | undefined {
|
|
|
65
65
|
return n > 0 ? Object.freeze(out) : undefined;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
const UNCONFIGURED = formatError("sub-LLM bridge not configured");
|
|
69
|
+
|
|
70
|
+
const rejectBatch = async (items: readonly string[]): Promise<readonly string[]> =>
|
|
71
|
+
Object.freeze(items.map(() => UNCONFIGURED));
|
|
72
|
+
|
|
73
|
+
export const REJECT: SubLlmHandlers = Object.freeze({
|
|
74
|
+
llmQuery: async () => UNCONFIGURED,
|
|
75
|
+
llmBatch: rejectBatch,
|
|
76
|
+
rlmQuery: async () => UNCONFIGURED,
|
|
77
|
+
rlmBatch: rejectBatch,
|
|
78
|
+
awaitTask: async () => UNCONFIGURED,
|
|
79
|
+
finishTask: async () => UNCONFIGURED,
|
|
80
|
+
addContext: async () => {
|
|
81
|
+
throw new Error("add_context not configured");
|
|
82
|
+
},
|
|
83
|
+
});
|
|
76
84
|
|
|
77
|
-
/** Body of a reply frame — the union of every handler's payload shape. */
|
|
78
85
|
export interface ReplyBody {
|
|
79
86
|
response?: string;
|
|
80
87
|
responses?: string[];
|
|
@@ -91,11 +98,138 @@ export interface ReplyBody {
|
|
|
91
98
|
error?: string;
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
const RLM_PATH_TYPES = new Set(["rlm_query", "rlm_batch"]);
|
|
102
|
+
|
|
103
|
+
/** Narrow unknown to SpawnResult-shaped object from createSubcallHandlers. */
|
|
104
|
+
function isSpawnResult(
|
|
105
|
+
value: unknown,
|
|
106
|
+
): value is {
|
|
107
|
+
readonly ok: boolean;
|
|
108
|
+
readonly task_id: string | null;
|
|
109
|
+
readonly kind: string;
|
|
110
|
+
readonly error?: string;
|
|
111
|
+
} {
|
|
112
|
+
if (typeof value !== "object" || value === null) return false;
|
|
113
|
+
const o = value as Record<string, unknown>;
|
|
114
|
+
return (
|
|
115
|
+
typeof o.ok === "boolean" &&
|
|
116
|
+
(typeof o.task_id === "string" || o.task_id === null) &&
|
|
117
|
+
typeof o.kind === "string" &&
|
|
118
|
+
typeof o.status === "string"
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Narrow unknown to AwaitResult-shaped object. */
|
|
123
|
+
function isAwaitResult(
|
|
124
|
+
value: unknown,
|
|
125
|
+
): value is {
|
|
126
|
+
readonly ok: boolean;
|
|
127
|
+
readonly result?: string;
|
|
128
|
+
readonly results?: readonly string[];
|
|
129
|
+
readonly error?: string;
|
|
130
|
+
} {
|
|
131
|
+
if (typeof value !== "object" || value === null) return false;
|
|
132
|
+
const o = value as Record<string, unknown>;
|
|
133
|
+
return typeof o.ok === "boolean" && typeof o.task_id === "string" && typeof o.status === "string";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve a handler return value to a single `response` string for the worker.
|
|
138
|
+
* Accepts plain string stubs OR SpawnResult (awaits to completion).
|
|
139
|
+
*/
|
|
140
|
+
async function resolveSingle(
|
|
141
|
+
h: SubLlmHandlers,
|
|
142
|
+
raw: unknown,
|
|
143
|
+
depth: number,
|
|
144
|
+
opts: SubcallOpts,
|
|
145
|
+
): Promise<ReplyBody> {
|
|
146
|
+
if (typeof raw === "string") {
|
|
147
|
+
return { response: raw };
|
|
148
|
+
}
|
|
149
|
+
if (isSpawnResult(raw)) {
|
|
150
|
+
if (!raw.ok || raw.task_id === null) {
|
|
151
|
+
const err = raw.error ?? "spawn failed";
|
|
152
|
+
return { error: err, response: formatError(err) };
|
|
153
|
+
}
|
|
154
|
+
const collected = await h.awaitTask(raw.task_id, undefined, undefined, depth, opts);
|
|
155
|
+
if (typeof collected === "string") {
|
|
156
|
+
return { response: collected };
|
|
157
|
+
}
|
|
158
|
+
if (isAwaitResult(collected)) {
|
|
159
|
+
if (!collected.ok && collected.error !== undefined) {
|
|
160
|
+
return { error: collected.error, response: formatError(collected.error) };
|
|
161
|
+
}
|
|
162
|
+
return { response: collected.result ?? "" };
|
|
163
|
+
}
|
|
164
|
+
return { response: String(collected ?? "") };
|
|
165
|
+
}
|
|
166
|
+
// Unexpected shape — surface as text rather than crash the worker.
|
|
167
|
+
return { response: String(raw ?? "") };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Resolve a handler return value to `responses: string[]` for the worker batch reducer.
|
|
172
|
+
*/
|
|
173
|
+
async function resolveBatch(
|
|
174
|
+
h: SubLlmHandlers,
|
|
175
|
+
raw: unknown,
|
|
176
|
+
expectedN: number,
|
|
177
|
+
depth: number,
|
|
178
|
+
opts: SubcallOpts,
|
|
179
|
+
): Promise<ReplyBody> {
|
|
180
|
+
if (Array.isArray(raw)) {
|
|
181
|
+
const responses = raw.map((x) => (typeof x === "string" ? x : String(x)));
|
|
182
|
+
return { responses };
|
|
183
|
+
}
|
|
184
|
+
if (isSpawnResult(raw)) {
|
|
185
|
+
if (!raw.ok || raw.task_id === null) {
|
|
186
|
+
const err = raw.error ?? "spawn failed";
|
|
187
|
+
const msg = formatError(err);
|
|
188
|
+
return {
|
|
189
|
+
error: err,
|
|
190
|
+
responses: Array.from({ length: Math.max(1, expectedN) }, () => msg),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const collected = await h.awaitTask(raw.task_id, undefined, undefined, depth, opts);
|
|
194
|
+
if (Array.isArray(collected)) {
|
|
195
|
+
return { responses: collected.map(String) };
|
|
196
|
+
}
|
|
197
|
+
if (isAwaitResult(collected)) {
|
|
198
|
+
if (collected.results !== undefined) {
|
|
199
|
+
return { responses: [...collected.results] };
|
|
200
|
+
}
|
|
201
|
+
if (!collected.ok && collected.error !== undefined) {
|
|
202
|
+
const msg = formatError(collected.error);
|
|
203
|
+
return {
|
|
204
|
+
error: collected.error,
|
|
205
|
+
responses: Array.from({ length: Math.max(1, expectedN) }, () => msg),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (collected.result !== undefined) {
|
|
209
|
+
return { responses: [collected.result] };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
error: "malformed batch await result",
|
|
214
|
+
responses: Array.from({ length: Math.max(1, expectedN) }, () =>
|
|
215
|
+
formatError("malformed batch await result"),
|
|
216
|
+
),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (typeof raw === "string") {
|
|
220
|
+
return { responses: [raw] };
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
error: "malformed batch handler result",
|
|
224
|
+
responses: Array.from({ length: Math.max(1, expectedN) }, () =>
|
|
225
|
+
formatError("malformed batch handler result"),
|
|
226
|
+
),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
94
230
|
/**
|
|
95
231
|
* Service one interrupt and hand the reply body to `reply`.
|
|
96
|
-
*
|
|
97
|
-
* Errors are replied, never thrown: the caller invokes this from the stdio pump, where a
|
|
98
|
-
* rejection would surface as an unhandled promise and leave the worker parked forever.
|
|
232
|
+
* Errors are replied, never thrown.
|
|
99
233
|
*/
|
|
100
234
|
export async function serviceInterrupt(
|
|
101
235
|
msg: WorkerInterrupt,
|
|
@@ -103,44 +237,93 @@ export async function serviceInterrupt(
|
|
|
103
237
|
reply: (rid: string, body: ReplyBody) => void,
|
|
104
238
|
): Promise<void> {
|
|
105
239
|
const d = msg.depth;
|
|
240
|
+
const paths =
|
|
241
|
+
"paths" in msg && RLM_PATH_TYPES.has(msg.type)
|
|
242
|
+
? toStringArray(msg.paths)
|
|
243
|
+
: undefined;
|
|
106
244
|
const opts: SubcallOpts = Object.freeze({
|
|
107
245
|
detached: msg.detached === true,
|
|
108
|
-
|
|
109
|
-
paths: msg.type === "rlm_query" || msg.type === "rlm_query_batched"
|
|
110
|
-
? toStringArray(msg.paths)
|
|
111
|
-
: undefined,
|
|
246
|
+
paths,
|
|
112
247
|
});
|
|
248
|
+
|
|
113
249
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
250
|
+
switch (msg.type) {
|
|
251
|
+
case "llm_query": {
|
|
252
|
+
const raw = await h.llmQuery(msg.prompt ?? "", d, opts);
|
|
253
|
+
reply(msg.rid, await resolveSingle(h, raw, d, opts));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
case "rlm_query": {
|
|
257
|
+
const raw = await h.rlmQuery(msg.prompt ?? "", d, opts);
|
|
258
|
+
reply(msg.rid, await resolveSingle(h, raw, d, opts));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
case "llm_batch": {
|
|
262
|
+
const prompts = msg.prompts ?? [];
|
|
263
|
+
const raw = await h.llmBatch(prompts, d, opts);
|
|
264
|
+
reply(msg.rid, await resolveBatch(h, raw, prompts.length, d, opts));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
case "rlm_batch": {
|
|
268
|
+
const tasks = msg.tasks ?? msg.prompts ?? [];
|
|
269
|
+
const raw = await h.rlmBatch(tasks, d, opts);
|
|
270
|
+
reply(msg.rid, await resolveBatch(h, raw, tasks.length, d, opts));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
case "await": {
|
|
274
|
+
// Host-level await (orchestrator tools). Worker uses Task + await_task in-process.
|
|
275
|
+
const result = await h.awaitTask(
|
|
276
|
+
msg.task_id,
|
|
277
|
+
msg.task_ids,
|
|
278
|
+
msg.timeout_s,
|
|
279
|
+
d,
|
|
280
|
+
opts,
|
|
281
|
+
);
|
|
282
|
+
if (typeof result === "string") {
|
|
283
|
+
reply(msg.rid, { response: result });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (isAwaitResult(result)) {
|
|
287
|
+
if (result.results !== undefined) {
|
|
288
|
+
reply(msg.rid, { responses: [...result.results] });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
reply(msg.rid, {
|
|
292
|
+
response: result.result ?? "",
|
|
293
|
+
error: result.ok ? undefined : result.error,
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
reply(msg.rid, { response: String(result ?? "") });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
case "finish": {
|
|
301
|
+
const result = await h.finishTask(msg.summary ?? "", d, opts);
|
|
302
|
+
// finish is not reduced by the worker as content — stringify is fine
|
|
130
303
|
reply(msg.rid, {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
path_prefix: lib.pathPrefix,
|
|
136
|
-
documents: lib.documents ?? 0,
|
|
137
|
-
converted: lib.converted ?? 0,
|
|
138
|
-
skipped: lib.skipped,
|
|
304
|
+
response:
|
|
305
|
+
typeof result === "string"
|
|
306
|
+
? result
|
|
307
|
+
: JSON.stringify(result ?? { ok: true, finished: true }),
|
|
139
308
|
});
|
|
140
|
-
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
case "add_context": {
|
|
312
|
+
const lib = await h.addContext(msg.source ?? "", d);
|
|
313
|
+
if (lib.alreadyLoaded) {
|
|
314
|
+
reply(msg.rid, {
|
|
315
|
+
already_loaded: true,
|
|
316
|
+
files: 0,
|
|
317
|
+
chars: lib.chars,
|
|
318
|
+
source_id: lib.sourceId,
|
|
319
|
+
path_prefix: lib.pathPrefix,
|
|
320
|
+
documents: lib.documents ?? 0,
|
|
321
|
+
converted: lib.converted ?? 0,
|
|
322
|
+
skipped: lib.skipped,
|
|
323
|
+
});
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
141
326
|
const { path, json: isJson } = await writeContextTempFile(lib.payload);
|
|
142
|
-
// Worker reads then unlinks (worker._add_context). Host must not unlink here —
|
|
143
|
-
// if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
|
|
144
327
|
reply(msg.rid, {
|
|
145
328
|
path,
|
|
146
329
|
json: isJson,
|
|
@@ -152,9 +335,16 @@ export async function serviceInterrupt(
|
|
|
152
335
|
converted: lib.converted ?? 0,
|
|
153
336
|
skipped: lib.skipped,
|
|
154
337
|
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
default: {
|
|
341
|
+
const _exhaustive: never = msg;
|
|
342
|
+
reply((_exhaustive as WorkerInterrupt).rid, {
|
|
343
|
+
error: "unknown interrupt type",
|
|
344
|
+
});
|
|
155
345
|
}
|
|
156
346
|
}
|
|
157
|
-
} catch (err) {
|
|
347
|
+
} catch (err: unknown) {
|
|
158
348
|
reply(msg.rid, { error: errorMessage(err) });
|
|
159
349
|
}
|
|
160
350
|
}
|
package/src/sandbox/protocol.ts
CHANGED
|
@@ -4,12 +4,20 @@
|
|
|
4
4
|
* Newline-delimited JSON over the worker's stdin/stdout — no sockets, no HTTP.
|
|
5
5
|
* Parent -> worker: requests (exec/load_context/shutdown) and llm replies.
|
|
6
6
|
* Worker -> parent: request responses and mid-exec sub-LLM interrupts.
|
|
7
|
+
*
|
|
8
|
+
* Canonical api_v5 kinds only — no legacy `*_query_batched` wire names.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
/** Requests the parent sends to the worker. */
|
|
10
12
|
export type WorkerRequest =
|
|
11
13
|
| { readonly id: string; readonly type: "exec"; readonly code: string }
|
|
12
|
-
| {
|
|
14
|
+
| {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly type: "load_context";
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly index?: number;
|
|
19
|
+
readonly json: boolean;
|
|
20
|
+
}
|
|
13
21
|
| { readonly id: string; readonly type: "shutdown" };
|
|
14
22
|
|
|
15
23
|
/** Reply the parent sends to satisfy a sub-LLM interrupt. */
|
|
@@ -50,27 +58,27 @@ export interface WorkerResponse {
|
|
|
50
58
|
readonly answer_content?: string;
|
|
51
59
|
readonly raised?: boolean;
|
|
52
60
|
readonly execution_time?: number;
|
|
53
|
-
// user-created variable names after this exec
|
|
61
|
+
// user-created variable names after this exec
|
|
54
62
|
readonly var_names?: readonly string[];
|
|
55
63
|
// load_context:
|
|
56
64
|
readonly index?: number;
|
|
57
65
|
}
|
|
58
66
|
|
|
59
|
-
/**
|
|
67
|
+
/** Canonical interrupt kinds (api_v5). */
|
|
60
68
|
export type InterruptKind =
|
|
61
69
|
| "llm_query"
|
|
62
|
-
| "llm_query_batched"
|
|
63
70
|
| "rlm_query"
|
|
64
|
-
| "
|
|
71
|
+
| "llm_batch"
|
|
72
|
+
| "rlm_batch"
|
|
73
|
+
| "await"
|
|
74
|
+
| "finish"
|
|
65
75
|
| "add_context";
|
|
66
76
|
|
|
67
77
|
interface InterruptBase {
|
|
68
78
|
readonly rid: string;
|
|
69
79
|
readonly depth: number;
|
|
70
80
|
/**
|
|
71
|
-
*
|
|
72
|
-
* must not attach it to that invocation's emitter or LimitGuard. Absent on the
|
|
73
|
-
* synchronous path.
|
|
81
|
+
* Detached work may outlive the exec that issued it.
|
|
74
82
|
*/
|
|
75
83
|
readonly detached?: boolean;
|
|
76
84
|
}
|
|
@@ -78,22 +86,29 @@ interface InterruptBase {
|
|
|
78
86
|
interface PromptInterrupt extends InterruptBase {
|
|
79
87
|
readonly type: "llm_query" | "rlm_query";
|
|
80
88
|
readonly prompt?: string;
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* `rlm_query` only — path prefixes narrowing the child's inherited context. Never sent for
|
|
84
|
-
* `llm_query`, whose frame stays byte-identical to before.
|
|
85
|
-
*/
|
|
89
|
+
/** `rlm_query` only — path prefixes narrowing the child's inherited context. */
|
|
86
90
|
readonly paths?: readonly string[];
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
interface
|
|
90
|
-
readonly type: "
|
|
93
|
+
interface BatchInterrupt extends InterruptBase {
|
|
94
|
+
readonly type: "llm_batch" | "rlm_batch";
|
|
91
95
|
readonly prompts?: readonly string[];
|
|
92
|
-
readonly
|
|
93
|
-
/** `rlm_query_batched` only — one prefix set shared by every prompt in the batch. */
|
|
96
|
+
readonly tasks?: readonly string[];
|
|
94
97
|
readonly paths?: readonly string[];
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
interface AwaitInterrupt extends InterruptBase {
|
|
101
|
+
readonly type: "await";
|
|
102
|
+
readonly task_id?: string;
|
|
103
|
+
readonly task_ids?: readonly string[];
|
|
104
|
+
readonly timeout_s?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface FinishInterrupt extends InterruptBase {
|
|
108
|
+
readonly type: "finish";
|
|
109
|
+
readonly summary?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
97
112
|
export interface AddContextInterrupt extends InterruptBase {
|
|
98
113
|
readonly type: "add_context";
|
|
99
114
|
readonly source?: string;
|
|
@@ -102,18 +117,24 @@ export interface AddContextInterrupt extends InterruptBase {
|
|
|
102
117
|
/** A mid-exec sub-LLM/tool request from the worker. */
|
|
103
118
|
export type WorkerInterrupt =
|
|
104
119
|
| PromptInterrupt
|
|
105
|
-
|
|
|
120
|
+
| BatchInterrupt
|
|
121
|
+
| AwaitInterrupt
|
|
122
|
+
| FinishInterrupt
|
|
106
123
|
| AddContextInterrupt;
|
|
107
124
|
|
|
108
125
|
export type WorkerMessage = WorkerResponse | WorkerInterrupt;
|
|
109
126
|
|
|
110
|
-
export const INTERRUPT_KINDS = Object.freeze(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
127
|
+
export const INTERRUPT_KINDS = Object.freeze(
|
|
128
|
+
new Set<InterruptKind>([
|
|
129
|
+
"llm_query",
|
|
130
|
+
"rlm_query",
|
|
131
|
+
"llm_batch",
|
|
132
|
+
"rlm_batch",
|
|
133
|
+
"await",
|
|
134
|
+
"finish",
|
|
135
|
+
"add_context",
|
|
136
|
+
]),
|
|
137
|
+
);
|
|
117
138
|
|
|
118
139
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
119
140
|
return typeof value === "object" && value !== null;
|
|
@@ -124,11 +145,13 @@ function isWorkerResponse(value: unknown): value is WorkerResponse {
|
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
export function isInterrupt(msg: unknown): msg is WorkerInterrupt {
|
|
127
|
-
return
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
return (
|
|
149
|
+
isRecord(msg) &&
|
|
150
|
+
typeof msg.type === "string" &&
|
|
151
|
+
INTERRUPT_KINDS.has(msg.type as InterruptKind) &&
|
|
152
|
+
typeof msg.rid === "string" &&
|
|
153
|
+
typeof msg.depth === "number"
|
|
154
|
+
);
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
export function isWorkerMessage(msg: unknown): msg is WorkerMessage {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/sandbox/py/guards.py
CHANGED
|
@@ -19,6 +19,13 @@ import sys
|
|
|
19
19
|
from contextlib import contextmanager
|
|
20
20
|
from typing import Any
|
|
21
21
|
|
|
22
|
+
from hostio import pin_stdio_utf8
|
|
23
|
+
|
|
24
|
+
# Pin UTF-8 before anything reads or writes a frame. On Windows these three default to the
|
|
25
|
+
# locale encoding (cp1252), while the JSONL protocol Node speaks is UTF-8 both ways — see
|
|
26
|
+
# issue #7. reconfigure() mutates in place, so the REAL_* captures below are the same objects.
|
|
27
|
+
pin_stdio_utf8()
|
|
28
|
+
|
|
22
29
|
# Capture the REAL stdio before exec() redirects sys.stdout/sys.stderr into buffers.
|
|
23
30
|
# All protocol writes must go to the real stdout even while user code's prints are captured.
|
|
24
31
|
REAL_STDOUT = sys.stdout
|
|
@@ -81,10 +88,13 @@ for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
|
|
|
81
88
|
|
|
82
89
|
RESERVED = frozenset(
|
|
83
90
|
{
|
|
84
|
-
|
|
85
|
-
"
|
|
86
|
-
"
|
|
87
|
-
"
|
|
91
|
+
# Canonical api_v5
|
|
92
|
+
"llm_query", "llm_batch",
|
|
93
|
+
"rlm_query", "rlm_batch",
|
|
94
|
+
"await_task", "finish",
|
|
95
|
+
"spawn",
|
|
96
|
+
# Helpers (not the old *_query_batched API)
|
|
97
|
+
"llm_query_chunked", "map_files", "llm_map_reduce",
|
|
88
98
|
"search", "grep_context", "outline",
|
|
89
99
|
"add_context",
|
|
90
100
|
"SHOW_VARS", "answer", "context",
|
|
@@ -119,7 +129,7 @@ def _stall_alarm(exec_timeout_s: float, stall_timeout_s: float):
|
|
|
119
129
|
def _fire(signum, frame): # noqa: ARG001
|
|
120
130
|
raise _StallTimeout(
|
|
121
131
|
f"sub-call stalled — no reply from the host for {stall_timeout_s:g}s "
|
|
122
|
-
"(the task may still be running;
|
|
132
|
+
"(the task may still be running; await_task it again in a later block)"
|
|
123
133
|
)
|
|
124
134
|
|
|
125
135
|
old = signal.signal(signal.SIGALRM, _fire) if use else None
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Host↔worker transport, pinned to UTF-8.
|
|
2
|
+
|
|
3
|
+
Every byte the host hands this worker is UTF-8: the context temp files come from Node's
|
|
4
|
+
FileHandle.write(string) (default utf8, see sandbox/context-file.ts) and the JSONL frames come
|
|
5
|
+
off a pipe Node writes the same way. Python does NOT match that — text I/O defaults to the
|
|
6
|
+
locale encoding, which is cp1252 on a Western Windows install. That mismatch is issue #7: a
|
|
7
|
+
packed repo holding any non-ASCII byte raised UnicodeDecodeError before the REPL ever ran.
|
|
8
|
+
|
|
9
|
+
Encoding is therefore never left to the locale here. Interpreter-wide UTF-8 mode (-X utf8=1,
|
|
10
|
+
set by the host in sandbox.ts) covers model-written open(); these two functions cover the
|
|
11
|
+
scaffold's own I/O, and they win outright — PYTHONIOENCODING overrides -X utf8=1, and
|
|
12
|
+
reconfigure() overrides PYTHONIOENCODING.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
ENCODING = "utf-8"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_host_payload(path: str, is_json: bool) -> Any:
|
|
26
|
+
"""Read a payload the host serialized to a temp file.
|
|
27
|
+
|
|
28
|
+
Strict on decode errors by design. The host always writes UTF-8, so a failure here is a
|
|
29
|
+
transport bug, not bad input — and errors="replace" would hand the model silently
|
|
30
|
+
mojibake'd source code instead of surfacing the problem.
|
|
31
|
+
|
|
32
|
+
The explicit encoding= is intentionally redundant with -X utf8=1 under a normal spawn:
|
|
33
|
+
UTF-8 mode already makes the default encoding UTF-8. It still matters so a standalone
|
|
34
|
+
import of this module (or a worker started without -X utf8=1) cannot silently reintroduce
|
|
35
|
+
issue #7. phase-encoding.ts check 6 (AST / EncodingWarning) is the only automated guard
|
|
36
|
+
that encoding= is present — do not drop either side of that net.
|
|
37
|
+
"""
|
|
38
|
+
with io.open(path, "r", encoding=ENCODING) as f:
|
|
39
|
+
return json.load(f) if is_json else f.read()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pin_stdio_utf8() -> None:
|
|
43
|
+
"""Force the three real streams to UTF-8, whatever the locale or PYTHONIOENCODING says.
|
|
44
|
+
|
|
45
|
+
The write side uses surrogatepass, not strict: model code can synthesize a lone surrogate
|
|
46
|
+
(a bad decode, a truncated pair), and json.dumps(ensure_ascii=False) would then raise
|
|
47
|
+
inside _send — killing the worker on a request it had already completed. The read side
|
|
48
|
+
uses replace so a corrupt frame comes back as a "bad json" error response instead of an
|
|
49
|
+
exception that escapes main()'s loop and takes the worker down (fail-soft I/O, AGENTS.md).
|
|
50
|
+
"""
|
|
51
|
+
for stream, errors in (
|
|
52
|
+
(sys.stdin, "replace"),
|
|
53
|
+
(sys.stdout, "surrogatepass"),
|
|
54
|
+
(sys.stderr, "surrogatepass"),
|
|
55
|
+
):
|
|
56
|
+
if isinstance(stream, io.TextIOWrapper): # not a TextIOWrapper under some test harnesses
|
|
57
|
+
stream.reconfigure(encoding=ENCODING, errors=errors)
|