@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
package/README.md CHANGED
@@ -110,15 +110,19 @@ These functions are injected into the model's Python namespace inside the REPL:
110
110
  | Function | Signature | Description |
111
111
  |---|---|---|
112
112
  | `context` | `list[dict]` | Loaded files as `[{"path","content","tokens"}, …]` — starts empty; cwd seeds on first `repl()` |
113
- | `llm_query` | `(prompt, model=None) -> str` | One-shot sub-LLM call (worker model) |
114
- | `llm_query_batched` | `(prompts, model=None) -> list[str]` | Concurrent sub-LLM calls (pool-bounded) |
115
- | `llm_query_chunked` | `(text, prompt, model=None) -> list[str]` | Split large text into cap-sized chunks and fan out via sub-LLMs |
116
- | `rlm_query` | `(prompt, model=None, paths=None) -> str` | Recursive child RLM with its own sandbox (depth-capped). Inherits your `context`; `paths` narrows it by prefix |
117
- | `rlm_query_batched` | `(prompts, model=None, paths=None) -> list[str]` | Concurrent recursive child RLMs, sharing one `paths` slice |
113
+ | `llm_query` | `(prompt) -> Task` | Always spawn one sub-LLM; `await_task` `str` |
114
+ | `llm_batch` | `(prompts) -> Task` | Always spawn many sub-LLMs in parallel; `await_task` → `list[str]` |
115
+ | `llm_query_chunked` | `(text, prompt) -> Task` | Always spawn; split large text into cap-sized chunks; `await_task` `list[str]` |
116
+ | `map_files` | `(files, prompt) -> Task` | Always spawn; ask `prompt` of many files; `await_task` `dict[path, answer]` |
117
+ | `rlm_query` | `(prompt, paths=None) -> Task` | Always spawn recursive child RLM; `await_task` report `str` |
118
+ | `rlm_batch` | `(prompts, paths=None) -> Task` | Always spawn many child RLMs; `await_task` → `list[str]` |
119
+ | `await_task` | `(Task \| list[Task])` | Collect result(s) from always-spawn tools |
118
120
  | `add_context` | `(source) -> dict \| str` | Append a dir, file, document, or git URL into `context` under `ctx/<id>/` |
119
121
  | `SHOW_VARS` | `() -> str` | List currently defined variables & their types |
120
122
  | `answer` | `dict` | Set `answer["content"]=...; answer["ready"]=True` to finalize |
121
123
 
124
+ Fan-out posts run **detached** (BG / ↯bg): fire several Tasks, do free `search`/`grep`, then `await_task([...])`.
125
+
122
126
  ### Adding context
123
127
 
124
128
  `context` starts empty. The working directory seeds automatically on the first `repl()` call
@@ -166,6 +170,49 @@ inherited by every child spawned afterwards.
166
170
  > each holds a full Python process and its own copy of the inherited context. Error and
167
171
  > wall-clock caps (above) still bound a runaway tree.
168
172
 
