@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.
- package/README.md +131 -154
- package/README.ru.md +5 -5
- package/README.zh-CN.md +5 -5
- package/package.json +3 -2
- 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 +40 -12
- package/src/config/settings.ts +13 -3
- 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 +59 -54
- package/src/mode/native-guards.ts +4 -4
- package/src/mode/rlm-mode.ts +6 -1
- package/src/mode/subagent.ts +1 -1
- 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 +8 -5
- package/src/sandbox/py/retrieval.py +17 -8
- package/src/sandbox/py/tasks.py +1 -1
- package/src/sandbox/py/worker.py +106 -79
- package/src/sandbox/sandbox-manager.ts +26 -1
- package/src/sandbox/sandbox.ts +1 -1
- 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 +32 -2
- package/src/util/concurrency.ts +1 -1
- package/src/bridge/subcall-handlers.ts +0 -382
|
@@ -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,36 @@ 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
|
+
controller.explicitClearPin = true;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
controller.llmModel = llm.model;
|
|
40
|
+
controller.savedLlmRef = modelRef(llm.model);
|
|
41
|
+
controller.explicitClearPin = false;
|
|
42
|
+
controller.setConfig(Object.freeze({
|
|
43
|
+
...controller.config,
|
|
44
|
+
subSampling: Object.freeze({
|
|
45
|
+
...controller.config.subSampling,
|
|
46
|
+
reasoning: llm.thinkingLevel,
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
21
51
|
export async function runRlmConfig(controller: RlmController, ctx: ExtensionContext): Promise<boolean> {
|
|
22
52
|
// Match Pi's native list: refresh so a just-added key appears, then use scoped models when
|
|
23
53
|
// the session narrowed them, else every available (auth-configured) model. Never getAll().
|
|
@@ -34,23 +64,21 @@ export async function runRlmConfig(controller: RlmController, ctx: ExtensionCont
|
|
|
34
64
|
models,
|
|
35
65
|
controller.llmModel,
|
|
36
66
|
controller.config.subSampling.reasoning,
|
|
67
|
+
controller.savedLlmRef,
|
|
37
68
|
);
|
|
69
|
+
// Only an explicit choice touches the pin. ESC leaves model + reasoning alone.
|
|
70
|
+
// Choosing cheapest must NOT wipe subSampling.reasoning (null !== undefined used to).
|
|
71
|
+
applyLlmSelection(controller, llm);
|
|
72
|
+
|
|
73
|
+
// Persist model choice immediately — if showConfigPanel throws or process exits before it
|
|
74
|
+
// returns, the pin survives (Root Cause #2, v0.3.2).
|
|
38
75
|
if (llm !== undefined) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
...controller.config,
|
|
42
|
-
subSampling: Object.freeze({ ...controller.config.subSampling, reasoning: llm?.thinkingLevel }),
|
|
43
|
-
}));
|
|
76
|
+
const saved = await controller.persist();
|
|
77
|
+
if (!saved) ctx.ui.notify("RLM: failed to save llm setting", "error");
|
|
44
78
|
}
|
|
45
79
|
|
|
46
80
|
controller.setConfig(await showConfigPanel(ctx, controller.config));
|
|
47
81
|
|
|
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
82
|
const persisted = await controller.persist();
|
|
55
83
|
if (!persisted) ctx.ui.notify("RLM: failed to save settings to ~/.pi/agent/rlm.json", "error");
|
|
56
84
|
setRlmModeStatus(ctx.ui, controller, ctx.getContextUsage());
|
package/src/config/settings.ts
CHANGED
|
@@ -9,8 +9,9 @@ import { DEFAULT_CONFIG } from "./defaults.ts";
|
|
|
9
9
|
|
|
10
10
|
export interface PersistedSettings {
|
|
11
11
|
readonly config: Partial<RlmConfig>;
|
|
12
|
-
/** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)".
|
|
13
|
-
|
|
12
|
+
/** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)".
|
|
13
|
+
* `null` = explicit "cheapest" clear (omit key on disk). */
|
|
14
|
+
readonly llm?: string | null;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
type MutablePartialRlmConfig = { -readonly [K in keyof RlmConfig]?: RlmConfig[K] };
|
|
@@ -133,7 +134,16 @@ export async function saveSettings(s: PersistedSettings): Promise<boolean> {
|
|
|
133
134
|
try {
|
|
134
135
|
const p = settingsPath();
|
|
135
136
|
await mkdir(dirname(p), { recursive: true });
|
|
136
|
-
|
|
137
|
+
const body: Record<string, unknown> = { config: s.config };
|
|
138
|
+
if (s.llm !== undefined) {
|
|
139
|
+
// Explicit: string → write pin, null → omit key (cheapest).
|
|
140
|
+
if (s.llm !== null) body.llm = s.llm;
|
|
141
|
+
} else {
|
|
142
|
+
// Merge: preserve existing disk pin so config-only saves never strip it.
|
|
143
|
+
const existing = await loadSettings();
|
|
144
|
+
if (existing.llm) body.llm = existing.llm;
|
|
145
|
+
}
|
|
146
|
+
await writeFile(p, `${JSON.stringify(body, null, 2)}\n`);
|
|
137
147
|
return true;
|
|
138
148
|
} catch {
|
|
139
149
|
return false;
|
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
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep RLM `context` in sync with the disk after native edit/write.
|
|
3
|
+
*
|
|
4
|
+
* Seed packs file bodies once; without this, search/map_files/llm still see pre-edit text.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
9
|
+
import { estimateTokens } from "../text/tokens.ts";
|
|
10
|
+
import type { ContextFile } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
/** Paths that look like tool file targets. */
|
|
13
|
+
export function extractEditPaths(input: unknown): readonly string[] {
|
|
14
|
+
if (typeof input !== "object" || input === null) return Object.freeze([]);
|
|
15
|
+
const o = input as Record<string, unknown>;
|
|
16
|
+
const keys = ["path", "file_path", "filePath", "filename", "file"] as const;
|
|
17
|
+
const out: string[] = [];
|
|
18
|
+
for (const k of keys) {
|
|
19
|
+
const v = o[k];
|
|
20
|
+
if (typeof v === "string" && v.trim() !== "") out.push(v.trim());
|
|
21
|
+
}
|
|
22
|
+
// Some tools pass { path, oldText, newText } only — already covered.
|
|
23
|
+
return Object.freeze(out);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Normalize disk path to how cwd-seed entries usually appear (relative to cwd when under cwd).
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeContextPath(filePath: string, cwd: string): string {
|
|
30
|
+
const abs = isAbsolute(filePath) ? resolve(filePath) : resolve(cwd, filePath);
|
|
31
|
+
const rel = relative(cwd, abs);
|
|
32
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return abs;
|
|
33
|
+
return rel.split("\\").join("/");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pathMatches(entryPath: string, target: string, cwd: string): boolean {
|
|
37
|
+
if (entryPath === target) return true;
|
|
38
|
+
const a = normalizeContextPath(entryPath, cwd);
|
|
39
|
+
const b = normalizeContextPath(target, cwd);
|
|
40
|
+
if (a === b) return true;
|
|
41
|
+
// suffix match for namespaced entries
|
|
42
|
+
return entryPath.endsWith("/" + target) || entryPath.endsWith(target);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Upsert one file into a context payload list. Returns a **new** array (identity change
|
|
47
|
+
* so BM25 stamp invalidates when the worker rebinds `context`).
|
|
48
|
+
*/
|
|
49
|
+
export function upsertContextFile(
|
|
50
|
+
payload: unknown,
|
|
51
|
+
filePath: string,
|
|
52
|
+
content: string,
|
|
53
|
+
cwd: string,
|
|
54
|
+
): ContextFile[] {
|
|
55
|
+
const path = normalizeContextPath(filePath, cwd);
|
|
56
|
+
const tokens = estimateTokens(content.length);
|
|
57
|
+
const entry: ContextFile = Object.freeze({ path, content, tokens });
|
|
58
|
+
|
|
59
|
+
if (!Array.isArray(payload)) {
|
|
60
|
+
return [entry];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const next = new Array<ContextFile>(payload.length + 1);
|
|
64
|
+
let n = 0;
|
|
65
|
+
let replaced = false;
|
|
66
|
+
for (let i = 0; i < payload.length; i++) {
|
|
67
|
+
const item: unknown = payload[i];
|
|
68
|
+
if (
|
|
69
|
+
item !== null &&
|
|
70
|
+
typeof item === "object" &&
|
|
71
|
+
"path" in item &&
|
|
72
|
+
typeof (item as { path: unknown }).path === "string" &&
|
|
73
|
+
pathMatches((item as { path: string }).path, path, cwd)
|
|
74
|
+
) {
|
|
75
|
+
next[n++] = entry;
|
|
76
|
+
replaced = true;
|
|
77
|
+
} else if (
|
|
78
|
+
item !== null &&
|
|
79
|
+
typeof item === "object" &&
|
|
80
|
+
"path" in item &&
|
|
81
|
+
"content" in item &&
|
|
82
|
+
typeof (item as { path: unknown }).path === "string" &&
|
|
83
|
+
typeof (item as { content: unknown }).content === "string"
|
|
84
|
+
) {
|
|
85
|
+
const e = item as { path: string; content: string; tokens?: number };
|
|
86
|
+
next[n++] = Object.freeze({
|
|
87
|
+
path: e.path,
|
|
88
|
+
content: e.content,
|
|
89
|
+
tokens: typeof e.tokens === "number" ? e.tokens : estimateTokens(e.content.length),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// drop non-file entries silently (shouldn't appear in file bundles)
|
|
93
|
+
}
|
|
94
|
+
if (!replaced) next[n++] = entry;
|
|
95
|
+
next.length = n;
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read file from disk; return null if missing/unreadable. */
|
|
100
|
+
export async function readDiskFile(filePath: string, cwd: string): Promise<string | null> {
|
|
101
|
+
const abs = isAbsolute(filePath) ? resolve(filePath) : resolve(cwd, filePath);
|
|
102
|
+
try {
|
|
103
|
+
return await readFile(abs, "utf8");
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Python snippet that rebinds `context` with updated path content and forces a new list id
|
|
111
|
+
* so BM25 rebuilds on next search.
|
|
112
|
+
*/
|
|
113
|
+
export function patchContextExecCode(filePath: string, content: string, cwd: string): string {
|
|
114
|
+
const path = normalizeContextPath(filePath, cwd);
|
|
115
|
+
// JSON for safe embedding in Python string literals
|
|
116
|
+
const pathLit = JSON.stringify(path);
|
|
117
|
+
const contentLit = JSON.stringify(content);
|
|
118
|
+
const tokens = estimateTokens(content.length);
|
|
119
|
+
return `
|
|
120
|
+
_path = ${pathLit}
|
|
121
|
+
_content = ${contentLit}
|
|
122
|
+
_tokens = ${tokens}
|
|
123
|
+
_old = context if isinstance(context, list) else []
|
|
124
|
+
_next = []
|
|
125
|
+
_found = False
|
|
126
|
+
for _e in _old:
|
|
127
|
+
if isinstance(_e, dict) and str(_e.get("path", "")) in (_path, _path.replace("\\\\", "/")):
|
|
128
|
+
_next.append({"path": _path, "content": _content, "tokens": _tokens})
|
|
129
|
+
_found = True
|
|
130
|
+
elif isinstance(_e, dict) and (
|
|
131
|
+
str(_e.get("path", "")).endswith("/" + _path) or str(_e.get("path", "")).endswith(_path)
|
|
132
|
+
):
|
|
133
|
+
_next.append({"path": str(_e.get("path")), "content": _content, "tokens": _tokens})
|
|
134
|
+
_found = True
|
|
135
|
+
else:
|
|
136
|
+
_next.append(_e)
|
|
137
|
+
if not _found:
|
|
138
|
+
_next.append({"path": _path, "content": _content, "tokens": _tokens})
|
|
139
|
+
context = _next
|
|
140
|
+
`.trim();
|
|
141
|
+
}
|
package/src/core/engine.ts
CHANGED
|
@@ -13,10 +13,10 @@ import { buildAddContextHandler } from "../bridge/add-context.ts";
|
|
|
13
13
|
import { mergeIntoContext } from "../context/merge.ts";
|
|
14
14
|
import {
|
|
15
15
|
createSubcallHandlers,
|
|
16
|
+
createTaskRegistry,
|
|
16
17
|
type Invocation,
|
|
17
|
-
} from "../bridge/
|
|
18
|
+
} from "../bridge/handlers/index.ts";
|
|
18
19
|
import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
19
|
-
import { resolveModelId } from "../config/settings.ts";
|
|
20
20
|
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
21
21
|
import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
|
|
22
22
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
@@ -30,7 +30,6 @@ import { appendUserMessage } from "./history.ts";
|
|
|
30
30
|
import { runTurn } from "./iteration.ts";
|
|
31
31
|
import { type Limits, LimitError, LimitGuard } from "./limits.ts";
|
|
32
32
|
import type { RlmConfig, RlmInput, RlmResult, RunRlm, Sampling } from "./types.ts";
|
|
33
|
-
import { formatError } from "../util/errors.ts";
|
|
34
33
|
import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
|
|
35
34
|
|
|
36
35
|
/**
|
|
@@ -71,20 +70,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
71
70
|
emitter.emitTurn(0, deps.config.maxIterations);
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
const
|
|
75
|
-
if (input.modelOverride && !overrideModel) {
|
|
76
|
-
if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, status: "error", detail: "unknown model override" });
|
|
77
|
-
else emitter.emitStatus("error");
|
|
78
|
-
return {
|
|
79
|
-
answer: formatError(`unknown model override '${input.modelOverride}'`),
|
|
80
|
-
iterations: 0,
|
|
81
|
-
costUsd: 0,
|
|
82
|
-
inputTokens: 0,
|
|
83
|
-
outputTokens: 0,
|
|
84
|
-
durationMs: 0,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
const model = overrideModel ?? deps.model;
|
|
73
|
+
const model = deps.model;
|
|
88
74
|
|
|
89
75
|
// Create LimitGuard BEFORE the bridge so sub-LLM usage feeds into it.
|
|
90
76
|
// Children inherit the parent's remaining timeout (propagated as remaining amount, not
|
|
@@ -117,6 +103,8 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
117
103
|
// run can settle or abort it first (a child engine left running would keep spending).
|
|
118
104
|
let detachedInFlight = 0;
|
|
119
105
|
let detachedIdle: (() => void) | undefined;
|
|
106
|
+
// One registry per run — unawaited task reminders share the same map as await handlers.
|
|
107
|
+
const taskRegistry = createTaskRegistry();
|
|
120
108
|
const subcalls = createSubcallHandlers({
|
|
121
109
|
resolve: () => invocation,
|
|
122
110
|
gates: deps.gates
|
|
@@ -140,7 +128,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
140
128
|
if (detachedInFlight === 0) detachedIdle?.();
|
|
141
129
|
}
|
|
142
130
|
},
|
|
143
|
-
});
|
|
131
|
+
}, taskRegistry);
|
|
144
132
|
/** Wait (bounded) for detached work before the sandbox goes away. */
|
|
145
133
|
const settleDetached = async (): Promise<void> => {
|
|
146
134
|
if (detachedInFlight === 0) return;
|
|
@@ -189,6 +177,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
189
177
|
maxPromptChars: deps.config.maxPromptChars,
|
|
190
178
|
contextLoader: deps.config.contextLoader,
|
|
191
179
|
child: input.depth > 0,
|
|
180
|
+
depth: input.depth,
|
|
192
181
|
});
|
|
193
182
|
|
|
194
183
|
const contextHandlers = deps.config.contextLoader
|
|
@@ -251,6 +240,15 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
251
240
|
pendingReplOutputs = undefined;
|
|
252
241
|
}
|
|
253
242
|
|
|
243
|
+
// Soft runtime nudge (rlm_test parity): remind the model to await pending host tasks.
|
|
244
|
+
const pendingIds = taskRegistry.awaitDeps.unawaitedIds();
|
|
245
|
+
if (pendingIds.length > 0) {
|
|
246
|
+
appendUserMessage(
|
|
247
|
+
history,
|
|
248
|
+
`[runtime] Unawaited task_ids: ${pendingIds.join(", ")} — call await before finish.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
254
252
|
appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations));
|
|
255
253
|
|
|
256
254
|
// rootSampling fields win; smartReasoning is the default reasoning when not overridden.
|