@hicaru/pi-rlm 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bridge/fallback-todo.ts +12 -1
- package/src/bridge/subcall-handlers.ts +336 -0
- package/src/config/defaults.ts +4 -1
- package/src/core/engine.ts +65 -30
- package/src/index.ts +28 -0
- package/src/prompts/system.ts +23 -2
- package/src/sandbox/protocol.ts +6 -0
- package/src/sandbox/sandbox-manager.ts +20 -6
- package/src/sandbox/sandbox.ts +77 -12
- package/src/sandbox/worker.py +460 -82
- package/src/tool/background-tasks.ts +95 -0
- package/src/tool/repl-details.ts +2 -0
- package/src/tool/repl-tool.ts +186 -102
- package/src/tool/rlm-events.ts +10 -2
- package/src/tool/subcall-render.ts +15 -3
- package/src/tool/subcall-store.ts +57 -1
- package/src/util/concurrency.ts +87 -13
- package/src/util/trace.ts +42 -0
- package/src/bridge/llm-query.ts +0 -156
- package/src/bridge/rlm-query.ts +0 -108
package/package.json
CHANGED
|
@@ -90,12 +90,22 @@ function withPatch(task: Task, params: TodoParams): Task {
|
|
|
90
90
|
});
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** The actions `worker.py::_todo` documents. Anything else is a caller error, not a missing task. */
|
|
94
|
+
const TODO_ACTIONS: ReadonlySet<string> = Object.freeze(new Set([
|
|
95
|
+
"create", "update", "list", "get", "delete", "clear",
|
|
96
|
+
]));
|
|
97
|
+
|
|
93
98
|
export function createTodoFallback(): (action: string, params: Record<string, unknown>) => Promise<string> {
|
|
94
99
|
let nextId = 1;
|
|
95
100
|
let tasks: readonly Task[] = Object.freeze([]);
|
|
96
101
|
const fmt = (task: Task): string => taskLines(task)[0] ?? `#${task.id}`;
|
|
97
102
|
|
|
98
103
|
const apply = (action: string, rawParams: Record<string, unknown>): string => {
|
|
104
|
+
// Validated BEFORE the id lookup below: an unknown action used to fall through to it and
|
|
105
|
+
// report "task #? not found", which reads as a missing task rather than a bad action.
|
|
106
|
+
if (!TODO_ACTIONS.has(action)) {
|
|
107
|
+
return formatError(`unknown todo action '${action}' — expected ${[...TODO_ACTIONS].join(", ")}`);
|
|
108
|
+
}
|
|
99
109
|
const params = toTodoParams(rawParams);
|
|
100
110
|
if (action === "clear") {
|
|
101
111
|
const count = tasks.length;
|
|
@@ -131,7 +141,8 @@ export function createTodoFallback(): (action: string, params: Record<string, un
|
|
|
131
141
|
tasks = Object.freeze(tasks.map((item) => item.id === task.id ? updated : item));
|
|
132
142
|
return `Updated ${fmt(updated)}`;
|
|
133
143
|
}
|
|
134
|
-
|
|
144
|
+
// Unreachable: every TODO_ACTIONS member is handled above. Kept as the exhaustiveness arm.
|
|
145
|
+
return formatError(`unhandled todo action '${action}'`);
|
|
135
146
|
};
|
|
136
147
|
return async (action, params) => apply(action, params);
|
|
137
148
|
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single implementation of the sub-LLM handler set (llm_query, llm_query_batched,
|
|
3
|
+
* rlm_query, rlm_query_batched).
|
|
4
|
+
*
|
|
5
|
+
* Resolves AGENTS.md DRY #1–#5, which previously lived twice: once in `createLlmBridge` /
|
|
6
|
+
* `createRlmHandlers` for the headless engine, once in `NativeBridgeState` for the repl()
|
|
7
|
+
* tool. The two callers only ever differed in how they answer one question — "which emitter,
|
|
8
|
+
* limits and parent node does THIS interrupt belong to?" — so that is the only thing they
|
|
9
|
+
* still supply, as `resolve`. The engine binds one Invocation for a whole run; the repl()
|
|
10
|
+
* tool swaps one per turn and routes `spawn()`ed work to its session registry.
|
|
11
|
+
*
|
|
12
|
+
* Concurrency: every leaf completion passes through `gates.leaf`, every child engine through
|
|
13
|
+
* `gates.rlm.at(depth)`, so a sandbox that puts 25 batches on the wire at once is still
|
|
14
|
+
* bounded session-wide. See util/concurrency.ts for why the rlm gate is per-depth.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
18
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { displayModelRef, modelRef, resolveModelId } from "../config/settings.ts";
|
|
20
|
+
import { type ChatMsg, modelComplete } from "./model.ts";
|
|
21
|
+
import { previewText } from "../text/preview.ts";
|
|
22
|
+
import { checkResourceLimits } from "../core/resource-limits.ts";
|
|
23
|
+
import type { RlmInput, RlmResult, Sampling } from "../core/types.ts";
|
|
24
|
+
import type { SubcallGates } from "../util/concurrency.ts";
|
|
25
|
+
import type { SubcallOpts, SubLlmHandlers } from "../sandbox/sandbox.ts";
|
|
26
|
+
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
27
|
+
import { errorMessage, formatError, isErrorText } from "../util/errors.ts";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The slice of LimitGuard these handlers need. Narrow on purpose: the headless bridge is
|
|
31
|
+
* constructed from a `remainingBudget()` callback rather than owning a guard, and both
|
|
32
|
+
* shapes satisfy this.
|
|
33
|
+
*/
|
|
34
|
+
export interface InvocationLimits {
|
|
35
|
+
remainingBudgetUsd(): number | undefined;
|
|
36
|
+
remainingTimeoutMs(): number | undefined;
|
|
37
|
+
addUsage(usage: Usage): void;
|
|
38
|
+
addRaw(costUsd: number, inputTokens: number, outputTokens: number): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Adapt a "how much is left?" callback to InvocationLimits.
|
|
43
|
+
*
|
|
44
|
+
* The headless engine owns the real LimitGuard and folds usage in through `onUsage` /
|
|
45
|
+
* `onChildUsage`, so the accounting methods here are deliberately inert.
|
|
46
|
+
*/
|
|
47
|
+
export function limitsFromRemaining(
|
|
48
|
+
remaining?: () => { readonly budgetUsd?: number; readonly timeoutMs?: number },
|
|
49
|
+
): InvocationLimits {
|
|
50
|
+
return {
|
|
51
|
+
remainingBudgetUsd: () => remaining?.().budgetUsd,
|
|
52
|
+
remainingTimeoutMs: () => remaining?.().timeoutMs,
|
|
53
|
+
addUsage: () => {},
|
|
54
|
+
addRaw: () => {},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where one interrupt's reporting and accounting go.
|
|
60
|
+
*
|
|
61
|
+
* Captured at interrupt entry and threaded down, never re-read: once handlers can outlive
|
|
62
|
+
* their exec, re-reading mutable tool state after an await would attribute a sub-call to
|
|
63
|
+
* whichever turn happens to be current when it resumes.
|
|
64
|
+
*/
|
|
65
|
+
export interface Invocation {
|
|
66
|
+
readonly emitter: RlmEmitter;
|
|
67
|
+
readonly parentId: string | undefined;
|
|
68
|
+
readonly depth: number;
|
|
69
|
+
readonly limits: InvocationLimits;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The config slice these handlers read. Structurally satisfied by `RlmConfig`, and re-read on
|
|
74
|
+
* every call so `/rlm-config` changes take effect without rebuilding the sandbox.
|
|
75
|
+
*/
|
|
76
|
+
export interface SubcallConfig {
|
|
77
|
+
readonly maxPromptChars: number;
|
|
78
|
+
readonly maxDepth: number;
|
|
79
|
+
readonly subSampling?: Sampling;
|
|
80
|
+
readonly subSystemPrompt?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SubcallHandlerDeps {
|
|
84
|
+
/**
|
|
85
|
+
* Pick the Invocation for this interrupt. `null` ⇒ the bridge is not wired yet.
|
|
86
|
+
*
|
|
87
|
+
* `depth` is the depth the sandbox reported. The headless engine trusts it (its handlers
|
|
88
|
+
* serve one sandbox per depth); the repl() tool overrides it with its own turn depth.
|
|
89
|
+
*/
|
|
90
|
+
readonly resolve: (opts: SubcallOpts, depth: number) => Invocation | null;
|
|
91
|
+
/** Session-wide admission control. Required — a per-caller default would silently unbound it. */
|
|
92
|
+
readonly gates: SubcallGates;
|
|
93
|
+
readonly registry: ModelRegistry;
|
|
94
|
+
readonly getWorkerModel: () => Model<Api>;
|
|
95
|
+
/** Live accessor — `/rlm-config` replaces the config object, so never capture the value. */
|
|
96
|
+
readonly getConfig: () => SubcallConfig;
|
|
97
|
+
readonly signal?: AbortSignal;
|
|
98
|
+
readonly onUsage?: (usage: Usage, role: "sub") => void;
|
|
99
|
+
|
|
100
|
+
// ── recursion (omit all three to get llm-only handlers) ──
|
|
101
|
+
/**
|
|
102
|
+
* Spawns a child RLM for rlm_query.
|
|
103
|
+
*
|
|
104
|
+
* Receives the parent's Invocation so the child can report on the same emitter its
|
|
105
|
+
* parent subcall node lives on — a child of detached work must not emit to a turn
|
|
106
|
+
* emitter that will be shut down before it finishes, and keeping parent and child on
|
|
107
|
+
* one emitter is what lets the session registry drain the subtree intact.
|
|
108
|
+
*/
|
|
109
|
+
readonly runChild?: (input: RlmInput, inv: Invocation) => Promise<RlmResult>;
|
|
110
|
+
readonly getModel?: () => Model<Api>;
|
|
111
|
+
/**
|
|
112
|
+
* What rlm_query degrades to at the depth cap. A child RLM there would just be an LM, so
|
|
113
|
+
* both callers hand in their own one-shot path rather than re-deriving one here.
|
|
114
|
+
*/
|
|
115
|
+
readonly degrade?: (prompt: string, model: string | null, depth: number) => Promise<string>;
|
|
116
|
+
/** Called with a child run's totals so a caller-side guard can debit them too. */
|
|
117
|
+
readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
|
|
118
|
+
/** Wraps detached work so a session registry can count what is still in flight. */
|
|
119
|
+
readonly trackDetached?: <T>(run: () => Promise<T>) => Promise<T>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** DRY #4 — the batch failure summary, previously written out in both copies. */
|
|
123
|
+
export function summarizeBatch(out: readonly string[]): { readonly failed: number; readonly error?: string } {
|
|
124
|
+
let failed = 0;
|
|
125
|
+
for (const item of out) if (isErrorText(item)) failed += 1;
|
|
126
|
+
if (failed === 0) return { failed: 0 };
|
|
127
|
+
const error = failed === out.length
|
|
128
|
+
? `all ${out.length} sub-calls failed — reduce batch size or try llm_query individually`
|
|
129
|
+
: `${failed}/${out.length} sub-calls failed`;
|
|
130
|
+
return { failed, error };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type SubcallHandlers = Pick<
|
|
134
|
+
SubLlmHandlers,
|
|
135
|
+
"llmQuery" | "llmQueryBatched" | "rlmQuery" | "rlmQueryBatched"
|
|
136
|
+
>;
|
|
137
|
+
|
|
138
|
+
const UNWIRED = formatError("RLM bridge not wired for this invocation");
|
|
139
|
+
|
|
140
|
+
function emptyResult(answer: string): RlmResult {
|
|
141
|
+
return { answer, iterations: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, durationMs: 0 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createSubcallHandlers(deps: SubcallHandlerDeps): SubcallHandlers {
|
|
145
|
+
/** DRY #3 — the one display-model resolution. */
|
|
146
|
+
const displayModel = (model: string | null): string =>
|
|
147
|
+
displayModelRef(deps.registry, model, deps.getWorkerModel());
|
|
148
|
+
|
|
149
|
+
/** Detached work is counted by the session registry; attached work runs as-is. */
|
|
150
|
+
const detachable = <T>(opts: SubcallOpts, run: () => Promise<T>): Promise<T> =>
|
|
151
|
+
opts.detached && deps.trackDetached !== undefined ? deps.trackDetached(run) : run();
|
|
152
|
+
|
|
153
|
+
/** One leaf completion. Reports cost/tokens via `track`; never throws. */
|
|
154
|
+
async function complete1(
|
|
155
|
+
inv: Invocation,
|
|
156
|
+
prompt: string,
|
|
157
|
+
model: string | null,
|
|
158
|
+
track: (usage: Usage) => void,
|
|
159
|
+
): Promise<string> {
|
|
160
|
+
const config = deps.getConfig();
|
|
161
|
+
const limitError = checkResourceLimits({
|
|
162
|
+
budgetUsd: inv.limits.remainingBudgetUsd(),
|
|
163
|
+
timeoutMs: inv.limits.remainingTimeoutMs(),
|
|
164
|
+
});
|
|
165
|
+
if (limitError !== undefined) return limitError;
|
|
166
|
+
if (prompt.length > config.maxPromptChars) {
|
|
167
|
+
return formatError(
|
|
168
|
+
`sub-LLM prompt exceeded the size limit (${prompt.length.toLocaleString()} chars > ` +
|
|
169
|
+
`${config.maxPromptChars.toLocaleString()}). Shorten or chunk the prompt before calling llm_query.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const resolved = model ? resolveModelId(deps.registry, model) : undefined;
|
|
173
|
+
if (model && !resolved) return formatError(`unknown model override '${model}'`);
|
|
174
|
+
try {
|
|
175
|
+
const messages: ChatMsg[] = [{ role: "user", content: prompt }];
|
|
176
|
+
const res = await deps.gates.leaf.run(() => modelComplete(messages, {
|
|
177
|
+
model: resolved ?? deps.getWorkerModel(),
|
|
178
|
+
registry: deps.registry,
|
|
179
|
+
system: config.subSystemPrompt,
|
|
180
|
+
maxTokens: config.subSampling?.maxTokens,
|
|
181
|
+
temperature: config.subSampling?.temperature,
|
|
182
|
+
reasoning: config.subSampling?.reasoning,
|
|
183
|
+
signal: deps.signal,
|
|
184
|
+
}));
|
|
185
|
+
inv.limits.addUsage(res.usage);
|
|
186
|
+
deps.onUsage?.(res.usage, "sub");
|
|
187
|
+
track(res.usage);
|
|
188
|
+
return res.text;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const msg = errorMessage(err);
|
|
191
|
+
const hint = /credit|402|payment|quota|rate.limit/i.test(msg)
|
|
192
|
+
? " — try smaller batches or individual llm_query calls"
|
|
193
|
+
: "";
|
|
194
|
+
return formatError(`${msg}${hint}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* DRY #5 — the create → execute → update emit pattern for leaf sub-calls, in one place.
|
|
200
|
+
* rlm_query does NOT use this: its node is created inside `childRun`, and a wrapper here
|
|
201
|
+
* would double-report it.
|
|
202
|
+
*/
|
|
203
|
+
async function emitting<T>(
|
|
204
|
+
opts: SubcallOpts,
|
|
205
|
+
depth: number,
|
|
206
|
+
init: { kind: "llm" | "batch"; label: string; model: string | null; args: string },
|
|
207
|
+
run: (inv: Invocation, track: (usage: Usage) => void) => Promise<T>,
|
|
208
|
+
summarize: (out: T) => {
|
|
209
|
+
readonly preview: string;
|
|
210
|
+
readonly error?: string;
|
|
211
|
+
readonly failed?: number;
|
|
212
|
+
readonly total?: number;
|
|
213
|
+
},
|
|
214
|
+
unwired: () => T,
|
|
215
|
+
): Promise<T> {
|
|
216
|
+
const inv = deps.resolve(opts, depth);
|
|
217
|
+
if (inv === null) return unwired();
|
|
218
|
+
const id = inv.emitter.emitSubcallCreated({
|
|
219
|
+
kind: init.kind, parentId: inv.parentId, label: init.label,
|
|
220
|
+
model: displayModel(init.model), args: init.args, depth: inv.depth,
|
|
221
|
+
});
|
|
222
|
+
let costUsd = 0;
|
|
223
|
+
let tokens = 0;
|
|
224
|
+
const track = (usage: Usage): void => { costUsd += usage.cost.total; tokens += usage.totalTokens; };
|
|
225
|
+
const out = await detachable(opts, () => run(inv, track));
|
|
226
|
+
const summary = summarize(out);
|
|
227
|
+
inv.emitter.emitSubcallUpdated({
|
|
228
|
+
id,
|
|
229
|
+
status: summary.error !== undefined ? "error" : "done",
|
|
230
|
+
costUsd, tokens,
|
|
231
|
+
resultPreview: summary.preview,
|
|
232
|
+
detail: summary.error,
|
|
233
|
+
failedCount: summary.failed,
|
|
234
|
+
totalCount: summary.total,
|
|
235
|
+
});
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* One child RLM run: depth cap → resource guard → spawn engine → debit parent.
|
|
241
|
+
* Emits its own subcall node, so callers must not wrap it in another.
|
|
242
|
+
*/
|
|
243
|
+
async function childRun(inv: Invocation, prompt: string, model: string | null): Promise<RlmResult> {
|
|
244
|
+
const childDepth = inv.depth + 1;
|
|
245
|
+
const run = deps.runChild;
|
|
246
|
+
const maxDepth = deps.getConfig().maxDepth;
|
|
247
|
+
|
|
248
|
+
// At the cap a child RLM would just be an LM — short-circuit to the caller's one-shot path.
|
|
249
|
+
if (run === undefined || childDepth >= maxDepth) {
|
|
250
|
+
const degrade = deps.degrade;
|
|
251
|
+
const answer = degrade !== undefined
|
|
252
|
+
? await degrade(prompt, model, inv.depth)
|
|
253
|
+
: await complete1(inv, prompt, model, () => {});
|
|
254
|
+
return emptyResult(answer);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const remBudget = inv.limits.remainingBudgetUsd();
|
|
258
|
+
const remTimeout = inv.limits.remainingTimeoutMs();
|
|
259
|
+
const limitError = checkResourceLimits({ budgetUsd: remBudget, timeoutMs: remTimeout });
|
|
260
|
+
if (limitError) return emptyResult(limitError);
|
|
261
|
+
|
|
262
|
+
const rootModel = deps.getModel?.();
|
|
263
|
+
const resolvedOverride = model ? resolveModelId(deps.registry, model) : undefined;
|
|
264
|
+
const modelLabel = model
|
|
265
|
+
? (modelRef(resolvedOverride) ?? `unknown/${model}`)
|
|
266
|
+
: (rootModel === undefined ? undefined : (modelRef(rootModel) ?? rootModel.id));
|
|
267
|
+
const subId = inv.emitter.emitSubcallCreated({
|
|
268
|
+
kind: "rlm", parentId: inv.parentId, label: "rlm_query",
|
|
269
|
+
model: modelLabel, detail: prompt.slice(0, 60), depth: childDepth,
|
|
270
|
+
});
|
|
271
|
+
try {
|
|
272
|
+
const res = await deps.gates.rlm.at(childDepth).run(() => run({
|
|
273
|
+
rootPrompt: "",
|
|
274
|
+
context: prompt,
|
|
275
|
+
depth: childDepth,
|
|
276
|
+
parentNodeId: subId,
|
|
277
|
+
modelOverride: model ?? undefined,
|
|
278
|
+
remainingBudgetUsd: remBudget,
|
|
279
|
+
remainingTimeoutMs: remTimeout,
|
|
280
|
+
}, inv));
|
|
281
|
+
inv.limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
|
|
282
|
+
deps.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
|
|
283
|
+
// The child emits live usage deltas on the shared emitter, so no aggregate cost here
|
|
284
|
+
// — adding it would double-count against SubcallStore's running totals.
|
|
285
|
+
inv.emitter.emitSubcallUpdated({ id: subId, status: "done", resultPreview: res.answer.slice(0, 200) });
|
|
286
|
+
return res;
|
|
287
|
+
} catch (err) {
|
|
288
|
+
const msg = errorMessage(err);
|
|
289
|
+
inv.emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
|
|
290
|
+
return emptyResult(formatError(`child RLM failed - ${msg}`));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
llmQuery: (prompt, model, depth, opts) => emitting(
|
|
296
|
+
opts, depth,
|
|
297
|
+
{ kind: "llm", label: "llm_query", model, args: `prompt: ${previewText(prompt)}` },
|
|
298
|
+
(inv, track) => complete1(inv, prompt, model, track),
|
|
299
|
+
(out) => ({ preview: previewText(out), error: isErrorText(out) ? out : undefined }),
|
|
300
|
+
() => UNWIRED,
|
|
301
|
+
),
|
|
302
|
+
|
|
303
|
+
llmQueryBatched: (prompts, model, depth, opts) => emitting(
|
|
304
|
+
opts, depth,
|
|
305
|
+
{ kind: "batch", label: `llm_query ×${prompts.length}`, model, args: `prompt: ${previewText(prompts[0] ?? "")}` },
|
|
306
|
+
// NO outer gate. `complete1` already takes the single `leaf` slot each prompt needs;
|
|
307
|
+
// an outer `gates.leaf.map` here deadlocked every batch of >= limit prompts.
|
|
308
|
+
(inv, track) => Promise.all(prompts.map((p) => complete1(inv, p, model, track))),
|
|
309
|
+
(out) => {
|
|
310
|
+
const { failed, error } = summarizeBatch(out);
|
|
311
|
+
const first = previewText(out[0] ?? "");
|
|
312
|
+
return {
|
|
313
|
+
preview: out.length > 1 ? `${first} (+${out.length - 1} more)` : first,
|
|
314
|
+
error, failed, total: out.length,
|
|
315
|
+
};
|
|
316
|
+
},
|
|
317
|
+
() => prompts.map(() => UNWIRED),
|
|
318
|
+
),
|
|
319
|
+
|
|
320
|
+
async rlmQuery(prompt, model, depth, opts) {
|
|
321
|
+
const inv = deps.resolve(opts, depth);
|
|
322
|
+
if (inv === null) return UNWIRED;
|
|
323
|
+
return detachable(opts, async () => (await childRun(inv, prompt, model)).answer);
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
async rlmQueryBatched(prompts, model, depth, opts) {
|
|
327
|
+
const inv = deps.resolve(opts, depth);
|
|
328
|
+
if (inv === null) return prompts.map(() => UNWIRED);
|
|
329
|
+
// Bounded by the per-depth rlm gate inside childRun, not by an outer pool.
|
|
330
|
+
return detachable(opts, async () => {
|
|
331
|
+
const results = await Promise.all(prompts.map((p) => childRun(inv, p, model)));
|
|
332
|
+
return results.map((r) => r.answer);
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -16,7 +16,10 @@ export const DEFAULT_CONFIG: Readonly<RlmConfig> = Object.freeze({
|
|
|
16
16
|
maxIterations: 30,
|
|
17
17
|
execTimeoutS: 120,
|
|
18
18
|
requestTimeoutMs: 10 * 60_000,
|
|
19
|
-
|
|
19
|
+
// Session-wide, not per-batch: spawn() puts many requests on the wire at once, so this is
|
|
20
|
+
// the only thing bounding fan-out. Worst case is maxDepth × this many child engines (each
|
|
21
|
+
// owning a Python subprocess) plus this many leaf completions — keep it modest.
|
|
22
|
+
maxConcurrentSubcalls: 6,
|
|
20
23
|
maxPromptChars: 400_000,
|
|
21
24
|
maxErrors: 5,
|
|
22
25
|
orchestrator: true,
|
package/src/core/engine.ts
CHANGED
|
@@ -17,10 +17,12 @@ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
|
17
17
|
import { buildInteractiveHandlers } from "../bridge/interactive.ts";
|
|
18
18
|
import { buildLibraryHandler } from "../bridge/library.ts";
|
|
19
19
|
import { mergeLibraryIntoContext } from "../context/library-context.ts";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
createSubcallHandlers,
|
|
22
|
+
type Invocation,
|
|
23
|
+
} from "../bridge/subcall-handlers.ts";
|
|
21
24
|
import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
22
|
-
import {
|
|
23
|
-
import { displayModelRef, resolveModelId } from "../config/settings.ts";
|
|
25
|
+
import { resolveModelId } from "../config/settings.ts";
|
|
24
26
|
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
25
27
|
import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
|
|
26
28
|
import { phaseGuidance } from "../prompts/phases.ts";
|
|
@@ -57,6 +59,14 @@ import { STATE_SCHEMA_VERSION } from "../state/rows.ts";
|
|
|
57
59
|
import type { PhaseRow, RunHeader } from "../state/rows.ts";
|
|
58
60
|
import { serializeForSandbox, type ContextBundle } from "../context/repomix-context.ts";
|
|
59
61
|
import { formatError } from "../util/errors.ts";
|
|
62
|
+
import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Grace period for detached sub-calls to settle before the run disposes its sandbox.
|
|
66
|
+
* Past it the abort signal (or process exit) is what stops them; waiting longer would
|
|
67
|
+
* hold a finished run open on work whose result nobody can receive.
|
|
68
|
+
*/
|
|
69
|
+
const DETACHED_SETTLE_MS = 5_000;
|
|
60
70
|
|
|
61
71
|
|
|
62
72
|
export interface EngineDeps extends InteractiveDeps {
|
|
@@ -65,6 +75,8 @@ export interface EngineDeps extends InteractiveDeps {
|
|
|
65
75
|
readonly registry: ModelRegistry;
|
|
66
76
|
readonly config: RlmConfig;
|
|
67
77
|
readonly limits?: Limits;
|
|
78
|
+
/** Session-wide sub-call admission, shared with the repl() tool. Private one if omitted. */
|
|
79
|
+
readonly gates?: SubcallGates;
|
|
68
80
|
readonly signal?: AbortSignal;
|
|
69
81
|
/** Live RlmDetails reporting via onUpdate. Required — replaces SubcallObserver. */
|
|
70
82
|
readonly emitter: RlmEmitter;
|
|
@@ -171,37 +183,58 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
171
183
|
maxErrors: deps.limits?.maxErrors,
|
|
172
184
|
maxTokens: deps.limits?.maxTokens,
|
|
173
185
|
}, input.resume?.usageSeed.durationMs ?? 0);
|
|
174
|
-
const remainingBudget = (): { readonly budgetUsd?: number; readonly timeoutMs?: number } => ({
|
|
175
|
-
budgetUsd: limits.remainingBudgetUsd(),
|
|
176
|
-
timeoutMs: limits.remainingTimeoutMs(),
|
|
177
|
-
});
|
|
178
186
|
|
|
179
|
-
|
|
180
|
-
|
|
187
|
+
// One Invocation for the whole run: this engine owns exactly one sandbox at one depth,
|
|
188
|
+
// and its emitter and LimitGuard outlive every sub-call it services — including
|
|
189
|
+
// detached ones, which is why the headless path needs no session registry.
|
|
190
|
+
const invocation: Invocation = {
|
|
191
|
+
emitter,
|
|
192
|
+
parentId: selfReportId,
|
|
193
|
+
depth: input.depth,
|
|
194
|
+
limits: {
|
|
195
|
+
remainingBudgetUsd: () => limits.remainingBudgetUsd(),
|
|
196
|
+
remainingTimeoutMs: () => limits.remainingTimeoutMs(),
|
|
197
|
+
addUsage: (u) => {
|
|
198
|
+
limits.addUsage(u);
|
|
199
|
+
deps.onUsage?.(u, "sub");
|
|
200
|
+
},
|
|
201
|
+
addRaw: (costUsd, inputTokens, outputTokens) => {
|
|
202
|
+
limits.addRaw(costUsd, inputTokens, outputTokens);
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
// Detached work must not outlive the sandbox we dispose in `finally`: track it so the
|
|
207
|
+
// run can settle or abort it first (a child engine left running would keep spending).
|
|
208
|
+
let detachedInFlight = 0;
|
|
209
|
+
let detachedIdle: (() => void) | undefined;
|
|
210
|
+
const subcalls = createSubcallHandlers({
|
|
211
|
+
resolve: () => invocation,
|
|
212
|
+
gates: deps.gates ?? createSubcallGates(deps.config.maxConcurrentSubcalls),
|
|
181
213
|
registry: deps.registry,
|
|
182
|
-
|
|
214
|
+
getWorkerModel: () => deps.workerModel,
|
|
215
|
+
getModel: () => model,
|
|
216
|
+
getConfig: () => deps.config,
|
|
183
217
|
signal: deps.signal,
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const rlm = createRlmHandlers({
|
|
194
|
-
run,
|
|
195
|
-
llm,
|
|
196
|
-
config: () => deps.config,
|
|
197
|
-
modelLabel: (override) => displayModelRef(deps.registry, override, model),
|
|
198
|
-
emitter: () => emitter,
|
|
199
|
-
parentNodeId: () => selfReportId,
|
|
200
|
-
remainingBudget,
|
|
201
|
-
onChildUsage: (costUsd, inputTokens, outputTokens) => {
|
|
202
|
-
limits.addRaw(costUsd, inputTokens, outputTokens);
|
|
218
|
+
runChild: run,
|
|
219
|
+
trackDetached: async (task) => {
|
|
220
|
+
detachedInFlight += 1;
|
|
221
|
+
try {
|
|
222
|
+
return await task();
|
|
223
|
+
} finally {
|
|
224
|
+
detachedInFlight -= 1;
|
|
225
|
+
if (detachedInFlight === 0) detachedIdle?.();
|
|
226
|
+
}
|
|
203
227
|
},
|
|
204
228
|
});
|
|
229
|
+
/** Wait (bounded) for detached work before the sandbox goes away. */
|
|
230
|
+
const settleDetached = async (): Promise<void> => {
|
|
231
|
+
if (detachedInFlight === 0) return;
|
|
232
|
+
await new Promise<void>((resolve) => {
|
|
233
|
+
detachedIdle = resolve;
|
|
234
|
+
setTimeout(resolve, DETACHED_SETTLE_MS).unref?.();
|
|
235
|
+
});
|
|
236
|
+
detachedIdle = undefined;
|
|
237
|
+
};
|
|
205
238
|
let sandbox: PythonSandbox | undefined;
|
|
206
239
|
let best = "";
|
|
207
240
|
let lastAnswer = "";
|
|
@@ -352,9 +385,10 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
352
385
|
signal: deps.signal,
|
|
353
386
|
initTimeoutMs: deps.config.sandboxInitTimeoutMs,
|
|
354
387
|
maxPromptChars: deps.config.maxPromptChars,
|
|
388
|
+
awaitTimeoutS: Math.round(deps.config.requestTimeoutMs / 1000),
|
|
355
389
|
// Pipeline at depth 0 is read-only: guard open() write modes in the worker.
|
|
356
390
|
readOnly: pipelineOn,
|
|
357
|
-
handlers: { ...
|
|
391
|
+
handlers: { ...subcalls, ...phaseHandlers, ...interactiveHandlers, ...libraryHandlers },
|
|
358
392
|
});
|
|
359
393
|
|
|
360
394
|
let history: ChatMsg[] = input.resume ? input.resume.history : [{ role: "system", content: system }];
|
|
@@ -573,6 +607,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
573
607
|
if (nodeStatus !== "error" && lastAnswer) emitter.emitAnswer(previewText(lastAnswer));
|
|
574
608
|
emitter.emitStatus(nodeStatus === "error" ? "error" : "done");
|
|
575
609
|
}
|
|
610
|
+
await settleDetached();
|
|
576
611
|
await sandbox?.dispose();
|
|
577
612
|
}
|
|
578
613
|
};
|
package/src/index.ts
CHANGED
|
@@ -12,12 +12,16 @@ import { postRlmGuide } from "./ui/intro.ts";
|
|
|
12
12
|
import { setRlmModeStatus } from "./ui/status.ts";
|
|
13
13
|
import { markdownTheme } from "./ui/theme-adapter.ts";
|
|
14
14
|
import { SandboxManager } from "./sandbox/sandbox-manager.ts";
|
|
15
|
+
import { createSubcallGates } from "./util/concurrency.ts";
|
|
16
|
+
import { BackgroundTasks } from "./tool/background-tasks.ts";
|
|
15
17
|
import { packRepository, formatForLLM, serializeForSandbox } from "./context/repomix-context.ts";
|
|
16
18
|
import { buildNativeSystemPrompt, NATIVE_TURN_REMINDER } from "./prompts/system.ts";
|
|
17
19
|
import { bashCommandFromInput, isFileReadingCommand, capToolResultText, BASH_BLOCK_REASON } from "./mode/native-guards.ts";
|
|
18
20
|
import { errorMessage } from "./util/errors.ts";
|
|
19
21
|
|
|
20
22
|
const BLOCKED_NATIVE_TOOLS = Object.freeze(new Set(["read", "grep"]));
|
|
23
|
+
/** How often to keep the parent sandbox's request watchdog alive during detached work. */
|
|
24
|
+
const WATCHDOG_HEARTBEAT_MS = 30_000;
|
|
21
25
|
const CAPPED_RESULT_TOOLS = Object.freeze(new Set(["bash", "find", "ls"]));
|
|
22
26
|
|
|
23
27
|
export default function rlmExtension(pi: ExtensionAPI): void {
|
|
@@ -31,8 +35,28 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
31
35
|
python: config.python,
|
|
32
36
|
sandboxInitTimeoutMs: config.sandboxInitTimeoutMs,
|
|
33
37
|
maxPromptChars: config.maxPromptChars,
|
|
38
|
+
// Same wall budget as the parent request watchdog — a stalled sub-call should surface
|
|
39
|
+
// inside the cell rather than hang the session forever.
|
|
40
|
+
awaitTimeoutS: Math.round(config.requestTimeoutMs / 1000),
|
|
34
41
|
onSandboxDiscarded: () => { onSandboxDiscardExtra?.(); },
|
|
35
42
|
});
|
|
43
|
+
// One admission gate for the whole session: spawn() lets the sandbox put many requests on
|
|
44
|
+
// the wire at once, so nothing smaller than session scope actually bounds fan-out.
|
|
45
|
+
const gates = createSubcallGates(config.maxConcurrentSubcalls);
|
|
46
|
+
const background = new BackgroundTasks({
|
|
47
|
+
maxBudgetUsd: config.maxBudgetUsd,
|
|
48
|
+
maxTimeoutMs: config.maxTimeoutMs,
|
|
49
|
+
maxTokens: config.maxTokens,
|
|
50
|
+
maxErrors: config.maxErrors,
|
|
51
|
+
});
|
|
52
|
+
// A detached child works in its OWN sandbox, so this one sees no frames and its request
|
|
53
|
+
// watchdog would fire mid-await and SIGKILL a healthy worker, taking the REPL namespace
|
|
54
|
+
// with it. Keep it alive while detached work is genuinely in flight.
|
|
55
|
+
const watchdogHeartbeat = setInterval(() => {
|
|
56
|
+
if (background.pending > 0) sandboxManager.refreshWatchdog();
|
|
57
|
+
}, WATCHDOG_HEARTBEAT_MS);
|
|
58
|
+
watchdogHeartbeat.unref();
|
|
59
|
+
|
|
36
60
|
let packedContextText: string | undefined;
|
|
37
61
|
let contextPackPromise: Promise<string | undefined> | undefined;
|
|
38
62
|
const ensureRepositoryContext = async (cwd: string): Promise<string | undefined> => {
|
|
@@ -114,6 +138,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
114
138
|
getWorkerModel: () => controller.resolveModels(ctx)?.worker,
|
|
115
139
|
registry: ctx.modelRegistry,
|
|
116
140
|
getConfig: () => controller.config,
|
|
141
|
+
gates,
|
|
142
|
+
background,
|
|
117
143
|
registerDiscardHook: (reset) => { onSandboxDiscardExtra = reset; },
|
|
118
144
|
ensureContext: async () => {
|
|
119
145
|
const contextText = await ensureRepositoryContext(ctx.cwd ?? process.cwd());
|
|
@@ -224,6 +250,8 @@ export default function rlmExtension(pi: ExtensionAPI): void {
|
|
|
224
250
|
// ── Session shutdown: cleanup ──
|
|
225
251
|
pi.on("session_shutdown", async () => {
|
|
226
252
|
controller.abort();
|
|
253
|
+
clearInterval(watchdogHeartbeat);
|
|
254
|
+
background.dispose();
|
|
227
255
|
await sandboxManager.dispose();
|
|
228
256
|
contextInjected = false;
|
|
229
257
|
packedContextText = undefined;
|
package/src/prompts/system.ts
CHANGED
|
@@ -74,6 +74,23 @@ const CHUNKED_GLOSSARY_LINES: readonly string[] = Object.freeze([
|
|
|
74
74
|
" you open()ed, an oversized sub-result, or several concatenated context files.",
|
|
75
75
|
]);
|
|
76
76
|
|
|
77
|
+
/** Non-blocking fan-out: spawn now, collect later (headless glossary). */
|
|
78
|
+
const SPAWN_GLOSSARY_LINES: readonly string[] = Object.freeze([
|
|
79
|
+
"- `spawn(fn, *args) -> Task`: start `llm_query`, `llm_query_batched`, `llm_query_chunked`,",
|
|
80
|
+
" `map_files`, `rlm_query` or `rlm_query_batched` WITHOUT waiting. Returns immediately.",
|
|
81
|
+
" (Not `llm_map_reduce` — its reduce step depends on its own map results.)",
|
|
82
|
+
"- `rlm_await(task)` / `rlm_await_all(tasks) -> list`: collect results; order matches input.",
|
|
83
|
+
" Tasks survive across turns, so spawn the slow work first, keep doing useful things, and",
|
|
84
|
+
" await only when you actually need the results. `task.done` tells you if it has landed.",
|
|
85
|
+
"",
|
|
86
|
+
" ```python",
|
|
87
|
+
" # start the slow sub-agents, then keep working while they run",
|
|
88
|
+
" tasks = [spawn(rlm_query, f\"Audit {area} end to end\") for area in areas]",
|
|
89
|
+
" hits = [f for f in context if \"TODO\" in f[\"content\"]] # overlaps with the sub-agents",
|
|
90
|
+
" reports = rlm_await_all(tasks)",
|
|
91
|
+
" ```",
|
|
92
|
+
]);
|
|
93
|
+
|
|
77
94
|
/** Why a file the user mentioned may be missing from `context`. */
|
|
78
95
|
const CONTEXT_EXCLUSION_NOTE =
|
|
79
96
|
" NOTE: files larger than 1MB and gitignored files are NOT in `context` — they exist only on disk.";
|
|
@@ -206,6 +223,7 @@ function replGlossary(
|
|
|
206
223
|
"- `llm_query_batched(prompts: list[str], model=None) -> list[str]`: run several sub-LLM calls",
|
|
207
224
|
" concurrently; output order matches input order.",
|
|
208
225
|
...CHUNKED_GLOSSARY_LINES,
|
|
226
|
+
...SPAWN_GLOSSARY_LINES,
|
|
209
227
|
...DELEGATION_GLOSSARY_LINES,
|
|
210
228
|
);
|
|
211
229
|
if (askUserQuestion) {
|
|
@@ -370,6 +388,7 @@ function nativeReplGlossary(): string {
|
|
|
370
388
|
CHUNKED_GLOSSARY_LINE_NATIVE,
|
|
371
389
|
"- `rlm_query(prompt, model=None) -> str` — recursive RLM with its own REPL for complex sub-tasks needing iterative reasoning. Prefer llm_query — rlm_query is slower and costlier.",
|
|
372
390
|
"- `rlm_query_batched(prompts, model=None) -> list[str]` — concurrent recursive RLM calls.",
|
|
391
|
+
"- `spawn(fn, *args) -> Task` / `rlm_await(t)` / `rlm_await_all(ts)` — start `llm_query`, `llm_query_batched`, `llm_query_chunked`, `map_files`, `rlm_query` or `rlm_query_batched` without waiting (NOT `llm_map_reduce`); collect later, order preserved. Tasks outlive the repl() call, so spawn slow work early and await when you need it.",
|
|
373
392
|
"",
|
|
374
393
|
"",
|
|
375
394
|
"- `answers` / `plan` — dicts persisted across every repl() call and snapshot. Your memo.",
|
|
@@ -445,8 +464,10 @@ export function buildNativeSystemPrompt(): string {
|
|
|
445
464
|
/** Soft cap on the static native prompt. Leaves headroom for per-turn context injection
|
|
446
465
|
* without bloating the root model's system prompt. Exceeded → phase-guards.ts fails.
|
|
447
466
|
* Raised from 6K when the retrieval glossary and the condensed decomposition doctrine
|
|
448
|
-
* landed; both buy far more than they cost (paper Table 2, Fig. 4a)
|
|
449
|
-
|
|
467
|
+
* landed; both buy far more than they cost (paper Table 2, Fig. 4a), then again for
|
|
468
|
+
* spawn/rlm_await: the async fan-out API is part of the model-visible contract, and
|
|
469
|
+
* ~50 tokens is worth the model actually using it. */
|
|
470
|
+
export const NATIVE_PROMPT_BUDGET = 7_700;
|
|
450
471
|
|
|
451
472
|
/** Exported for tests — prompt length without context metadata (which is injected separately). */
|
|
452
473
|
export const NATIVE_PROMPT_STATIC = buildNativeSystemPrompt();
|
package/src/sandbox/protocol.ts
CHANGED
|
@@ -88,6 +88,12 @@ export interface AskAnswer {
|
|
|
88
88
|
interface InterruptBase {
|
|
89
89
|
readonly rid: string;
|
|
90
90
|
readonly depth: number;
|
|
91
|
+
/**
|
|
92
|
+
* Started via `spawn()`: the request may outlive the `exec` that issued it, so the host
|
|
93
|
+
* must not attach it to that invocation's emitter or LimitGuard. Absent on the
|
|
94
|
+
* synchronous path.
|
|
95
|
+
*/
|
|
96
|
+
readonly detached?: boolean;
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
interface PromptInterrupt extends InterruptBase {
|