173
+ ## Prompt Architecture
174
+
175
+ The system prompt is structured around a **contract / routing / examples / rules** pattern
176
+ (api_v5, modeled on the best-performing arm from the RLM paper bake-off):
177
+
178
+ | Section | Purpose |
179
+ |---|---|
180
+ | `<contract>` | Hard invariant: every heavy call returns a `Task`, never the answer. Only `await_task` returns content. |
181
+ | `<routing>` | Decision tree: which tool for which job. Includes negative guidance ("NOT for") so the model knows when NOT to pick a tool. |
182
+ | `<examples>` | Concrete E1–E7 patterns: good decompositions (rlm_batch for parallel studies, map_files for one-shot extracts) alongside anti-patterns with WHY each fails. |
183
+ | `<rules>` | Standing orders: locate-then-delegate, memoize into `answers`, cap concurrent workers, author edits yourself. |
184
+
185
+ Key design decisions (v0.3.2):
186
+
187
+ - **Thinking rule:** the prompt tells the model *when* to plan out loud (complex decomposition,
188
+ uncertain targets) vs. when to jump straight to `repl()` (known paths, cheap lookups).
189
+ - **Depth visibility:** child RLMs see `Recursion depth: N` in their system prompt and
190
+ calibrate ambition — they delegate only when their assigned task itself decomposes.
191
+ - **Children are sandboxed:** the prompt explicitly states children cannot mutate the parent's
192
+ `answers`, `plan`, or REPL variables. Inheritance is one-way (read-only context).
193
+ - **Root tasks wrapped in `<task>` XML tags** so the model cleanly separates user intent from
194
+ system instructions.
195
+ - **`answer["ready"]` nudge:** runs that never finalize are wasted — the prompt reinforces
196
+ the contract with an explicit "You MUST flip" directive.
197
+
198
+ ## Subagents and environment
199
+
200
+ RLM never confiscates native file tools (`read` / `grep` / bash readers) unless `repl` is in
201
+ the **active** tool set — the paper's trade is all-or-nothing. Process-boundary subagents that
202
+ spawn pi with a `--tools` allowlist without `repl` therefore keep ordinary file access.
203
+
204
+ Optional env conventions (for packages that want an explicit full bypass):
205
+
206
+ | Env | Meaning |
207
+ |---|---|
208
+ | `PI_SUBAGENT_CHILD=1` | Full RLM bypass in this process (no tools / hooks / flags). |
209
+ | `PI_RLM_FORCE_IN_SUBAGENT=1` | Experimental: opt a child back into RLM. **Consumed on activate** (not inherited after). Refused when `PI_RLM_DEPTH >= maxDepth`. |
210
+ | `PI_RLM_DEPTH` | Cross-process depth counter (default `0`). Bumped when force-in activates. |
211
+
212
+ In-process recursion (`rlm_query`) still uses `maxDepth` from `/rlm-config` and is unrelated to
213
+ these env vars. Set `RLM_TRACE_FILE` to a path for JSONL traces of bypass / force / block-skip
214
+ decisions.
215
+
169
216
  ## Security
170
217
 
171
218
  - **Key isolation**: provider keys live only in TypeScript (`AuthStorage`); the sandbox
package/README.ru.md CHANGED
@@ -104,11 +104,11 @@ rm -rf ~/.pi/agent/extensions/rlm
104
104
  | Функция | Сигнатура | Описание |
105
105
  |---|---|---|
106
106
  | `context` | `list[dict]` | Репозиторий, упакованный как `[{"path","content","tokens"}, ...]` — вся кодовая база |
107
- | `llm_query` | `(prompt, model=None) -> str` | Одноразовый вызов sub-LLM (worker-модель) |
108
- | `llm_query_batched` | `(prompts, model=None) -> list[str]` | Параллельные вызовы sub-LLM (с ограничением пула) |
109
- | `llm_query_chunked` | `(text, prompt, model=None) -> list[str]` | Дробит большой текст на части по лимиту и обрабатывает через sub-LLM |
110
- | `rlm_query` | `(prompt, model=None, paths=None) -> str` | Рекурсивный дочерний RLM со своей песочницей (с ограничением глубины). Наследует ваш `context`; `paths` сужает его по префиксу |
111
- | `rlm_query_batched` | `(prompts, model=None, paths=None) -> list[str]` | Параллельные рекурсивные дочерние RLM с общим срезом `paths` |
107
+ | `llm_query` | `(prompt) -> str` | Одноразовый вызов sub-LLM (настроенная RLM LLM) |
108
+ | `llm_query_batched` | `(prompts) -> list[str]` | Параллельные вызовы sub-LLM (с ограничением пула) |
109
+ | `llm_query_chunked` | `(text, prompt) -> list[str]` | Дробит большой текст на части по лимиту и обрабатывает через sub-LLM |
110
+ | `rlm_query` | `(prompt, paths=None) -> str` | Рекурсивный дочерний RLM со своей песочницей (с ограничением глубины). Наследует ваш `context`; `paths` сужает его по префиксу |
111
+ | `rlm_query_batched` | `(prompts, paths=None) -> list[str]` | Параллельные рекурсивные дочерние RLM с общим срезом `paths` |
112
112
  | `ask_user_question` | `(questions) -> list[dict]` | Задать пользователю структурированные вопросы (только на глубине 0) |
