@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.
Files changed (45) hide show
  1. package/README.md +52 -5
  2. package/README.ru.md +5 -5
  3. package/README.zh-CN.md +5 -5
  4. package/package.json +1 -1
  5. package/src/bridge/handlers/await.ts +148 -0
  6. package/src/bridge/handlers/completion.ts +72 -0
  7. package/src/bridge/handlers/emitting.ts +104 -0
  8. package/src/bridge/handlers/finish.ts +45 -0
  9. package/src/bridge/handlers/index.ts +48 -0
  10. package/src/bridge/handlers/llm-query.ts +130 -0
  11. package/src/bridge/handlers/rlm-query.ts +227 -0
  12. package/src/bridge/handlers/task-registry.ts +202 -0
  13. package/src/bridge/handlers/types.ts +136 -0
  14. package/src/commands/rlm-config.ts +33 -14
  15. package/src/context/listing.ts +2 -2
  16. package/src/context/refresh.ts +141 -0
  17. package/src/core/engine.ts +16 -18
  18. package/src/core/types.ts +1 -3
  19. package/src/index.ts +95 -38
  20. package/src/mode/native-guards.ts +4 -4
  21. package/src/mode/subagent.ts +68 -0
  22. package/src/prompts/glossary.ts +71 -74
  23. package/src/prompts/native.ts +127 -85
  24. package/src/prompts/system.ts +29 -15
  25. package/src/sandbox/interrupts.ts +258 -68
  26. package/src/sandbox/protocol.ts +53 -30
  27. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  28. package/src/sandbox/py/__pycache__/hostio.cpython-314.pyc +0 -0
  29. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  30. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  31. package/src/sandbox/py/guards.py +15 -5
  32. package/src/sandbox/py/hostio.py +57 -0
  33. package/src/sandbox/py/retrieval.py +17 -8
  34. package/src/sandbox/py/tasks.py +1 -1
  35. package/src/sandbox/py/worker.py +109 -83
  36. package/src/sandbox/sandbox-manager.ts +26 -1
  37. package/src/sandbox/sandbox.ts +9 -2
  38. package/src/tool/background-tasks.ts +1 -1
  39. package/src/tool/repl-result.ts +2 -2
  40. package/src/tool/repl-tool.ts +13 -14
  41. package/src/ui/config-panel.ts +1 -1
  42. package/src/ui/intro.ts +1 -4
  43. package/src/ui/model-picker.ts +28 -2
  44. package/src/util/concurrency.ts +1 -1
  45. 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
- * Split from sandbox.ts, which owns the subprocess and the JSONL pump. Adding a sandbox function
6
- * touches this file and worker.py; the transport underneath does not change.
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; // always ContextFile[] under ctx/<id>/ (or un-prefixed for cwd)
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 to service sub-LLM interrupts. Return the reply payload. */
39
+ /** Handlers the bridge installs canonical names only. */
47
40
  export interface SubLlmHandlers {
48
- llmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
49
- llmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
50
- rlmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
51
- rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
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
- /** Default handlers every sandbox function refuses until a bridge installs a real one. */
69
- export const REJECT: SubLlmHandlers = {
70
- llmQuery: async () => formatError("sub-LLM bridge not configured"),
71
- llmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
72
- rlmQuery: async () => formatError("sub-LLM bridge not configured"),
73
- rlmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
74
- addContext: async () => { throw new Error("add_context not configured"); },
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
- // Only the recursive kinds carry a context slice; the value crossed JSON, so guard it.
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
- if (msg.type === "llm_query") {
115
- const response = await h.llmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
116
- reply(msg.rid, { response });
117
- } else if (msg.type === "rlm_query") {
118
- const response = await h.rlmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
119
- reply(msg.rid, { response });
120
- } else if (msg.type === "llm_query_batched") {
121
- const responses = await h.llmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
122
- reply(msg.rid, { responses });
123
- } else if (msg.type === "rlm_query_batched") {
124
- const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
125
- reply(msg.rid, { responses });
126
- } else if (msg.type === "add_context") {
127
- const lib = await h.addContext(msg.source ?? "", d);
128
- if (lib.alreadyLoaded) {
129
- // No temp file — worker short-circuits on already_loaded.
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
- already_loaded: true,
132
- files: 0,
133
- chars: lib.chars,
134
- source_id: lib.sourceId,
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
- } else {
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
  }
@@ -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
- | { readonly id: string; readonly type: "load_context"; readonly path: string; readonly index?: number; readonly json: boolean }
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 (filters builtins/context) — Metadata(stdout) for history orientation
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
- /** Kinds of sub-LLM interrupt the worker can raise mid-exec. */
67
+ /** Canonical interrupt kinds (api_v5). */
60
68
  export type InterruptKind =
61
69
  | "llm_query"
62
- | "llm_query_batched"
63
70
  | "rlm_query"
64
- | "rlm_query_batched"
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
- * Started via `spawn()`: the request may outlive the `exec` that issued it, so the host
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
- readonly model?: string | null;
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 BatchedPromptInterrupt extends InterruptBase {
90
- readonly type: "llm_query_batched" | "rlm_query_batched";
93
+ interface BatchInterrupt extends InterruptBase {
94
+ readonly type: "llm_batch" | "rlm_batch";
91
95
  readonly prompts?: readonly string[];
92
- readonly model?: string | null;
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
- | BatchedPromptInterrupt
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(new Set<InterruptKind>([
111
- "llm_query",
112
- "llm_query_batched",
113
- "rlm_query",
114
- "rlm_query_batched",
115
- "add_context",
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 isRecord(msg)
128
- && typeof msg.type === "string"
129
- && INTERRUPT_KINDS.has(msg.type as InterruptKind)
130
- && typeof msg.rid === "string"
131
- && typeof msg.depth === "number";
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 {
@@ -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
- "llm_query", "llm_query_batched", "llm_query_chunked",
85
- "rlm_query", "rlm_query_batched",
86
- "spawn", "rlm_await", "rlm_await_all",
87
- "map_files", "llm_map_reduce",
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; rlm_await it again in a later block)"
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)