@hicaru/pi-rlm 0.3.1 → 0.3.3

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 (46) hide show
  1. package/README.md +131 -154
  2. package/README.ru.md +5 -5
  3. package/README.zh-CN.md +5 -5
  4. package/package.json +3 -2
  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 +40 -12
  15. package/src/config/settings.ts +13 -3
  16. package/src/context/listing.ts +2 -2
  17. package/src/context/refresh.ts +141 -0
  18. package/src/core/engine.ts +16 -18
  19. package/src/core/types.ts +1 -3
  20. package/src/index.ts +59 -54
  21. package/src/mode/native-guards.ts +4 -4
  22. package/src/mode/rlm-mode.ts +6 -1
  23. package/src/mode/subagent.ts +1 -1
  24. package/src/prompts/glossary.ts +71 -74
  25. package/src/prompts/native.ts +127 -85
  26. package/src/prompts/system.ts +29 -15
  27. package/src/sandbox/interrupts.ts +258 -68
  28. package/src/sandbox/protocol.ts +53 -30
  29. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  30. package/src/sandbox/py/__pycache__/hostio.cpython-314.pyc +0 -0
  31. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  32. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  33. package/src/sandbox/py/guards.py +8 -5
  34. package/src/sandbox/py/retrieval.py +17 -8
  35. package/src/sandbox/py/tasks.py +1 -1
  36. package/src/sandbox/py/worker.py +106 -79
  37. package/src/sandbox/sandbox-manager.ts +26 -1
  38. package/src/sandbox/sandbox.ts +1 -1
  39. package/src/tool/background-tasks.ts +1 -1
  40. package/src/tool/repl-result.ts +2 -2
  41. package/src/tool/repl-tool.ts +13 -14
  42. package/src/ui/config-panel.ts +1 -1
  43. package/src/ui/intro.ts +1 -4
  44. package/src/ui/model-picker.ts +32 -2
  45. package/src/util/concurrency.ts +1 -1
  46. package/src/bridge/subcall-handlers.ts +0 -382