113
113
  | `SHOW_VARS` | `() -> str` | Список текущих переменных и их типов |
114
114
  | `answer` | `dict` | Установите `answer["content"]=...; answer["ready"]=True` для завершения |
package/README.zh-CN.md CHANGED
@@ -110,11 +110,11 @@ rm -rf ~/.pi/agent/extensions/rlm
110
110
  | 函数 | 签名 | 描述 |
111
111
  |---|---|---|
112
112
  | `context` | `list[dict]` | 打包为 `[{"path","content","tokens"}, ...]` 的仓库 —— 完整的代码库 |
113
- | `llm_query` | `(prompt, model=None) -> str` | 单次子 LLM 调用 (worker 模型) |
114
- | `llm_query_batched` | `(prompts, model=None) -> list[str]` | 并发子 LLM 调用 (池上限) |
115
- | `llm_query_chunked` | `(text, prompt, model=None) -> list[str]` | 将大文本拆分为不超过上限的块并通过子 LLM 处理 |
116
- | `rlm_query` | `(prompt, model=None, paths=None) -> str` | 具有自有沙箱的递归子 RLM (设有深度限制)。继承父级的 `context`;`paths` 按前缀缩小范围 |
117
- | `rlm_query_batched` | `(prompts, model=None, paths=None) -> list[str]` | 并发递归子 RLM,共享同一个 `paths` 切片 |
113
+ | `llm_query` | `(prompt) -> str` | 单次子 LLM 调用(配置的 RLM LLM) |
114
+ | `llm_query_batched` | `(prompts) -> list[str]` | 并发子 LLM 调用 (池上限) |
115
+ | `llm_query_chunked` | `(text, prompt) -> list[str]` | 将大文本拆分为不超过上限的块并通过子 LLM 处理 |
116
+ | `rlm_query` | `(prompt, paths=None) -> str` | 具有自有沙箱的递归子 RLM (设有深度限制)。继承父级的 `context`;`paths` 按前缀缩小范围 |
117
+ | `rlm_query_batched` | `(prompts, paths=None) -> list[str]` | 并发递归子 RLM,共享同一个 `paths` 切片 |
118
118
  | `ask_user_question` | `(questions) -> list[dict]` | 向用户提出结构化问题 (仅限深度 0) |
119
119
  | `SHOW_VARS` | `() -> str` | 列出当前定义的变量及其类型 |
120
120
  | `answer` | `dict` | 设置 `answer["content"]=...; answer["ready"]=True` 以结束 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hicaru/pi-rlm",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "author": "hicaru",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,148 @@
