@hicaru/pi-rlm 0.3.1 → 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 (44) hide show
  1. package/README.md +34 -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 +54 -42
  20. package/src/mode/native-guards.ts +4 -4
  21. package/src/mode/subagent.ts +1 -1
  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 +8 -5
  32. package/src/sandbox/py/retrieval.py +17 -8
  33. package/src/sandbox/py/tasks.py +1 -1
  34. package/src/sandbox/py/worker.py +106 -79
  35. package/src/sandbox/sandbox-manager.ts +26 -1
  36. package/src/sandbox/sandbox.ts +1 -1
  37. package/src/tool/background-tasks.ts +1 -1
  38. package/src/tool/repl-result.ts +2 -2
  39. package/src/tool/repl-tool.ts +13 -14
  40. package/src/ui/config-panel.ts +1 -1
  41. package/src/ui/intro.ts +1 -4
  42. package/src/ui/model-picker.ts +28 -2
  43. package/src/util/concurrency.ts +1 -1
  44. 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 {
@@ -88,10 +88,13 @@ for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
88
88
 
89
89
  RESERVED = frozenset(
90
90
  {
91
- "llm_query", "llm_query_batched", "llm_query_chunked",
92
- "rlm_query", "rlm_query_batched",
93
- "spawn", "rlm_await", "rlm_await_all",
94
- "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",
95
98
  "search", "grep_context", "outline",
96
99
  "add_context",
97
100
  "SHOW_VARS", "answer", "context",
@@ -126,7 +129,7 @@ def _stall_alarm(exec_timeout_s: float, stall_timeout_s: float):
126
129
  def _fire(signum, frame): # noqa: ARG001
127
130
  raise _StallTimeout(
128
131
  f"sub-call stalled — no reply from the host for {stall_timeout_s:g}s "
129
- "(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)"
130
133
  )
131
134
 
132
135
  old = signal.signal(signal.SIGALRM, _fire) if use else None
@@ -15,7 +15,7 @@ from typing import Any
15
15
 
16
16
 
17
17
  _CHUNK_HEADER_OVERHEAD = 64
18
- _MAX_CHUNK_BATCH = 20 # fan-out per llm_query_batched call (matches prompt guidance)
18
+ _MAX_CHUNK_BATCH = 20 # fan-out per llm_batch call (matches prompt guidance)
19
19
  _MAX_CHUNKS = 500 # ceiling: above this, force pre-filtering in Python
20
20
  _NUDGE_CHARS = 500_000 # str/bytes vars above this trigger a one-time stdout hint
21
21
 
@@ -167,11 +167,14 @@ class _Bm25Index:
167
167
  out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
168
168
  for i, (idx, score) in enumerate(top):
169
169
  text = self.texts[idx]
170
+ snip = text[:_SNIPPET_CHARS]
171
+ # Both `snippet` and `text` so agents never KeyError mixing search vs grep shapes.
170
172
  out[i] = {
171
173
  "path": self.paths[idx],
172
174
  "line": self.starts[idx],
173
175
  "score": round(score, 3),
174
- "snippet": text[:_SNIPPET_CHARS],
176
+ "snippet": snip,
177
+ "text": snip,
175
178
  }
176
179
  return out
177
180
 
@@ -179,8 +182,8 @@ class _Bm25Index:
179
182
  def search(entries: list[tuple[str, str]], index: _Bm25Index, query: str, k: int = 10, path_glob: str | None = None) -> list[dict[str, Any]]:
180
183
  """Rank `context` windows against a natural-language query (BM25).
181
184
 
182
- Returns [{path, line, score, snippet}] — pointers, not bodies. Follow up by slicing the
183
- named files out of `context` and delegating them to llm_query / map_files.
185
+ Returns [{path, line, score, snippet, text}] — pointers, not bodies.
186
+ `text` is an alias of `snippet` (same as grep_context hits).
184
187
  """
185
188
  terms = _tokenize(str(query))
186
189
  if not terms:
@@ -201,9 +204,9 @@ def grep_context(
201
204
  ) -> dict[str, Any]:
202
205
  """Regex over `context`, capped and shaped.
203
206
 
204
- Returns {"hits": [{path, line, text}], "counts": {path: n}, "total": n, "truncated": bool}.
205
- `counts` is complete even when `hits` is capped, so a wide pattern reports its shape
206
- instead of flooding stdout.
207
+ Returns {"hits": [{path, line, text, snippet}], "counts": {path: n}, "total": n, "truncated": bool}.
208
+ `snippet` is an alias of `text` (same as search hits) to avoid KeyError footguns.
209
+ `counts` is complete even when `hits` is capped.
207
210
  """
208
211
  try:
209
212
  rx = re.compile(pattern)
@@ -234,7 +237,13 @@ def grep_context(
234
237
  continue
235
238
  lo = max(0, i - pad_before)
236
239
  hi = min(len(lines), i + pad_after + 1)
237
- hits.append({"path": path, "line": i + 1, "text": "\n".join(lines[lo:hi])[:_SNIPPET_CHARS]})
240
+ body = "\n".join(lines[lo:hi])[:_SNIPPET_CHARS]
241
+ hits.append({
242
+ "path": path,
243
+ "line": i + 1,
244
+ "text": body,
245
+ "snippet": body,
246
+ })
238
247
  return {"hits": hits, "counts": counts, "total": total, "truncated": total > len(hits)}
239
248
 
240
249
  def outline(entries: list[tuple[str, str]], path: str) -> str:
@@ -42,7 +42,7 @@ def _reduce_batch(n: int):
42
42
 
43
43
 
44
44
  def _reduce_chunked(sizes: list[int], drop_empty: bool = True):
45
- """Concatenate several llm_query_batched replies back into one flat chunk list.
45
+ """Concatenate several llm_batch replies back into one flat chunk list.
46
46
 
47
47
  drop_empty=True (llm_query_chunked): filter "" replies so results never degrade
48
48
  to blank entries; the flattened list may then be shorter than the chunk count,