@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.
- package/README.md +52 -5
- package/README.ru.md +5 -5
- package/README.zh-CN.md +5 -5
- package/package.json +1 -1
- package/src/bridge/handlers/await.ts +148 -0
- package/src/bridge/handlers/completion.ts +72 -0
- package/src/bridge/handlers/emitting.ts +104 -0
- package/src/bridge/handlers/finish.ts +45 -0
- package/src/bridge/handlers/index.ts +48 -0
- package/src/bridge/handlers/llm-query.ts +130 -0
- package/src/bridge/handlers/rlm-query.ts +227 -0
- package/src/bridge/handlers/task-registry.ts +202 -0
- package/src/bridge/handlers/types.ts +136 -0
- package/src/commands/rlm-config.ts +33 -14
- package/src/context/listing.ts +2 -2
- package/src/context/refresh.ts +141 -0
- package/src/core/engine.ts +16 -18
- package/src/core/types.ts +1 -3
- package/src/index.ts +95 -38
- package/src/mode/native-guards.ts +4 -4
- package/src/mode/subagent.ts +68 -0
- package/src/prompts/glossary.ts +71 -74
- package/src/prompts/native.ts +127 -85
- package/src/prompts/system.ts +29 -15
- package/src/sandbox/interrupts.ts +258 -68
- package/src/sandbox/protocol.ts +53 -30
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/hostio.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +15 -5
- package/src/sandbox/py/hostio.py +57 -0
- package/src/sandbox/py/retrieval.py +17 -8
- package/src/sandbox/py/tasks.py +1 -1
- package/src/sandbox/py/worker.py +109 -83
- package/src/sandbox/sandbox-manager.ts +26 -1
- package/src/sandbox/sandbox.ts +9 -2
- package/src/tool/background-tasks.ts +1 -1
- package/src/tool/repl-result.ts +2 -2
- package/src/tool/repl-tool.ts +13 -14
- package/src/ui/config-panel.ts +1 -1
- package/src/ui/intro.ts +1 -4
- package/src/ui/model-picker.ts +28 -2
- package/src/util/concurrency.ts +1 -1
- package/src/bridge/subcall-handlers.ts +0 -382
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single in-memory task registry for api_v5 async-by-default spawns.
|
|
3
|
+
*
|
|
4
|
+
* DRY: this is the ONLY place that assigns task_ids, parks await waiters, and
|
|
5
|
+
* resolves/rejects entries. Handlers and session UI both consume this type —
|
|
6
|
+
* never re-implement a Map of TaskEntry elsewhere.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AwaitResult, SpawnResult, TaskEntry } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
export const SPAWN_HINT =
|
|
12
|
+
"Call await_task(task_id=...) to get the result — this is NOT the answer.";
|
|
13
|
+
|
|
14
|
+
export interface SpawnDeps {
|
|
15
|
+
nextId(): number;
|
|
16
|
+
register(kind: SpawnResult["kind"], n: number, taskId: string): SpawnResult;
|
|
17
|
+
resolve(taskId: string, result: string | readonly string[]): void;
|
|
18
|
+
reject(taskId: string, error: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AwaitDeps {
|
|
22
|
+
get(taskId: string): TaskEntry | undefined;
|
|
23
|
+
wait(taskId: string, timeoutMs?: number): Promise<TaskEntry>;
|
|
24
|
+
unawaitedIds(): readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TaskRegistry {
|
|
28
|
+
readonly spawnDeps: SpawnDeps;
|
|
29
|
+
readonly awaitDeps: AwaitDeps;
|
|
30
|
+
entries(): ReadonlyMap<string, TaskEntry>;
|
|
31
|
+
toAwaitResult(taskId: string): AwaitResult;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Waiter {
|
|
35
|
+
resolve: (entry: TaskEntry) => void;
|
|
36
|
+
reject: (err: Error) => void;
|
|
37
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function notify(waiters: Map<string, Waiter>, taskId: string, entry: TaskEntry): void {
|
|
41
|
+
const w = waiters.get(taskId);
|
|
42
|
+
if (w === undefined) return;
|
|
43
|
+
if (w.timer !== undefined) clearTimeout(w.timer);
|
|
44
|
+
w.resolve(entry);
|
|
45
|
+
waiters.delete(taskId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createTaskRegistry(): TaskRegistry {
|
|
49
|
+
const tasks = new Map<string, TaskEntry>();
|
|
50
|
+
const waiters = new Map<string, Waiter>();
|
|
51
|
+
let counter = 0;
|
|
52
|
+
|
|
53
|
+
const spawnDeps: SpawnDeps = {
|
|
54
|
+
nextId: () => {
|
|
55
|
+
counter += 1;
|
|
56
|
+
return counter;
|
|
57
|
+
},
|
|
58
|
+
register(kind, n, taskId) {
|
|
59
|
+
const entry: TaskEntry = {
|
|
60
|
+
taskId,
|
|
61
|
+
kind,
|
|
62
|
+
n,
|
|
63
|
+
status: "pending",
|
|
64
|
+
createdAt: Date.now(),
|
|
65
|
+
};
|
|
66
|
+
tasks.set(taskId, entry);
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
ok: true,
|
|
69
|
+
task_id: taskId,
|
|
70
|
+
kind,
|
|
71
|
+
n,
|
|
72
|
+
status: "pending" as const,
|
|
73
|
+
hint: SPAWN_HINT,
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
resolve(taskId, result) {
|
|
77
|
+
const entry = tasks.get(taskId);
|
|
78
|
+
if (entry === undefined) return;
|
|
79
|
+
entry.status = "done";
|
|
80
|
+
if (typeof result === "string") {
|
|
81
|
+
entry.result = result;
|
|
82
|
+
} else {
|
|
83
|
+
entry.results = Object.freeze([...result]);
|
|
84
|
+
}
|
|
85
|
+
notify(waiters, taskId, entry);
|
|
86
|
+
},
|
|
87
|
+
reject(taskId, error) {
|
|
88
|
+
const entry = tasks.get(taskId);
|
|
89
|
+
if (entry === undefined) return;
|
|
90
|
+
entry.status = "error";
|
|
91
|
+
entry.error = error;
|
|
92
|
+
const w = waiters.get(taskId);
|
|
93
|
+
if (w !== undefined) {
|
|
94
|
+
if (w.timer !== undefined) clearTimeout(w.timer);
|
|
95
|
+
w.reject(new Error(error));
|
|
96
|
+
waiters.delete(taskId);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const awaitDeps: AwaitDeps = {
|
|
102
|
+
get: (taskId) => tasks.get(taskId),
|
|
103
|
+
wait(taskId, timeoutMs) {
|
|
104
|
+
return new Promise<TaskEntry>((resolve, reject) => {
|
|
105
|
+
const entry = tasks.get(taskId);
|
|
106
|
+
if (entry !== undefined && entry.status !== "pending") {
|
|
107
|
+
resolve(entry);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const timer =
|
|
111
|
+
timeoutMs !== undefined
|
|
112
|
+
? setTimeout(() => {
|
|
113
|
+
waiters.delete(taskId);
|
|
114
|
+
const e = tasks.get(taskId);
|
|
115
|
+
if (e !== undefined && e.status === "pending") {
|
|
116
|
+
e.status = "timeout";
|
|
117
|
+
e.error = `Timeout after ${timeoutMs}ms`;
|
|
118
|
+
}
|
|
119
|
+
reject(new Error(`Timeout waiting for task ${taskId}`));
|
|
120
|
+
}, timeoutMs)
|
|
121
|
+
: undefined;
|
|
122
|
+
waiters.set(taskId, { resolve, reject, timer });
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
unawaitedIds: () => {
|
|
126
|
+
const ids: string[] = [];
|
|
127
|
+
for (const [id, e] of tasks) {
|
|
128
|
+
if (e.status === "pending") ids.push(id);
|
|
129
|
+
}
|
|
130
|
+
return ids;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
spawnDeps,
|
|
136
|
+
awaitDeps,
|
|
137
|
+
entries: () => tasks,
|
|
138
|
+
toAwaitResult(taskId) {
|
|
139
|
+
const entry = tasks.get(taskId);
|
|
140
|
+
if (entry === undefined) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
task_id: taskId,
|
|
144
|
+
kind: "unknown",
|
|
145
|
+
status: "error",
|
|
146
|
+
error: `Task ${taskId} not found`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const status =
|
|
150
|
+
entry.status === "pending" ? "error" : entry.status;
|
|
151
|
+
return {
|
|
152
|
+
ok: entry.status === "done",
|
|
153
|
+
task_id: entry.taskId,
|
|
154
|
+
kind: entry.kind,
|
|
155
|
+
status,
|
|
156
|
+
result: entry.result,
|
|
157
|
+
results: entry.results,
|
|
158
|
+
error:
|
|
159
|
+
entry.error ??
|
|
160
|
+
(entry.status === "pending" ? "Task still pending" : undefined),
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Register a spawn and start background work once.
|
|
168
|
+
* Returns SpawnResult immediately; never uses non-null assertions on task_id.
|
|
169
|
+
*/
|
|
170
|
+
export function spawnAndRun(
|
|
171
|
+
sd: SpawnDeps,
|
|
172
|
+
kind: SpawnResult["kind"],
|
|
173
|
+
n: number,
|
|
174
|
+
work: () => Promise<string | readonly string[]>,
|
|
175
|
+
trackDetached: (<T>(run: () => Promise<T>) => Promise<T>) | undefined,
|
|
176
|
+
detached: boolean,
|
|
177
|
+
): SpawnResult {
|
|
178
|
+
const id = sd.nextId();
|
|
179
|
+
const taskId = `task_${id}`;
|
|
180
|
+
const spawned = sd.register(kind, n, taskId);
|
|
181
|
+
|
|
182
|
+
const run = async (): Promise<void> => {
|
|
183
|
+
try {
|
|
184
|
+
const result = await work();
|
|
185
|
+
sd.resolve(taskId, result);
|
|
186
|
+
} catch (err: unknown) {
|
|
187
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
188
|
+
sd.reject(taskId, message.startsWith("Error:") ? message : `Error: ${message}`);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
if (trackDetached !== undefined && detached) {
|
|
193
|
+
void trackDetached(run).catch((err: unknown) => {
|
|
194
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
195
|
+
sd.reject(taskId, message.startsWith("Error:") ? message : `Error: ${message}`);
|
|
196
|
+
});
|
|
197
|
+
} else {
|
|
198
|
+
void run();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return spawned;
|
|
202
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the async-by-default subcall handler set.
|
|
3
|
+
*
|
|
4
|
+
* Every subcall (llm_query, llm_batch, rlm_query, rlm_batch) returns a SpawnResult
|
|
5
|
+
* immediately with a task_id. The model must call await(task_id) to collect the
|
|
6
|
+
* real answer. This contract is proven in rlm_test (api_v5 + batch, scores 0.89–1.0).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { RlmInput, RlmResult, Sampling } from "../../core/types.ts";
|
|
12
|
+
import type { SubcallGates } from "../../util/concurrency.ts";
|
|
13
|
+
import type { SubcallOpts } from "../../sandbox/interrupts.ts";
|
|
14
|
+
import type { RlmEmitter } from "../../tool/rlm-events.ts";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Spawn / Await / Finish — the three shapes the model sees
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** Returned by every spawn: llm_query, llm_batch, rlm_query, rlm_batch. */
|
|
21
|
+
export interface SpawnResult {
|
|
22
|
+
readonly ok: boolean;
|
|
23
|
+
readonly task_id: string | null;
|
|
24
|
+
readonly kind: "llm" | "rlm" | "llm_batch" | "rlm_batch";
|
|
25
|
+
/** Number of sub-tasks in this batch (1 for singles). */
|
|
26
|
+
readonly n: number;
|
|
27
|
+
readonly status: "pending";
|
|
28
|
+
/** Hint text reminding the model to await. */
|
|
29
|
+
readonly hint: string;
|
|
30
|
+
readonly error?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Returned by await(task_id) or await(task_ids=[...]). */
|
|
34
|
+
export interface AwaitResult {
|
|
35
|
+
readonly ok: boolean;
|
|
36
|
+
readonly task_id: string;
|
|
37
|
+
readonly kind: string;
|
|
38
|
+
readonly status: "done" | "error" | "timeout";
|
|
39
|
+
/** Single result (llm_query, rlm_query). */
|
|
40
|
+
readonly result?: string;
|
|
41
|
+
/** Ordered batch results (llm_batch, rlm_batch). */
|
|
42
|
+
readonly results?: readonly string[];
|
|
43
|
+
readonly error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Returned by finish(summary) — the contract boundary. */
|
|
47
|
+
export interface FinishResult {
|
|
48
|
+
readonly ok: true;
|
|
49
|
+
readonly finished: true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Invocation — per-call context (emitter, limits, depth)
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export interface InvocationLimits {
|
|
57
|
+
remainingTimeoutMs(): number | undefined;
|
|
58
|
+
addUsage(usage: Usage): void;
|
|
59
|
+
addRaw(costUsd: number, inputTokens: number, outputTokens: number): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function limitsFromRemaining(
|
|
63
|
+
remaining?: () => { readonly timeoutMs?: number },
|
|
64
|
+
): InvocationLimits {
|
|
65
|
+
return Object.freeze({
|
|
66
|
+
remainingTimeoutMs: () => remaining?.().timeoutMs,
|
|
67
|
+
addUsage: (_usage: Usage) => {},
|
|
68
|
+
addRaw: (_costUsd: number, _inputTokens: number, _outputTokens: number) => {},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface Invocation {
|
|
73
|
+
readonly emitter: RlmEmitter;
|
|
74
|
+
readonly parentId: string | undefined;
|
|
75
|
+
readonly depth: number;
|
|
76
|
+
readonly limits: InvocationLimits;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Handler dependencies — supplied by the engine or repl() tool
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export interface SubcallConfig {
|
|
84
|
+
readonly maxPromptChars: number;
|
|
85
|
+
readonly maxDepth: number;
|
|
86
|
+
readonly subSampling?: Sampling;
|
|
87
|
+
readonly subSystemPrompt?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface SubcallHandlerDeps {
|
|
91
|
+
/** Resolve the invocation for a given interrupt (emitter + limits). */
|
|
92
|
+
readonly resolve: (opts: SubcallOpts, depth: number) => Invocation | null;
|
|
93
|
+
/** Session-wide concurrency gates. */
|
|
94
|
+
readonly gates: SubcallGates;
|
|
95
|
+
readonly registry: ModelRegistry;
|
|
96
|
+
readonly getLlmModel: () => Model<Api>;
|
|
97
|
+
readonly getConfig: () => SubcallConfig;
|
|
98
|
+
readonly signal?: AbortSignal;
|
|
99
|
+
readonly onUsage?: (usage: Usage, role: "sub") => void;
|
|
100
|
+
// Recursion
|
|
101
|
+
readonly runChild?: (input: RlmInput, inv: Invocation) => Promise<RlmResult>;
|
|
102
|
+
readonly getChildContext?: () => unknown;
|
|
103
|
+
readonly getModel?: () => Model<Api>;
|
|
104
|
+
readonly degrade?: (prompt: string, depth: number) => Promise<string>;
|
|
105
|
+
readonly onChildUsage?: (costUsd: number, inputTokens: number, outputTokens: number) => void;
|
|
106
|
+
readonly trackDetached?: <T>(run: () => Promise<T>) => Promise<T>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Handler set returned to the sandbox (canonical names only)
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/** Canonical api_v5 handler set — no legacy aliases. */
|
|
114
|
+
export interface SubcallHandlers {
|
|
115
|
+
readonly llmQuery: (prompt: string, depth: number, opts: SubcallOpts) => Promise<SpawnResult>;
|
|
116
|
+
readonly llmBatch: (prompts: readonly string[], depth: number, opts: SubcallOpts) => Promise<SpawnResult>;
|
|
117
|
+
readonly rlmQuery: (task: string, depth: number, opts: SubcallOpts) => Promise<SpawnResult>;
|
|
118
|
+
readonly rlmBatch: (tasks: readonly string[], depth: number, opts: SubcallOpts) => Promise<SpawnResult>;
|
|
119
|
+
readonly awaitTask: (taskId: string | undefined, taskIds: readonly string[] | undefined, timeoutS: number | undefined, depth: number, opts: SubcallOpts) => Promise<AwaitResult>;
|
|
120
|
+
readonly finishTask: (summary: string, depth: number, opts: SubcallOpts) => Promise<FinishResult>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Task registry entry — tracks in-flight spawns
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
export interface TaskEntry {
|
|
128
|
+
readonly taskId: string;
|
|
129
|
+
readonly kind: "llm" | "rlm" | "llm_batch" | "rlm_batch";
|
|
130
|
+
readonly n: number;
|
|
131
|
+
status: "pending" | "done" | "error" | "timeout";
|
|
132
|
+
result?: string;
|
|
133
|
+
results?: readonly string[];
|
|
134
|
+
error?: string;
|
|
135
|
+
readonly createdAt: number;
|
|
136
|
+
}
|
|
@@ -8,7 +8,7 @@ import type { RlmController } from "../mode/rlm-mode.ts";
|
|
|
8
8
|
import { cheapestModel } from "../mode/llm-model.ts";
|
|
9
9
|
import { setRlmModeStatus } from "../ui/status.ts";
|
|
10
10
|
import { showConfigPanel } from "../ui/config-panel.ts";
|
|
11
|
-
import { pickableModels, selectModel } from "../ui/model-picker.ts";
|
|
11
|
+
import { pickableModels, selectModel, type ModelSelection } from "../ui/model-picker.ts";
|
|
12
12
|
|
|
13
13
|
/** Newer Pi hosts expose session-scoped models; 0.79 peers do not — duck-type safely. */
|
|
14
14
|
function sessionScopedModels(
|
|
@@ -18,6 +18,34 @@ function sessionScopedModels(
|
|
|
18
18
|
return Array.isArray(scoped) ? scoped as readonly { readonly model: Model<Api> }[] : undefined;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Apply a model-picker result to controller pin state.
|
|
23
|
+
*
|
|
24
|
+
* - `null` → explicit "cheapest (auto)" (clear pin; leave reasoning alone)
|
|
25
|
+
* - `ModelSelection` → pin that model (and its thinking level, which may be undefined)
|
|
26
|
+
* - `undefined` → ESC / no change
|
|
27
|
+
*/
|
|
28
|
+
export function applyLlmSelection(
|
|
29
|
+
controller: RlmController,
|
|
30
|
+
llm: ModelSelection | null | undefined,
|
|
31
|
+
): void {
|
|
32
|
+
if (llm === undefined) return;
|
|
33
|
+
if (llm === null) {
|
|
34
|
+
controller.llmModel = undefined;
|
|
35
|
+
controller.savedLlmRef = undefined;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
controller.llmModel = llm.model;
|
|
39
|
+
controller.savedLlmRef = modelRef(llm.model);
|
|
40
|
+
controller.setConfig(Object.freeze({
|
|
41
|
+
...controller.config,
|
|
42
|
+
subSampling: Object.freeze({
|
|
43
|
+
...controller.config.subSampling,
|
|
44
|
+
reasoning: llm.thinkingLevel,
|
|
45
|
+
}),
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
21
49
|
export async function runRlmConfig(controller: RlmController, ctx: ExtensionContext): Promise<boolean> {
|
|
22
50
|
// Match Pi's native list: refresh so a just-added key appears, then use scoped models when
|
|
23
51
|
// the session narrowed them, else every available (auth-configured) model. Never getAll().
|
|
@@ -34,23 +62,14 @@ export async function runRlmConfig(controller: RlmController, ctx: ExtensionCont
|
|
|
34
62
|
models,
|
|
35
63
|
controller.llmModel,
|
|
36
64
|
controller.config.subSampling.reasoning,
|
|
65
|
+
controller.savedLlmRef,
|
|
37
66
|
);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
...controller.config,
|
|
42
|
-
subSampling: Object.freeze({ ...controller.config.subSampling, reasoning: llm?.thinkingLevel }),
|
|
43
|
-
}));
|
|
44
|
-
}
|
|
67
|
+
// Only an explicit choice touches the pin. ESC leaves model + reasoning alone.
|
|
68
|
+
// Choosing cheapest must NOT wipe subSampling.reasoning (null !== undefined used to).
|
|
69
|
+
applyLlmSelection(controller, llm);
|
|
45
70
|
|
|
46
71
|
controller.setConfig(await showConfigPanel(ctx, controller.config));
|
|
47
72
|
|
|
48
|
-
// Only an explicit choice touches the persisted pin. ESC (`undefined`) used to fall through
|
|
49
|
-
// here and freeze whatever cheapest resolved to at that moment, which silently ended
|
|
50
|
-
// "cheapest (auto)" for every later session — including once a cheaper model appeared.
|
|
51
|
-
if (llm === null) controller.savedLlmRef = undefined; // "⟳ cheapest (auto)"
|
|
52
|
-
else if (llm !== undefined) controller.savedLlmRef = modelRef(llm.model);
|
|
53
|
-
|
|
54
73
|
const persisted = await controller.persist();
|
|
55
74
|
if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
|
|
56
75
|
setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
|
package/src/context/listing.ts
CHANGED
|
@@ -23,7 +23,7 @@ export function formatContextListing(context: unknown): string {
|
|
|
23
23
|
'Use `add_context("/path/to/dir")` / `add_context("docs.pdf")` / `add_context("https://…")` for external sources.',
|
|
24
24
|
"Documents (PDF, DOCX, XLSX, PPTX, CSV, …) are converted to Markdown on the way in.",
|
|
25
25
|
"",
|
|
26
|
-
"Use repl({code}) and delegate semantic reading to llm_query /
|
|
26
|
+
"Use repl({code}) and delegate semantic reading to llm_query / llm_batch / llm_query_chunked.",
|
|
27
27
|
].join("\n");
|
|
28
28
|
}
|
|
29
29
|
|
|
@@ -53,7 +53,7 @@ export function formatContextListing(context: unknown): string {
|
|
|
53
53
|
truncated,
|
|
54
54
|
"",
|
|
55
55
|
"File contents are loaded in the REPL `context` variable — file-reading tools are disabled.",
|
|
56
|
-
"Use repl({code}) and delegate semantic reading to llm_query /
|
|
56
|
+
"Use repl({code}) and delegate semantic reading to llm_query / llm_batch / llm_query_chunked.",
|
|
57
57
|
].join("\n");
|
|
58
58
|
}
|
|
59
59
|
|