1
+ /**
2
+ * await handler — collect results from background tasks.
3
+ *
4
+ * Supports single-task await (await_task(task_id="...")) and multi-task await
5
+ * (await_task(task_ids=[...])). Returns AwaitResult with the collected result(s)
6
+ * in input order for batches.
7
+ */
8
+
9
+ import { errorMessage, formatError } from "../../util/errors.ts";
10
+ import type { AwaitResult, SubcallHandlerDeps, TaskEntry } from "./types.ts";
11
+ import type { SubcallOpts } from "../../sandbox/interrupts.ts";
12
+ import type { AwaitDeps } from "./task-registry.ts";
13
+
14
+ export function createAwaitHandler(_deps: SubcallHandlerDeps, ad: AwaitDeps) {
15
+ return async (
16
+ taskId: string | undefined,
17
+ taskIds: readonly string[] | undefined,
18
+ timeoutS: number | undefined,
19
+ _depth: number,
20
+ _opts: SubcallOpts,
21
+ ): Promise<AwaitResult> => {
22
+ const timeoutMs = timeoutS !== undefined ? timeoutS * 1000 : undefined;
23
+
24
+ // Single task
25
+ if (taskId !== undefined && (taskIds === undefined || taskIds.length === 0)) {
26
+ const entry = ad.get(taskId);
27
+ if (entry === undefined) {
28
+ return {
29
+ ok: false,
30
+ task_id: taskId,
31
+ kind: "unknown",
32
+ status: "error",
33
+ error: `Task ${taskId} not found — was it already awaited or never spawned?`,
34
+ };
35
+ }
36
+
37
+ if (entry.status === "pending") {
38
+ try {
39
+ const resolved = await ad.wait(taskId, timeoutMs);
40
+ return toAwaitResult(resolved);
41
+ } catch (err: unknown) {
42
+ return {
43
+ ok: false,
44
+ task_id: taskId,
45
+ kind: entry.kind,
46
+ status: "error",
47
+ error: formatError(errorMessage(err)),
48
+ };
49
+ }
50
+ }
51
+
52
+ return toAwaitResult(entry);
53
+ }
54
+
55
+ // Multiple tasks
56
+ const ids = taskIds ?? (taskId !== undefined ? [taskId] : []);
57
+ if (ids.length === 0) {
58
+ return {
59
+ ok: false,
60
+ task_id: "",
61
+ kind: "unknown",
62
+ status: "error",
63
+ error: "No task_id or task_ids provided to await",
64
+ };
65
+ }
66
+
67
+ const entries = ids
68
+ .map((id) => ad.get(id))
69
+ .filter((e): e is TaskEntry => e !== undefined);
70
+
71
+ if (entries.length === 0) {
72
+ return {
73
+ ok: false,
74
+ task_id: ids[0] ?? "",
75
+ kind: "unknown",
76
+ status: "error",
77
+ error: "None of the requested task IDs were found",
78
+ };
79
+ }
80
+
81
+ const resolved = await Promise.all(
82
+ entries.map(async (e) => {
83
+ if (e.status === "pending") {
84
+ try {
85
+ return await ad.wait(e.taskId, timeoutMs);
86
+ } catch {
87
+ return e;
88
+ }
89
+ }
90
+ return e;
91
+ }),
92
+ );
93
+
94
+ const awaited = resolved.map(toAwaitResult);
95
+ const allDone = awaited.every((a) => a.status === "done");
96
+ const hasResults = awaited.some((a) => a.results !== undefined);
97
+ const firstError = awaited.find((a) => a.error)?.error;
98
+ const first = awaited[0];
99
+ const kind = first?.kind ?? "unknown";
100
+
101
+ if (hasResults) {
102
+ const allResults: string[] = [];
103
+ for (const a of awaited) {
104
+ if (a.results !== undefined) {
105
+ for (const r of a.results) allResults.push(r);
106
+ } else if (a.result !== undefined) {
107
+ allResults.push(a.result);
108
+ }
109
+ }
110
+ return {
111
+ ok: allDone,
112
+ task_id: ids.join(","),
113
+ kind,
114
+ status: allDone ? "done" : "error",
115
+ results: Object.freeze(allResults),
116
+ error: firstError,
117
+ };
118
+ }
119
+
120
+ if (awaited.length === 1 && first !== undefined) {
121
+ return first;
122
+ }
123
+
124
+ return {
125
+ ok: allDone,
126
+ task_id: ids.join(","),
127
+ kind,
128
+ status: allDone ? "done" : "error",
129
+ results: Object.freeze(awaited.map((a) => a.result ?? a.error ?? "")),
130
+ error: firstError,
131
+ };
132
+ };
133
+ }
134
+
135
+ function toAwaitResult(entry: TaskEntry): AwaitResult {
136
+ const status = entry.status === "pending" ? "error" : entry.status;
137
+ return {
138
+ ok: entry.status === "done",
139
+ task_id: entry.taskId,
140
+ kind: entry.kind,
141
+ status,
142
+ result: entry.result,
143
+ results: entry.results,
144
+ error:
145
+ entry.error ??
146
+ (entry.status === "pending" ? "Task still pending" : undefined),
147
+ };
148
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Single LLM completion — the ONE place a sub-LLM call is made.
3
+ *
4
+ * Every leaf completion takes exactly ONE slot on `gates.leaf` here.
5
+ * Never wrap a whole batch in the leaf gate (deadlock); only complete1 acquires it.
6
+ *
7
+ * AGENTS.md DRY #1: complete1 exists once. Never inline another one.
8
+ */
9
+
10
+ import type { Api, Model, Usage } from "@earendil-works/pi-ai";
11
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
12
+ import { type ChatMsg, modelComplete } from "../model.ts";
13
+ import { checkResourceLimits } from "../../core/resource-limits.ts";
14
+ import { errorMessage, formatError } from "../../util/errors.ts";
15
+ import type { Semaphore } from "../../util/concurrency.ts";
16
+ import type { Invocation, SubcallConfig } from "./types.ts";
17
+
18
+ export interface Complete1Deps {
19
+ readonly leafGate: Semaphore;
20
+ readonly registry: ModelRegistry;
21
+ readonly getLlmModel: () => Model<Api>;
22
+ readonly getConfig: () => SubcallConfig;
23
+ readonly signal?: AbortSignal;
24
+ readonly onUsage?: (usage: Usage, role: "sub") => void;
25
+ }
26
+
27
+ /**
28
+ * Run one LLM completion inside the leaf gate.
29
+ * Never throws — returns formatError(...) strings on failure.
30
+ */
31
+ export async function complete1(
32
+ inv: Invocation,
33
+ prompt: string,
34
+ track: (usage: Usage) => void,
35
+ deps: Complete1Deps,
36
+ ): Promise<string> {
37
+ const config = deps.getConfig();
38
+ const limitError = checkResourceLimits({
39
+ timeoutMs: inv.limits.remainingTimeoutMs(),
40
+ });
41
+ if (limitError !== undefined) return limitError;
42
+ if (prompt.length > config.maxPromptChars) {
43
+ return formatError(
44
+ `sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ` +
45
+ `${config.maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`,
46
+ );
47
+ }
48
+ try {
49
+ const messages: ChatMsg[] = [{ role: "user", content: prompt }];
50
+ const res = await deps.leafGate.run(() =>
51
+ modelComplete(messages, {
52
+ model: deps.getLlmModel(),
53
+ registry: deps.registry,
54
+ system: config.subSystemPrompt,
55
+ maxTokens: config.subSampling?.maxTokens,
56
+ temperature: config.subSampling?.temperature,
57
+ reasoning: config.subSampling?.reasoning,
58
+ signal: deps.signal,
59
+ }),
60
+ );
61
+ inv.limits.addUsage(res.usage);
62
+ deps.onUsage?.(res.usage, "sub");
63
+ track(res.usage);
64
+ return res.text;
65
+ } catch (err: unknown) {
66
+ const msg = errorMessage(err);
67
+ const hint = /credit|402|payment|quota|rate.limit/i.test(msg)
68
+ ? " — try smaller batches or individual llm_query calls"
69
+ : "";
70
+ return formatError(`${msg}${hint}`);
71
+ }
72
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * emitting() — the single emit-pattern helper for leaf sub-calls.
3
+ *
4
+ * AGENTS.md DRY #5: every leaf subcall follows create-node → execute → update.
5
+ * rlm_query childRun emits its own node; do not wrap childRun in emitting.
6
+ */
7
+
8
+ import type { Usage } from "@earendil-works/pi-ai";
9
+ import { isErrorText } from "../../util/errors.ts";
10
+ import { previewText } from "../../text/preview.ts";
11
+ import type { Invocation } from "./types.ts";
12
+
13
+ export interface EmitOpts {
14
+ readonly kind: "llm" | "batch";
15
+ readonly label: string;
16
+ readonly args: string;
17
+ readonly model?: string;
18
+ }
19
+
20
+ export interface EmitSummary {
21
+ readonly preview: string;
22
+ readonly error?: string;
23
+ readonly failed?: number;
24
+ readonly total?: number;
25
+ }
26
+
27
+ /**
28
+ * Create a subcall node, run `fn`, then update the node with status/cost/preview.
29
+ * `fn` should not throw for soft failures (prefer Error: strings). Hard throws mark error.
30
+ */
31
+ export async function emitting<T>(
32
+ inv: Invocation,
33
+ opts: EmitOpts,
34
+ fn: (track: (usage: Usage) => void) => Promise<T>,
35
+ summarize: (out: T) => EmitSummary,
36
+ ): Promise<T> {
37
+ const id = inv.emitter.emitSubcallCreated({
38
+ kind: opts.kind,
39
+ parentId: inv.parentId,
40
+ label: opts.label,
41
+ model: opts.model,
42
+ args: opts.args,
43
+ depth: inv.depth,
44
+ });
45
+
46
+ let costUsd = 0;
47
+ let tokens = 0;
48
+ const track = (u: Usage): void => {
49
+ costUsd += u.cost.total;
50
+ tokens += u.totalTokens;
51
+ };
52
+
53
+ try {
54
+ const out = await fn(track);
55
+ const summary = summarize(out);
56
+ inv.emitter.emitSubcallUpdated({
57
+ id,
58
+ status: summary.error !== undefined ? "error" : "done",
59
+ resultPreview: summary.preview,
60
+ costUsd,
61
+ tokens,
62
+ detail: summary.error,
63
+ failedCount: summary.failed,
64
+ totalCount: summary.total,
65
+ });
66
+ return out;
67
+ } catch (err: unknown) {
68
+ const msg = err instanceof Error ? err.message : String(err);
69
+ inv.emitter.emitSubcallUpdated({
70
+ id,
71
+ status: "error",
72
+ resultPreview: msg,
73
+ costUsd,
74
+ tokens,
75
+ detail: msg,
76
+ });
77
+ throw err;
78
+ }
79
+ }
80
+
81
+ /** Summarize a batch result for the emitter. */
82
+ export function summarizeBatch(out: readonly string[]): EmitSummary {
83
+ let failed = 0;
84
+ let firstError: string | undefined;
85
+ for (const s of out) {
86
+ if (isErrorText(s)) {
87
+ failed += 1;
88
+ firstError ??= s;
89
+ }
90
+ }
91
+ const first = previewText(out[0] ?? "");
92
+ const error =
93
+ failed === 0
94
+ ? undefined
95
+ : failed === out.length
96
+ ? `all ${out.length} sub-calls failed — reduce batch size or try llm_query individually`
97
+ : `${failed}/${out.length} sub-calls failed`;
98
+ return {
99
+ preview: out.length > 1 ? `${first} (+${out.length - 1} more)` : first,
100
+ error: error ?? firstError,
101
+ failed,
102
+ total: out.length,
103
+ };
104
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * finish handler — contract boundary: the model signals completion.
3
+ *
4
+ * Soft policy (default): returns finished=true always. If tasks are still pending,
5
+ * includes `warning` listing unawaited task_ids — does **not** auto-drain results
6
+ * into the summary (that would hide model mistakes). Callers may refuse to stop
7
+ * when warning is set.
8
+ */
9
+
10
+ import type { FinishResult, SubcallHandlerDeps } from "./types.ts";
11
+ import type { SubcallOpts } from "../../sandbox/interrupts.ts";
12
+ import type { AwaitDeps } from "./task-registry.ts";
13
+
14
+ export function createFinishHandler(
15
+ deps: SubcallHandlerDeps,
16
+ ad?: AwaitDeps,
17
+ ) {
18
+ return async (
19
+ summary: string,
20
+ depth: number,
21
+ opts: SubcallOpts,
22
+ ): Promise<FinishResult & { readonly summary?: string; readonly warning?: string }> => {
23
+ const inv = deps.resolve(opts, depth);
24
+ const pending = ad?.unawaitedIds() ?? [];
25
+ const warning =
26
+ pending.length > 0
27
+ ? `finish called with unawaited tasks: ${pending.join(", ")}`
28
+ : undefined;
29
+
30
+ if (inv !== null) {
31
+ inv.emitter.emitSubcallUpdated?.({
32
+ id: inv.parentId ?? "root",
33
+ status: "done",
34
+ detail: warning ?? "finish called",
35
+ });
36
+ }
37
+
38
+ return {
39
+ ok: true,
40
+ finished: true,
41
+ summary: summary.length > 0 ? summary : undefined,
42
+ warning,
43
+ };
44
+ };
45
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * createSubcallHandlers — assembles the complete async-by-default handler set.
3
+ *
4
+ * This is the ONE place where subcall handlers are wired together. The engine
5
+ * and repl() tool both call this with their own resolve + trackDetached.
6
+ *
7
+ * Task identity lives in TaskRegistry (one implementation). Pass an optional
8
+ * registry to share session state; otherwise a fresh registry is created.
9
+ */
10
+
11
+ import { createLlmQueryHandler, createLlmBatchHandler } from "./llm-query.ts";
12
+ import { createRlmQueryHandler, createRlmBatchHandler } from "./rlm-query.ts";
13
+ import { createAwaitHandler } from "./await.ts";
14
+ import { createFinishHandler } from "./finish.ts";
15
+ import { createTaskRegistry, type TaskRegistry } from "./task-registry.ts";
16
+ import type { SubcallHandlerDeps, SubcallHandlers } from "./types.ts";
17
+
18
+ export function createSubcallHandlers(
19
+ deps: SubcallHandlerDeps,
20
+ registry: TaskRegistry = createTaskRegistry(),
21
+ ): SubcallHandlers {
22
+ // Do NOT call getLlmModel() here — recursion-only tests leave leaf models unwired
23
+ // and throw if resolved at construction. complete1 reads getLlmModel lazily per call.
24
+ return {
25
+ llmQuery: createLlmQueryHandler(deps, registry.spawnDeps),
26
+ llmBatch: createLlmBatchHandler(deps, registry.spawnDeps),
27
+ rlmQuery: createRlmQueryHandler(deps, registry.spawnDeps),
28
+ rlmBatch: createRlmBatchHandler(deps, registry.spawnDeps),
29
+ awaitTask: createAwaitHandler(deps, registry.awaitDeps),
30
+ finishTask: createFinishHandler(deps, registry.awaitDeps),
31
+ };
32
+ }
33
+
34
+ export type {
35
+ SubcallHandlerDeps,
36
+ SubcallHandlers,
37
+ SpawnResult,
38
+ AwaitResult,
39
+ FinishResult,
40
+ Invocation,
41
+ InvocationLimits,
42
+ SubcallConfig,
43
+ } from "./types.ts";
44
+
45
+ export { limitsFromRemaining } from "./types.ts";
46
+ export { summarizeBatch } from "./emitting.ts";
47
+ export { createTaskRegistry, SPAWN_HINT } from "./task-registry.ts";
48
+ export type { TaskRegistry, SpawnDeps, AwaitDeps } from "./task-registry.ts";
@@ -0,0 +1,130 @@
1
+ /**
2
+ * llm_query and llm_batch handlers — async-by-default spawn pattern.
3
+ */
4
+
5
+ import type { Usage } from "@earendil-works/pi-ai";
6
+ import { modelRef } from "../../config/settings.ts";
7
+ import { complete1, type Complete1Deps } from "./completion.ts";
8
+ import { emitting, summarizeBatch } from "./emitting.ts";
9
+ import { formatError, isErrorText } from "../../util/errors.ts";
10
+ import { previewText } from "../../text/preview.ts";
11
+ import type { SpawnResult, SubcallHandlerDeps } from "./types.ts";
12
+ import type { SubcallOpts } from "../../sandbox/interrupts.ts";
13
+ import { SPAWN_HINT, spawnAndRun, type SpawnDeps } from "./task-registry.ts";
14
+
15
+ const UNWIRED = formatError("RLM bridge not wired for this invocation");
16
+
17
+ function completeDeps(deps: SubcallHandlerDeps): Complete1Deps {
18
+ return {
19
+ leafGate: deps.gates.leaf,
20
+ registry: deps.registry,
21
+ getLlmModel: deps.getLlmModel,
22
+ getConfig: deps.getConfig,
23
+ signal: deps.signal,
24
+ onUsage: deps.onUsage,
25
+ };
26
+ }
27
+
28
+ function displayModel(deps: SubcallHandlerDeps): string | undefined {
29
+ try {
30
+ const m = deps.getLlmModel();
31
+ return modelRef(m) ?? m.id;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ export function createLlmQueryHandler(
38
+ deps: SubcallHandlerDeps,
39
+ sd: SpawnDeps,
40
+ ) {
41
+ return async (
42
+ prompt: string,
43
+ depth: number,
44
+ opts: SubcallOpts,
45
+ ): Promise<SpawnResult> => {
46
+ const inv = deps.resolve(opts, depth);
47
+ if (inv === null) {
48
+ return {
49
+ ok: false,
50
+ task_id: null,
51
+ kind: "llm",
52
+ n: 1,
53
+ status: "pending",
54
+ hint: SPAWN_HINT,
55
+ error: UNWIRED,
56
+ };
57
+ }
58
+
59
+ const cdeps = completeDeps(deps);
60
+ return spawnAndRun(
61
+ sd,
62
+ "llm",
63
+ 1,
64
+ () =>
65
+ emitting(
66
+ inv,
67
+ {
68
+ kind: "llm",
69
+ label: "llm_query",
70
+ args: `prompt: ${previewText(prompt)}`,
71
+ model: displayModel(deps),
72
+ },
73
+ (track: (u: Usage) => void) => complete1(inv, prompt, track, cdeps),
74
+ (out) => ({
75
+ preview: previewText(out),
76
+ error: isErrorText(out) ? out : undefined,
77
+ }),
78
+ ),
79
+ deps.trackDetached,
80
+ opts.detached,
81
+ );
82
+ };
83
+ }
84
+
85
+ export function createLlmBatchHandler(
86
+ deps: SubcallHandlerDeps,
87
+ sd: SpawnDeps,
88
+ ) {
89
+ return async (
90
+ prompts: readonly string[],
91
+ depth: number,
92
+ opts: SubcallOpts,
93
+ ): Promise<SpawnResult> => {
94
+ const inv = deps.resolve(opts, depth);
95
+ if (inv === null) {
96
+ return {
97
+ ok: false,
98
+ task_id: null,
99
+ kind: "llm_batch",
100
+ n: prompts.length,
101
+ status: "pending",
102
+ hint: SPAWN_HINT,
103
+ error: UNWIRED,
104
+ };
105
+ }
106
+
107
+ const cdeps = completeDeps(deps);
108
+ return spawnAndRun(
109
+ sd,
110
+ "llm_batch",
111
+ prompts.length,
112
+ () =>
113
+ emitting(
114
+ inv,
115
+ {
116
+ kind: "batch",
117
+ label: `llm_batch ×${prompts.length}`,
118
+ args: `prompt: ${previewText(prompts[0] ?? "")}`,
119
+ model: displayModel(deps),
120
+ },
121
+ // NO outer gate — complete1 takes the single leaf slot per prompt.
122
+ (track: (u: Usage) => void) =>
123
+ Promise.all(prompts.map((p) => complete1(inv, p, track, cdeps))),
124
+ summarizeBatch,
125
+ ),
126
+ deps.trackDetached,
127
+ opts.detached,
128
+ );
129
+ };
130
+ }