@hicaru/pi-rlm 0.2.0 → 0.2.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 (68) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  6. package/src/bridge/subcall-handlers.ts +382 -0
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +7 -15
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +115 -360
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +49 -10
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -386
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +14 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/py/worker.py +836 -0
  30. package/src/sandbox/sandbox-manager.ts +33 -6
  31. package/src/sandbox/sandbox.ts +153 -182
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/background-tasks.ts +95 -0
  34. package/src/tool/repl-details.ts +4 -2
  35. package/src/tool/repl-render.ts +58 -0
  36. package/src/tool/repl-result.ts +70 -0
  37. package/src/tool/repl-tool.ts +178 -216
  38. package/src/tool/rlm-aggregator.ts +2 -10
  39. package/src/tool/rlm-details.ts +0 -2
  40. package/src/tool/rlm-events.ts +10 -16
  41. package/src/tool/rlm-tool.ts +1 -12
  42. package/src/tool/subcall-render.ts +15 -3
  43. package/src/tool/subcall-store.ts +57 -1
  44. package/src/ui/config-panel.ts +4 -16
  45. package/src/ui/intro.ts +1 -2
  46. package/src/ui/model-picker.ts +34 -10
  47. package/src/ui/status.ts +3 -7
  48. package/src/util/concurrency.ts +91 -13
  49. package/src/util/trace.ts +42 -0
  50. package/src/bridge/fallback-todo.ts +0 -137
  51. package/src/bridge/interactive.ts +0 -65
  52. package/src/bridge/llm-query.ts +0 -156
  53. package/src/bridge/pi-interactive.ts +0 -41
  54. package/src/bridge/rlm-query.ts +0 -108
  55. package/src/core/artifacts.ts +0 -89
  56. package/src/core/critique.ts +0 -92
  57. package/src/core/gates.ts +0 -301
  58. package/src/core/pipeline-handlers.ts +0 -319
  59. package/src/core/pipeline.ts +0 -268
  60. package/src/prompts/phases.ts +0 -104
  61. package/src/sandbox/worker.py +0 -1078
  62. package/src/state/index.ts +0 -24
  63. package/src/state/internal.ts +0 -46
  64. package/src/state/paths.ts +0 -44
  65. package/src/state/reads.ts +0 -133
  66. package/src/state/resume.ts +0 -173
  67. package/src/state/rows.ts +0 -123
  68. package/src/state/writes.ts +0 -58