@@ -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
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * rlm_query and rlm_batch handlers — async-by-default spawn pattern.
3
+ *
4
+ * AGENTS.md DRY #2: childRun exists once, here.
5
+ */
6
+
7
+ import { modelRef } from "../../config/settings.ts";
8
+ import { errorMessage, formatError } from "../../util/errors.ts";
9
+ import { filterContextByPaths } from "../../context/merge.ts";
10
+ import { previewText } from "../../text/preview.ts";
11
+ import type { RlmInput, RlmResult } from "../../core/types.ts";
12
+ import { checkResourceLimits } from "../../core/resource-limits.ts";
13
+ import type { Invocation, SpawnResult, SubcallHandlerDeps } from "./types.ts";
14
+ import type { SubcallOpts } from "../../sandbox/interrupts.ts";
15
+ import { SPAWN_HINT, spawnAndRun, type SpawnDeps } from "./task-registry.ts";
16
+ import { complete1, type Complete1Deps } from "./completion.ts";
17
+
18
+ const UNWIRED = formatError("RLM bridge not wired for this invocation");
19
+ const NO_UNMATCHED: readonly string[] = Object.freeze([]);
20
+
21
+ function emptyResult(answer: string): RlmResult {
22
+ return {
23
+ answer,
24
+ iterations: 0,
25
+ costUsd: 0,
26
+ inputTokens: 0,
27
+ outputTokens: 0,
28
+ durationMs: 0,
29
+ };
30
+ }
31
+
32
+ interface ChildContext {
33
+ readonly context: unknown;
34
+ readonly unmatched: readonly string[];
35
+ }
36
+
37
+ function childContextFor(
38
+ deps: SubcallHandlerDeps,
39
+ prompt: string,
40
+ paths: readonly string[] | undefined,
41
+ ): ChildContext {
42
+ const inherited = deps.getChildContext?.();
43
+ if (inherited === undefined || inherited === null) {
44
+ return Object.freeze({ context: prompt, unmatched: NO_UNMATCHED });
45
+ }
46
+ if (paths === undefined || paths.length === 0) {
47
+ return Object.freeze({ context: inherited, unmatched: NO_UNMATCHED });
48
+ }
49
+ const filtered = filterContextByPaths(inherited, paths);
50
+ return Object.freeze({
51
+ context: filtered.files.length > 0 ? filtered.files : inherited,
52
+ unmatched: filtered.unmatched,
53
+ });
54
+ }
55
+
56
+ function completeDeps(deps: SubcallHandlerDeps): Complete1Deps {
57
+ return {
58
+ leafGate: deps.gates.leaf,
59
+ registry: deps.registry,
60
+ getLlmModel: deps.getLlmModel,
61
+ getConfig: deps.getConfig,
62
+ signal: deps.signal,
63
+ onUsage: deps.onUsage,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * One child RLM run: depth cap → resource guard → depth gate → spawn engine → debit parent.
69
+ * Emits its own subcall node (do not wrap in emitting()).
70
+ */
71
+ async function childRun(
72
+ deps: SubcallHandlerDeps,
73
+ inv: Invocation,
74
+ prompt: string,
75
+ paths: readonly string[] | undefined,
76
+ ): Promise<RlmResult> {
77
+ const childDepth = inv.depth + 1;
78
+ const run = deps.runChild;
79
+ const maxDepth = deps.getConfig().maxDepth;
80
+
81
+ if (run === undefined || childDepth >= maxDepth) {
82
+ const degrade = deps.degrade;
83
+ const answer =
84
+ degrade !== undefined
85
+ ? await degrade(prompt, inv.depth)
86
+ : await complete1(inv, prompt, () => {}, completeDeps(deps));
87
+ return emptyResult(answer);
88
+ }
89
+
90
+ const remTimeout = inv.limits.remainingTimeoutMs();
91
+ const limitError = checkResourceLimits({ timeoutMs: remTimeout });
92
+ if (limitError !== undefined) return emptyResult(limitError);
93
+
94
+ const rootModel = deps.getModel?.();
95
+ const modelLabel =
96
+ rootModel === undefined ? undefined : (modelRef(rootModel) ?? rootModel.id);
97
+ const subId = inv.emitter.emitSubcallCreated({
98
+ kind: "rlm",
99
+ parentId: inv.parentId,
100
+ label: "rlm_query",
101
+ model: modelLabel,
102
+ detail: prompt.slice(0, 60),
103
+ depth: childDepth,
104
+ });
105
+
106
+ const child = childContextFor(deps, prompt, paths);
107
+ const rootPrompt =
108
+ child.unmatched.length === 0
109
+ ? prompt
110
+ : `${prompt}\n\n[rlm] paths=${child.unmatched.join(", ")} matched no files; you received the full context.`;
111
+
112
+ const input: RlmInput = {
113
+ rootPrompt,
114
+ context: child.context,
115
+ depth: childDepth,
116
+ parentNodeId: subId,
117
+ remainingTimeoutMs: remTimeout,
118
+ };
119
+
120
+ try {
121
+ const res = await deps.gates.rlm.at(childDepth).run(() => run(input, inv));
122
+ inv.limits.addRaw(res.costUsd, res.inputTokens, res.outputTokens);
123
+ deps.onChildUsage?.(res.costUsd, res.inputTokens, res.outputTokens);
124
+ inv.emitter.emitSubcallUpdated({
125
+ id: subId,
126
+ status: "done",
127
+ resultPreview: res.answer.slice(0, 200),
128
+ });
129
+ return res;
130
+ } catch (err: unknown) {
131
+ const msg = errorMessage(err);
132
+ inv.emitter.emitSubcallUpdated({ id: subId, status: "error", detail: msg });
133
+ return emptyResult(formatError(`child RLM failed - ${msg}`));
134
+ }
135
+ }
136
+
137
+ export function createRlmQueryHandler(deps: SubcallHandlerDeps, sd: SpawnDeps) {
138
+ return async (
139
+ task: string,
140
+ depth: number,
141
+ opts: SubcallOpts,
142
+ ): Promise<SpawnResult> => {
143
+ const inv = deps.resolve(opts, depth);
144
+ if (inv === null) {
145
+ return {
146
+ ok: false,
147
+ task_id: null,
148
+ kind: "rlm",
149
+ n: 1,
150
+ status: "pending",
151
+ hint: SPAWN_HINT,
152
+ error: UNWIRED,
153
+ };
154
+ }
155
+
156
+ const pathArg = opts.paths;
157
+
158
+ return spawnAndRun(
159
+ sd,
160
+ "rlm",
161
+ 1,
162
+ async () => {
163
+ const r = await childRun(deps, inv, task, pathArg);
164
+ return r.answer;
165
+ },
166
+ deps.trackDetached,
167
+ opts.detached,
168
+ );
169
+ };
170
+ }
171
+
172
+ export function createRlmBatchHandler(deps: SubcallHandlerDeps, sd: SpawnDeps) {
173
+ return async (
174
+ tasks: readonly string[],
175
+ depth: number,
176
+ opts: SubcallOpts,
177
+ ): Promise<SpawnResult> => {
178
+ const inv = deps.resolve(opts, depth);
179
+ if (inv === null) {
180
+ return {
181
+ ok: false,
182
+ task_id: null,
183
+ kind: "rlm_batch",
184
+ n: tasks.length,
185
+ status: "pending",
186
+ hint: SPAWN_HINT,
187
+ error: UNWIRED,
188
+ };
189
+ }
190
+
191
+ const pathArg = opts.paths;
192
+ const id = inv.emitter.emitSubcallCreated({
193
+ kind: "batch",
194
+ parentId: inv.parentId,
195
+ label: `rlm_batch ×${tasks.length}`,
196
+ args: previewText(tasks[0] ?? ""),
197
+ depth: inv.depth,
198
+ });
199
+
200
+ return spawnAndRun(
201
+ sd,
202
+ "rlm_batch",
203
+ tasks.length,
204
+ async () => {
205
+ try {
206
+ const results = await Promise.all(
207
+ tasks.map((t) => childRun(deps, inv, t, pathArg)),
208
+ );
209
+ const answers = results.map((r) => r.answer);
210
+ inv.emitter.emitSubcallUpdated({
211
+ id,
212
+ status: "done",
213
+ resultPreview: previewText(answers[0] ?? ""),
214
+ totalCount: answers.length,
215
+ });
216
+ return answers;
217
+ } catch (err: unknown) {
218
+ const msg = errorMessage(err);
219
+ inv.emitter.emitSubcallUpdated({ id, status: "error", detail: msg });
220
+ throw err;
221
+ }
222
+ },
223
+ deps.trackDetached,
224
+ opts.detached,
225
+ );
226
+ };
227
+ }