@@ -1,137 +0,0 @@
1
- import { formatError } from "../util/errors.ts";
2
-
3
- type TaskStatus = "pending" | "in_progress" | "completed" | "deleted";
4
-
5
- interface Task {
6
- readonly id: number;
7
- readonly subject: string;
8
- readonly description?: string;
9
- readonly status: TaskStatus;
10
- readonly activeForm?: string;
11
- readonly blockedBy?: readonly number[];
12
- readonly owner?: string;
13
- }
14
-
15
- interface TodoParams {
16
- readonly id?: number;
17
- readonly subject?: string;
18
- readonly description?: string;
19
- readonly status?: TaskStatus;
20
- readonly activeForm?: string;
21
- readonly blockedBy?: readonly number[];
22
- readonly addBlockedBy?: readonly number[];
23
- readonly removeBlockedBy?: readonly number[];
24
- readonly owner?: string;
25
- readonly filterStatus?: string;
26
- readonly includeDeleted?: boolean;
27
- }
28
-
29
- const TODO_STATUSES = Object.freeze(new Set<unknown>(["pending", "in_progress", "completed", "deleted"]));
30
-
31
- function isTaskStatus(value: unknown): value is TaskStatus {
32
- return TODO_STATUSES.has(value);
33
- }
34
-
35
- function numericArray(value: unknown): readonly number[] | undefined {
36
- return Array.isArray(value) ? Object.freeze(value.filter((n): n is number => typeof n === "number")) : undefined;
37
- }
38
-
39
- function toTodoParams(raw: Record<string, unknown>): TodoParams {
40
- const blockedBy = numericArray(raw.blockedBy);
41
- const addBlockedBy = numericArray(raw.addBlockedBy);
42
- const removeBlockedBy = numericArray(raw.removeBlockedBy);
43
- return Object.freeze({
44
- ...(typeof raw.id === "number" ? { id: raw.id } : {}),
45
- ...(typeof raw.subject === "string" ? { subject: raw.subject } : {}),
46
- ...(typeof raw.description === "string" ? { description: raw.description } : {}),
47
- ...(isTaskStatus(raw.status) ? { status: raw.status } : {}),
48
- ...(typeof raw.activeForm === "string" ? { activeForm: raw.activeForm } : {}),
49
- ...(blockedBy ? { blockedBy } : {}),
50
- ...(addBlockedBy ? { addBlockedBy } : {}),
51
- ...(removeBlockedBy ? { removeBlockedBy } : {}),
52
- ...(typeof raw.owner === "string" ? { owner: raw.owner } : {}),
53
- ...(typeof raw.filterStatus === "string" ? { filterStatus: raw.filterStatus } : {}),
54
- ...(raw.includeDeleted === true ? { includeDeleted: true } : {}),
55
- });
56
- }
57
-
58
- function patchedBlockedBy(task: Task, params: TodoParams): readonly number[] | undefined {
59
- if (params.blockedBy) return params.blockedBy;
60
-
61
- let next = task.blockedBy ?? Object.freeze([] as readonly number[]);
62
- if (params.addBlockedBy) next = Object.freeze([...next, ...params.addBlockedBy]);
63
-
64
- if (params.removeBlockedBy) {
65
- const removeSet = new Set(params.removeBlockedBy);
66
- next = Object.freeze(next.filter((n) => !removeSet.has(n)));
67
- }
68
- return next.length ? next : undefined;
69
- }
70
-
71
- function taskLines(task: Task): readonly string[] {
72
- const lines: string[] = [`#${task.id} [${task.status}] ${task.subject}`];
73
- if (task.description) lines.push(` description: ${task.description}`);
74
- if (task.activeForm) lines.push(` activeForm: ${task.activeForm}`);
75
- if (task.blockedBy?.length) lines.push(` blockedBy: ${task.blockedBy.map((n) => `#${n}`).join(", ")}`);
76
- if (task.owner) lines.push(` owner: ${task.owner}`);
77
- return lines;
78
- }
79
-
80
- function withPatch(task: Task, params: TodoParams): Task {
81
- const blockedBy = patchedBlockedBy(task, params);
82
- return Object.freeze({
83
- ...task,
84
- ...(params.subject !== undefined ? { subject: params.subject } : {}),
85
- ...(params.description !== undefined ? { description: params.description } : {}),
86
- ...(params.status !== undefined ? { status: params.status } : {}),
87
- ...(params.activeForm !== undefined ? { activeForm: params.activeForm } : {}),
88
- ...(blockedBy ? { blockedBy } : {}),
89
- ...(params.owner !== undefined ? { owner: params.owner } : {}),
90
- });
91
- }
92
-
93
- export function createTodoFallback(): (action: string, params: Record<string, unknown>) => Promise<string> {
94
- let nextId = 1;
95
- let tasks: readonly Task[] = Object.freeze([]);
96
- const fmt = (task: Task): string => taskLines(task)[0] ?? `#${task.id}`;
97
-
98
- const apply = (action: string, rawParams: Record<string, unknown>): string => {
99
- const params = toTodoParams(rawParams);
100
- if (action === "clear") {
101
- const count = tasks.length;
102
- tasks = Object.freeze([]);
103
- nextId = 1;
104
- return `Cleared ${count} task(s).`;
105
- }
106
- if (action === "create") {
107
- const subject = typeof params.subject === "string" && params.subject.trim() ? params.subject.trim() : undefined;
108
- if (!subject) return formatError("create requires subject");
109
- const task = withPatch(Object.freeze({ id: nextId, subject, status: "pending" }), params);
110
- nextId += 1;
111
- tasks = Object.freeze([...tasks, task]);
112
- return `Created ${fmt(task)}`;
113
- }
114
- if (action === "list") {
115
- const filter = params.filterStatus ?? params.status;
116
- const includeDeleted = params.includeDeleted === true;
117
- const rows = tasks.filter((task) => (includeDeleted || task.status !== "deleted") && (!filter || task.status === filter)).map(fmt);
118
- return rows.length ? rows.join("\n") : "No tasks.";
119
- }
120
- const id = params.id;
121
- const task = id !== undefined ? tasks.find((item) => item.id === id) : undefined;
122
- if (!task) return formatError(`task #${id ?? "?"} not found`);
123
- if (action === "get") return taskLines(task).join("\n");
124
- if (action === "delete") {
125
- const deleted = Object.freeze({ ...task, status: "deleted" as const });
126
- tasks = Object.freeze(tasks.map((item) => item.id === task.id ? deleted : item));
127
- return `Deleted ${fmt(deleted)}`;
128
- }
129
- if (action === "update") {
130
- const updated = withPatch(task, params);
131
- tasks = Object.freeze(tasks.map((item) => item.id === task.id ? updated : item));
132
- return `Updated ${fmt(updated)}`;
133
- }
134
- return formatError(`unknown todo action '${action}'`);
135
- };
136
- return async (action, params) => apply(action, params);
137
- }
@@ -1,65 +0,0 @@
1
- import type { AskAnswer, AskQuestion } from "../sandbox/protocol.ts";
2
- import type { SubLlmHandlers } from "../sandbox/sandbox.ts";
3
- import type { RlmEmitter } from "../tool/rlm-events.ts";
4
- import { formatError } from "../util/errors.ts";
5
-
6
- export interface InteractiveBridgeOpts {
7
- readonly onAskUserQuestion?: (questions: readonly AskQuestion[]) => Promise<AskAnswer[]>;
8
- readonly onTodo?: (action: string, params: Record<string, unknown>) => Promise<string>;
9
- readonly onTodoRow?: (action: string, params: Record<string, unknown>, result: string) => void | Promise<void>;
10
- readonly emitter?: RlmEmitter;
11
- readonly depth: number;
12
- readonly parentId?: string;
13
- }
14
-
15
- export function buildInteractiveHandlers(opts: InteractiveBridgeOpts): {
16
- askUserQuestion: SubLlmHandlers["askUserQuestion"];
17
- todo: SubLlmHandlers["todo"];
18
- } {
19
- return {
20
- async askUserQuestion(questions, depth) {
21
- if (depth > 0) return questions.map((q) => ({
22
- question: q.question,
23
- selected: [],
24
- custom: formatError("ask_user_question not available inside rlm_query sub-calls"),
25
- }));
26
-
27
- const id = opts.emitter?.emitSubcallCreated({
28
- kind: "tool", parentId: opts.parentId,
29
- label: "ask_user_question",
30
- args: `${questions.length} question(s)`,
31
- depth,
32
- });
33
- try {
34
- const cb = opts.onAskUserQuestion;
35
- if (!cb) throw new Error("ask_user_question not configured (no onAskUserQuestion callback)");
36
- const answers = await cb(questions);
37
- if (id) opts.emitter?.emitSubcallUpdated({ id, status: "done" });
38
- return answers;
39
- } catch (err) {
40
- if (id) opts.emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
41
- throw err;
42
- }
43
- },
44
-
45
- async todo(action, params, depth) {
46
- const id = opts.emitter?.emitSubcallCreated({
47
- kind: "tool", parentId: opts.parentId,
48
- label: `todo:${action}`,
49
- args: params.subject ? String(params.subject) : String(params.id ?? ""),
50
- depth,
51
- });
52
- try {
53
- const cb = opts.onTodo;
54
- if (!cb) throw new Error("todo not configured (no onTodo callback)");
55
- const result = await cb(action, params);
56
- await opts.onTodoRow?.(action, params, result);
57
- if (id) opts.emitter?.emitSubcallUpdated({ id, status: "done", resultPreview: result.slice(0, 80) });
58
- return result;
59
- } catch (err) {
60
- if (id) opts.emitter?.emitSubcallUpdated({ id, status: "error", detail: String(err) });
61
- throw err;
62
- }
63
- },
64
- };
65
- }
@@ -1,156 +0,0 @@
1
- /**
2
- * The `llm_query` / `llm_query_batched` bridge: turns sandbox sub-LLM interrupts into
3
- * real (serverless) completions on the configured *worker* model, reporting each call to the
4
- * RlmEmitter for progressive TUI re-rendering.
5
- *
6
- * Caps enforce the divide-and-conquer budget from the RLM method: per-prompt size and batch
7
- * fan-out are bounded, and batches run through a fixed-size concurrency pool.
8
- *
9
- * Every input that can change between calls (worker model, emitter, parent node, depth,
10
- * remaining budget) is an accessor, so a single bridge instance serves both the headless
11
- * engine — which binds them once per run — and the native `repl` tool, which swaps them per
12
- * invocation without recreating the sandbox.
13
- */
14
-
15
- import type { Api, Model, Usage } from "@earendil-works/pi-ai";
16
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
17
- import type { RlmEmitter } from "../tool/rlm-events.ts";
18
- import { displayModelRef, resolveModelId } from "../config/settings.ts";
19
- import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
20
- import type { Sampling } from "../core/types.ts";
21
- import { type ChatMsg, modelComplete } from "./model.ts";
22
- import { previewText } from "../text/preview.ts";
23
- import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
24
- import { mapPool } from "../util/concurrency.ts";
25
-
26
- /**
27
- * The config slice this bridge reads. Structurally satisfied by `RlmConfig`, and re-read on
28
- * every call so `/rlm-config` changes take effect without rebuilding the sandbox.
29
- */
30
- export interface LlmBridgeConfig {
31
- readonly maxPromptChars: number;
32
- readonly maxConcurrentSubcalls: number;
33
- readonly subSystemPrompt?: string;
34
- readonly subSampling?: Sampling;
35
- }
36
-
37
- export interface LlmBridgeOptions {
38
- /** Resolved per call so provider/config changes between calls are picked up. */
39
- readonly workerModel: () => Model<Api>;
40
- readonly registry: ModelRegistry;
41
- readonly config: () => LlmBridgeConfig;
42
- readonly signal?: AbortSignal;
43
- readonly onUsage?: (usage: Usage, model: Model<Api>) => void;
44
- /** Parent run's remaining budget/timeout; checked before every sub-call. */
45
- readonly remainingBudget?: () => RemainingResources | undefined;
46
- /** Live RlmDetails reporting target, resolved per call. */
47
- readonly emitter?: () => RlmEmitter | undefined;
48
- readonly parentId?: () => string | undefined;
49
- readonly depth?: () => number;
50
- }
51
-
52
- /** Provider failures that a smaller batch or a cheaper model might get past. */
53
- const RETRYABLE_HINT = /credit|402|payment|quota|rate.limit/i;
54
-
55
- export interface LlmBridge {
56
- llmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
57
- llmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
58
- }
59
-
60
- /** Batch outcome summary, or undefined when every prompt succeeded. */
61
- function batchError(failed: number, total: number): string | undefined {
62
- if (failed === 0) return undefined;
63
- return failed === total
64
- ? `all ${total} sub-calls failed — reduce batch size or try llm_query individually`
65
- : `${failed}/${total} sub-calls failed`;
66
- }
67
-
68
- export function createLlmBridge(opts: LlmBridgeOptions): LlmBridge {
69
- const displayModel = (model: string | null): string =>
70
- displayModelRef(opts.registry, model, opts.workerModel());
71
-
72
- // Run one completion; report cost/tokens via `track` (a per-call or per-batch accumulator).
73
- async function complete1(prompt: string, model: string | null, track: (u: Usage) => void): Promise<string> {
74
- const config = opts.config();
75
- const rem = opts.remainingBudget?.();
76
- if (rem !== undefined) {
77
- const limitError = checkResourceLimits(rem);
78
- if (limitError !== undefined) return limitError;
79
- }
80
- if (prompt.length > config.maxPromptChars) {
81
- return formatError(`sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ${config.maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`);
82
- }
83
- const resolved = model ? resolveModelId(opts.registry, model) : undefined;
84
- if (model && !resolved) return formatError(`unknown model override '${model}'`);
85
- const target = resolved ?? opts.workerModel();
86
- try {
87
- const messages: ChatMsg[] = [{ role: "user", content: prompt }];
88
- const res = await modelComplete(messages, {
89
- model: target,
90
- registry: opts.registry,
91
- system: config.subSystemPrompt,
92
- maxTokens: config.subSampling?.maxTokens,
93
- temperature: config.subSampling?.temperature,
94
- reasoning: config.subSampling?.reasoning,
95
- signal: opts.signal,
96
- });
97
- opts.onUsage?.(res.usage, target);
98
- track(res.usage);
99
- return res.text;
100
- } catch (err) {
101
- const msg = errorMessage(err);
102
- const hint = RETRYABLE_HINT.test(msg) ? " — try smaller batches or individual llm_query calls" : "";
103
- return formatError(`${msg}${hint}`);
104
- }
105
- }
106
-
107
- return {
108
- async llmQuery(prompt, model) {
109
- const emitter = opts.emitter?.();
110
- const id = emitter?.emitSubcallCreated({
111
- kind: "llm", parentId: opts.parentId?.(), label: "llm_query",
112
- model: displayModel(model), args: `prompt: ${previewText(prompt)}`,
113
- depth: opts.depth?.() ?? 0,
114
- });
115
- let cost = 0;
116
- let tokens = 0;
117
- const out = await complete1(prompt, model, (u) => {
118
- cost += u.cost.total;
119
- tokens += u.totalTokens;
120
- });
121
- if (emitter && id !== undefined) emitter.emitSubcallUpdated({ id,
122
- status: isErrorText(out) ? "error" : "done",
123
- costUsd: cost, tokens, resultPreview: previewText(out),
124
- detail: isErrorText(out) ? out : undefined,
125
- });
126
- return out;
127
- },
128
-
129
- async llmQueryBatched(prompts, model) {
130
- const emitter = opts.emitter?.();
131
- const id = emitter?.emitSubcallCreated({
132
- kind: "batch", parentId: opts.parentId?.(), label: `llm_query ×${prompts.length}`,
133
- model: displayModel(model), args: `prompt: ${previewText(prompts[0] ?? "")}`,
134
- depth: opts.depth?.() ?? 0,
135
- });
136
- let cost = 0;
137
- let tokens = 0;
138
- const out = await mapPool(prompts, opts.config().maxConcurrentSubcalls, (p) =>
139
- complete1(p, model, (u) => {
140
- cost += u.cost.total;
141
- tokens += u.totalTokens;
142
- }),
143
- );
144
- const failed = out.filter(isErrorText).length;
145
- const error = batchError(failed, out.length);
146
- const firstPreview = previewText(out[0] ?? "");
147
- const resultPreview = out.length > 1 ? `${firstPreview} (+${out.length - 1} more)` : firstPreview;
148
- if (emitter && id !== undefined) emitter.emitSubcallUpdated({ id,
149
- status: error ? "error" : "done", costUsd: cost, tokens,
150
- resultPreview, detail: error,
151
- failedCount: failed, totalCount: out.length,
152
- });
153
- return out;
154
- },
155
- };
156
- }
@@ -1,41 +0,0 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { InteractiveDeps } from "../core/types.ts";
3
- import type { AskAnswer, AskQuestion } from "../sandbox/protocol.ts";
4
- import { formatError } from "../util/errors.ts";
5
- import { createTodoFallback } from "./fallback-todo.ts";
6
-
7
- async function askViaUi(ctx: ExtensionContext, questions: readonly AskQuestion[]): Promise<AskAnswer[]> {
8
- if (!ctx.hasUI) throw new Error("ask_user_question requires UI");
9
- const answers = new Array<AskAnswer>(questions.length);
10
- for (let i = 0; i < questions.length; i++) {
11
- const q = questions[i];
12
- if (!q) {
13
- answers[i] = { question: "", selected: [], custom: formatError("malformed question") };
14
- continue;
15
- }
16
- if (q.multiSelect) {
17
- const selected: string[] = [];
18
- while (true) {
19
- const pick = await ctx.ui.select(`${q.header}: ${q.question}`, [...q.options.map((o) => o.label), "Done"]);
20
- if (!pick || pick === "Done") break;
21
- if (!selected.includes(pick)) selected.push(pick);
22
- }
23
- answers[i] = { question: q.question, selected };
24
- continue;
25
- }
26
- const pick = await ctx.ui.select(`${q.header}: ${q.question}`, [...q.options.map((o) => o.label), "Type something."]);
27
- if (!pick) answers[i] = { question: q.question, selected: [], custom: formatError("user cancelled") };
28
- else if (pick === "Type something.") answers[i] = { question: q.question, selected: [], custom: await ctx.ui.input(q.question) ?? "" };
29
- else answers[i] = { question: q.question, selected: [pick] };
30
- }
31
- return answers;
32
- }
33
-
34
- export function createPiInteractiveDeps(ctx: ExtensionContext): InteractiveDeps {
35
- const fallbackTodo = createTodoFallback();
36
- return Object.freeze({
37
- onAskUserQuestion: (questions: readonly AskQuestion[]): Promise<AskAnswer[]> => askViaUi(ctx, questions),
38
- onTodo: (action: string, params: Record<string, unknown>): Promise<string> =>
39
- Promise.resolve(fallbackTodo(action, params)),
40
- });
41
- }
@@ -1,108 +0,0 @@
1
- /**
2
- * The `rlm_query` recursion bridge.
3
- *
4
- * A child RLM gets its own sandbox and iterates over the prompt as its context. At/over the
5
- * depth cap it degrades to a plain `llm_query` (ported from rlm/core/rlm.py `_subcall`). The
6
- * concurrency pool bounds parallel children for `rlm_query_batched`.
7
- *
8
- * As with the llm bridge, everything that can change between calls is an accessor, so the
9
- * headless engine (which binds them once per run) and the native `repl` tool (which swaps them
10
- * per invocation) share one implementation.
11
- */
12
-
13
- import type { RlmResult, RunRlm } from "../core/types.ts";
14
- import type { LlmBridge } from "./llm-query.ts";
15
- import type { RlmEmitter } from "../tool/rlm-events.ts";
16
- import { checkResourceLimits, type RemainingResources } from "../core/resource-limits.ts";
17
- import { errorMessage, formatError } from "../util/errors.ts";
18
- import { mapPool } from "../util/concurrency.ts";
19
- import { previewText } from "../text/preview.ts";
20
-
21
- /** The config slice this bridge reads; structurally satisfied by `RlmConfig`. */
22
- export interface RlmBridgeConfig {
23
- readonly maxDepth: number;
24
- readonly maxConcurrentSubcalls: number;
25
- }
26
-
27
- export interface RlmHandlers {
28
- rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
29
- rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
30
- }
31
-
32
- export interface RlmBridgeOptions {
33
- /**
34
- * Spawns one child run. The engine passes its own `run` (self-recursion); the native tool
35
- * passes a closure that builds a child engine bound to the current invocation's emitter.
36
- */
37
- readonly run: RunRlm;
38
- readonly llm: LlmBridge;
39
- readonly config: () => RlmBridgeConfig;
40
- /** "provider/id" shown on the sub-call node; see `displayModelRef` in config/settings.ts. */
41
- readonly modelLabel?: (override: string | null) => string;
42
- /** Live RlmDetails reporting target, resolved per call. */
43
- readonly emitter: () => RlmEmitter | undefined;
44
- /** Parent subcall ID that this run is attached under, resolved per call. */
45
- readonly parentNodeId?: () => string | undefined;
46
- /** Returns the parent's remaining budget/timeout for seeding child runs. */
47
- readonly remainingBudget?: () => RemainingResources | undefined;
48
- /** Called with a child run's total cost/tokens so the parent LimitGuard debits it. */
49
- readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
50
- }
51
-
52
- export function createRlmHandlers(opts: RlmBridgeOptions): RlmHandlers {
53
- /**
54
- * One child spawn. `childDepth` is the absolute depth the child will run at.
55
- * Never throws: failures come back as "Error: ..." strings, matching the sandbox contract.
56
- */
57
- async function child(prompt: string, model: string | null, childDepth: number): Promise<string> {
58
- // At the cap, a child RLM would just be an LM — short-circuit to a one-shot llm_query.
59
- if (childDepth >= opts.config().maxDepth) {
60
- return opts.llm.llmQuery(prompt, model, childDepth - 1);
61
- }
62
-
63
- const rem = opts.remainingBudget?.();
64
- if (rem !== undefined) {
65
- // Pre-spawn guard: refuse if the parent's budget or timeout is already exhausted
66
- // (reference: _subcall checks remaining_budget/timeout before spawning).
67
- const limitError = checkResourceLimits(rem);
68
- if (limitError !== undefined) return limitError;
69
- }
70
-
71
- const emitter = opts.emitter();
72
- const subId = emitter?.emitSubcallCreated({
73
- kind: "rlm", parentId: opts.parentNodeId?.(), label: "rlm_query",
74
- model: opts.modelLabel?.(model) ?? model ?? undefined,
75
- detail: prompt.slice(0, 60),
76
- depth: childDepth,
77
- });
78
-
79
- try {
80
- const res: RlmResult = await opts.run({
81
- rootPrompt: "",
82
- context: prompt,
83
- depth: childDepth,
84
- parentNodeId: subId,
85
- modelOverride: model ?? undefined,
86
- remainingBudgetUsd: rem?.budgetUsd,
87
- remainingTimeoutMs: rem?.timeoutMs,
88
- });
89
- opts.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
90
- // The child emits live cost/token deltas on the shared emitter as it runs, so the node
91
- // must NOT also receive a final aggregate — that would double-count.
92
- if (emitter && subId !== undefined) {
93
- emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: previewText(res.answer) });
94
- }
95
- return res.answer;
96
- } catch (err) {
97
- const msg = errorMessage(err);
98
- if (emitter && subId !== undefined) emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
99
- return formatError(`child RLM failed - ${msg}`);
100
- }
101
- }
102
-
103
- return {
104
- rlmQuery: (prompt, model, depth) => child(prompt, model, depth + 1),
105
- rlmQueryBatched: (prompts, model, depth) =>
106
- mapPool(prompts, opts.config().maxConcurrentSubcalls, (p) => child(p, model, depth + 1)),
107
- };
108
- }
@@ -1,89 +0,0 @@
1
- /**
2
- * Artifact plumbing for the gated RLM pipeline: goal capture, baseline dirty-tree
3
- * snapshot, and timestamped stage artifact writes under `.rlm/artifacts/`.
4
- */
5
- import { execFileSync } from "node:child_process";
6
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
- import { join } from "node:path";
8
- import type { Result } from "../util/errors.ts";
9
- import { errorMessage } from "../util/errors.ts";
10
-
11
- export const ARTIFACTS_DIR = ".rlm/artifacts";
12
-
13
- const stamp = (): string => new Date().toISOString().replace(/[:.]/g, "-");
14
-
15
- export interface GoalCapture {
16
- readonly goalPath: string; // repo-relative
17
- readonly baselinePath: string; // repo-relative
18
- }
19
-
20
- export type SaveOutcome =
21
- | { readonly ok: true; readonly path: string }
22
- | { readonly ok: false; readonly error: string };
23
-
24
- export type GoalCaptureResult =
25
- | { readonly ok: true; readonly value: GoalCapture }
26
- | { readonly ok: false; readonly error: string };
27
-
28
- /**
29
- * Capture the user's brief VERBATIM: no frontmatter, no headers — the raw file
30
- * is the only artifact that preserves explicit user constraints unrefracted. The
31
- * baseline snapshot records paths ALREADY dirty before the run, so validate
32
- * judges only the run's own delta. Best-effort: git failure ⇒ empty baseline.
33
- * Failures never throw (unwritable cwd etc.) — returns error for the engine to surface.
34
- */
35
- export function captureGoal(cwd: string, brief: string): GoalCaptureResult {
36
- try {
37
- const ts = stamp();
38
- const dir = join(ARTIFACTS_DIR, "goal");
39
- mkdirSync(join(cwd, dir), { recursive: true });
40
- const goalPath = join(dir, `goal-${ts}.md`);
41
- writeFileSync(join(cwd, goalPath), brief, "utf-8");
42
- let paths: readonly string[] = [];
43
- try {
44
- paths = execFileSync("git", ["status", "--short"], {
45
- cwd,
46
- encoding: "utf-8",
47
- stdio: ["ignore", "pipe", "ignore"],
48
- })
49
- .split("\n")
50
- .filter((l) => l.trim() !== "")
51
- .map((l) => {
52
- const rest = l.slice(3).trim();
53
- const arrow = rest.indexOf(" -> ");
54
- return arrow >= 0 ? rest.slice(arrow + 4).trim() : rest;
55
- });
56
- } catch {
57
- paths = [];
58
- }
59
- const baselinePath = join(dir, `baseline-${ts}.json`);
60
- writeFileSync(join(cwd, baselinePath), JSON.stringify({ paths }, null, 2), "utf-8");
61
- return { ok: true, value: { goalPath, baselinePath } };
62
- } catch (err) {
63
- const message = errorMessage(err);
64
- return { ok: false, error: message };
65
- }
66
- }
67
-
68
- /** Write a stage artifact under its dir; timestamped so runs never collide. */
69
- export function saveArtifact(cwd: string, dir: string, slug: string, content: string): SaveOutcome {
70
- try {
71
- const rel = join(ARTIFACTS_DIR, dir, `${stamp()}_${slug}.md`);
72
- mkdirSync(join(cwd, ARTIFACTS_DIR, dir), { recursive: true });
73
- writeFileSync(join(cwd, rel), content, "utf-8");
74
- return { ok: true, path: rel };
75
- } catch (err) {
76
- const message = errorMessage(err);
77
- return { ok: false, error: message };
78
- }
79
- }
80
-
81
- /** Read a previously saved artifact (repo-relative path). Failures never throw. */
82
- export function readArtifact(cwd: string, relPath: string): Result<string, string> {
83
- try {
84
- return { ok: true, value: readFileSync(join(cwd, relPath), "utf-8") };
85
- } catch (err) {
86
- const message = errorMessage(err);
87
- return { ok: false, error: `could not read artifact ${relPath}: ${message}` };
88
- }
89
- }