@hicaru/pi-rlm 0.1.7 → 0.1.8
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 +35 -1
- package/package.json +1 -1
- package/src/bridge/library.ts +77 -0
- package/src/bridge/rlm-query.ts +58 -12
- package/src/config/defaults.ts +2 -0
- package/src/config/settings.ts +4 -0
- package/src/context/library-context.ts +79 -0
- package/src/core/artifacts.ts +88 -0
- package/src/core/engine.ts +432 -35
- package/src/core/gates.ts +272 -0
- package/src/core/iteration.ts +7 -2
- package/src/core/pipeline.ts +170 -27
- package/src/core/types.ts +4 -0
- package/src/index.ts +6 -1
- package/src/prompts/phases.ts +125 -0
- package/src/prompts/system.ts +38 -7
- package/src/prompts/user.ts +12 -4
- package/src/sandbox/protocol.ts +25 -2
- package/src/sandbox/sandbox.ts +42 -1
- package/src/sandbox/worker.py +42 -5
- package/src/state/index.ts +2 -1
- package/src/state/paths.ts +4 -2
- package/src/state/reads.ts +31 -2
- package/src/state/resume.ts +19 -1
- package/src/state/rows.ts +6 -0
- package/src/state/writes.ts +5 -3
- package/src/text/edits.ts +148 -0
- package/src/tool/apply-edits-tool.ts +36 -29
- package/src/tool/repl-tool.ts +23 -2
- package/src/tool/rlm-tool.ts +0 -1
- package/src/ui/config-panel.ts +8 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-phase guidance for the gated RLM pipeline.
|
|
3
|
+
* Injected at each phase boundary; the only channel between phases is durable
|
|
4
|
+
* artifacts under .rlm/artifacts/.
|
|
5
|
+
*/
|
|
6
|
+
import type { PhaseRecord } from "../core/gates.ts";
|
|
7
|
+
import type { Phase } from "../core/pipeline.ts";
|
|
8
|
+
|
|
9
|
+
const CLARIFY_GUIDANCE = Object.freeze([
|
|
10
|
+
"## Clarify phase (intake interview)",
|
|
11
|
+
"Interview the user until the task is understood. The engine COUNTS ask_user_question",
|
|
12
|
+
"rounds it services — you cannot advance without having actually asked at least once.",
|
|
13
|
+
"",
|
|
14
|
+
"1. Intent first: ask ONE open-ended intent question (what problem, who hits it, what",
|
|
15
|
+
" success looks like). Options are answer *shapes* (e.g. end user / maintainer / operator);",
|
|
16
|
+
" do NOT mark a Recommended option. The automatic Other / free-text path carries the real framing.",
|
|
17
|
+
" Up to 2 more intent rounds if the answer still cannot scope a narrow probe; then proceed",
|
|
18
|
+
" with what you have.",
|
|
19
|
+
"2. Probe before asking more: ground follow-ups in the REPL (deterministic search over",
|
|
20
|
+
" context, a few llm_query reads) so questions cite real code as file:line.",
|
|
21
|
+
"3. Confirm inferences, don't record them: batch inferred decisions into one call —",
|
|
22
|
+
" \"From the code I inferred <behavior> (file:line). Keep or change?\" The user's answer",
|
|
23
|
+
" is the Decision, not your inference.",
|
|
24
|
+
"4. Scope / shape / detail rounds, one at a time: recommended option first; architecture",
|
|
25
|
+
" (shape) options must state the tradeoff (\"optimizes X, costs Y\") — never one option",
|
|
26
|
+
" masquerading as a choice; 2–4 independent detail questions may batch in one call.",
|
|
27
|
+
"5. Classify answers: Decision → record; Correction → re-probe that seam, re-ask dependents;",
|
|
28
|
+
" Defer → ## Open Questions.",
|
|
29
|
+
"6. Terminate on depth, not politeness: stop when every raised branch has a Decision or a",
|
|
30
|
+
" Deferral and ## Problem & Intent holds the user's own words verbatim. Do not pad the",
|
|
31
|
+
" interview; do not ask a final \"looks good?\" rubber-stamp.",
|
|
32
|
+
"7. Then save_artifact(\"clarification\", content) with frontmatter:",
|
|
33
|
+
" status: ready",
|
|
34
|
+
" decisions_count: N",
|
|
35
|
+
" open_questions_count: M",
|
|
36
|
+
" and body sections:",
|
|
37
|
+
" ## Problem & Intent (user's words VERBATIM)",
|
|
38
|
+
" ## Decisions (one '- ' bullet per decision)",
|
|
39
|
+
" ## Open Questions (one '- ' bullet per deferral; may be empty with count 0)",
|
|
40
|
+
" ## Non-Goals",
|
|
41
|
+
' Then advance_phase("research", summary).',
|
|
42
|
+
].join("\n"));
|
|
43
|
+
|
|
44
|
+
const RESEARCH_GUIDANCE = Object.freeze([
|
|
45
|
+
"## Research phase",
|
|
46
|
+
"Read the clarifications artifact (if present) and honor recorded Decisions; do not silently",
|
|
47
|
+
"resolve Open Questions.",
|
|
48
|
+
"Probe the repository, delegate long reads via `llm_query_batched` / `llm_query_chunked`.",
|
|
49
|
+
"Every factual claim about the code MUST be cited as `path/file.ext:LINE` (or `LINE-LINE`).",
|
|
50
|
+
"The engine VERIFIES citations against the working tree — unbacked citations reject advance.",
|
|
51
|
+
"When research is complete, write ONE research document and call:",
|
|
52
|
+
' `save_artifact("research", content)` # frontmatter must include `status: ready`',
|
|
53
|
+
' `advance_phase("blueprint", summary)`',
|
|
54
|
+
"Do not implement code in this phase.",
|
|
55
|
+
].join("\n"));
|
|
56
|
+
|
|
57
|
+
const BLUEPRINT_GUIDANCE = Object.freeze([
|
|
58
|
+
"## Blueprint phase",
|
|
59
|
+
"Read the clarifications artifact (if present) and plan only within recorded Decisions;",
|
|
60
|
+
"Open Questions that would block the design must be re-asked via ask_user_question, not assumed.",
|
|
61
|
+
'Produce ONE plan document and save it with `save_artifact("plan", content)`.',
|
|
62
|
+
"The ENGINE derive-checks the document — these are hard gates, not suggestions:",
|
|
63
|
+
"- frontmatter: `status: ready`, `phase_count: N`, `phases: [{n: 1, title: ...}, ...]`",
|
|
64
|
+
"- `phases:` / `phase_count` MUST match the `## Phase N:` body headings exactly (fenced examples ignored)",
|
|
65
|
+
"- every `file:line` citation MUST resolve against the working tree at this revision",
|
|
66
|
+
"Each `## Phase N: <title>` section contains:",
|
|
67
|
+
"- `### Changes Required` — per file: path + exact intended change (code from research)",
|
|
68
|
+
"- `### Success Criteria` — `#### Automated Verification:` (runnable commands) and",
|
|
69
|
+
" `#### Manual Verification:` checklists",
|
|
70
|
+
"Phases are executed by ISOLATED workers, one at a time, in order: each phase must be",
|
|
71
|
+
"independently implementable, leave the tree working, and never share a file with a",
|
|
72
|
+
"later phase unless that phase only EDITS what an earlier phase CREATED.",
|
|
73
|
+
'When ready: `advance_phase("implement", summary)` — the engine runs implement fanout itself.',
|
|
74
|
+
].join("\n"));
|
|
75
|
+
|
|
76
|
+
const IMPLEMENT_GUIDANCE = Object.freeze([
|
|
77
|
+
"## Implement phase",
|
|
78
|
+
"The engine drives serial child-RLM workers over each plan phase — you do not implement",
|
|
79
|
+
"here. If you were re-entered into implement unexpectedly, call",
|
|
80
|
+
'`advance_phase("validate")` only after the fanout has already completed.',
|
|
81
|
+
].join("\n"));
|
|
82
|
+
|
|
83
|
+
const VALIDATE_GUIDANCE = Object.freeze([
|
|
84
|
+
"## Validate phase",
|
|
85
|
+
"Read the goal artifact (verbatim brief), the clarifications artifact (recorded Decisions),",
|
|
86
|
+
"and the plan. Check each phase's Success Criteria against the working tree.",
|
|
87
|
+
"Exclude paths listed in the baseline JSON (pre-existing dirt).",
|
|
88
|
+
"Open Questions are not silently resolved — a blocking one surfaces via ask_user_question.",
|
|
89
|
+
"Write ONE validation document via `save_artifact(\"validation\", content)` with frontmatter:",
|
|
90
|
+
"- `status: ready`",
|
|
91
|
+
"- `blockers_count: <int ≥ 0>` # MEASURED — routing key; not prose",
|
|
92
|
+
"- `verdict: pass | fail` # pass requires blockers_count === 0",
|
|
93
|
+
"Each blocker needs a resolvable `file:line` citation.",
|
|
94
|
+
"Then finalize: `answer[\"content\"] = <report>; answer[\"ready\"] = True`.",
|
|
95
|
+
"If `blockers_count > 0`, the engine re-enters blueprint (bounded by maxBackwardJumps).",
|
|
96
|
+
].join("\n"));
|
|
97
|
+
|
|
98
|
+
const GUIDANCE: Readonly<Record<Phase, string>> = Object.freeze({
|
|
99
|
+
clarify: CLARIFY_GUIDANCE,
|
|
100
|
+
research: RESEARCH_GUIDANCE,
|
|
101
|
+
blueprint: BLUEPRINT_GUIDANCE,
|
|
102
|
+
implement: IMPLEMENT_GUIDANCE,
|
|
103
|
+
validate: VALIDATE_GUIDANCE,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export function phaseGuidance(phase: Phase): string {
|
|
107
|
+
return GUIDANCE[phase];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Single-phase implement prompt for an isolated child RLM (serial fanout unit). */
|
|
111
|
+
export function buildImplementPhasePrompt(planPath: string, r: PhaseRecord): string {
|
|
112
|
+
return [
|
|
113
|
+
`Implement ONLY Phase ${r.n} (${r.title}) of the plan at ${planPath} (${r.index + 1}/${r.total}).`,
|
|
114
|
+
`Read the plan from the REPL (open("${planPath}").read()).`,
|
|
115
|
+
"Hard rules (you are one unit of a sequenced run):",
|
|
116
|
+
"- CRITICAL: `context` is a STALE snapshot from run start. Always `open(path).read()` any file",
|
|
117
|
+
" you will edit BEFORE computing stage_edit anchors — earlier phases may have already changed them.",
|
|
118
|
+
"- Touch ONLY the files this phase's ### Changes Required lists.",
|
|
119
|
+
"- Earlier phases have already landed; a missing prerequisite file is a HARD ERROR —",
|
|
120
|
+
" finalize with 'Error: prerequisite missing: <path>' instead of creating it.",
|
|
121
|
+
"- Stage every change via stage_edit(path, old_text, new_text); never defer your own edits.",
|
|
122
|
+
"- Run only THIS phase's '#### Automated Verification:' commands; whole-plan checks are validate's job.",
|
|
123
|
+
`Finalize with a short summary of what landed for Phase ${r.n}.`,
|
|
124
|
+
].join("\n");
|
|
125
|
+
}
|
package/src/prompts/system.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface SystemPromptOptions {
|
|
|
20
20
|
readonly todo?: boolean;
|
|
21
21
|
readonly pipeline?: boolean;
|
|
22
22
|
readonly maxPromptChars?: number;
|
|
23
|
+
readonly libraryLoader?: boolean;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export type ContextKind = "files" | "text";
|
|
@@ -76,7 +77,14 @@ function howToRunCode(): string {
|
|
|
76
77
|
].join(" ");
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
function replGlossary(
|
|
80
|
+
function replGlossary(
|
|
81
|
+
kind: ContextKind,
|
|
82
|
+
recursion: boolean,
|
|
83
|
+
askUserQuestion: boolean,
|
|
84
|
+
todo: boolean,
|
|
85
|
+
pipeline: boolean,
|
|
86
|
+
libraryLoader: boolean,
|
|
87
|
+
): string {
|
|
80
88
|
const lines = ["Available in the REPL:"];
|
|
81
89
|
if (kind === "text") {
|
|
82
90
|
lines.push(
|
|
@@ -116,8 +124,9 @@ function replGlossary(kind: ContextKind, recursion: boolean, askUserQuestion: bo
|
|
|
116
124
|
"- `ask_user_question(questions: list[dict]) -> list[dict]`: pause and present the user",
|
|
117
125
|
" with 1-4 structured questions. Each question: {question, header, options: [{label, description}],",
|
|
118
126
|
" multiSelect?}. Returns list of {question, selected: [label], custom?}.",
|
|
119
|
-
"
|
|
120
|
-
"
|
|
127
|
+
" Default: use concrete options grounded in code/data (2–4 choices, Recommended first when ranking).",
|
|
128
|
+
" Exception — clarify-phase intent rounds: lead with an open-ended intent question whose options",
|
|
129
|
+
" are answer *shapes* (not a Recommended pick); free-text / Other carries the real framing.",
|
|
121
130
|
" Only valid at root depth; returns an error inside rlm_query sub-calls.",
|
|
122
131
|
);
|
|
123
132
|
}
|
|
@@ -130,6 +139,17 @@ function replGlossary(kind: ContextKind, recursion: boolean, askUserQuestion: bo
|
|
|
130
139
|
" Use to plan multi-step work before starting, then mark tasks as you complete them.",
|
|
131
140
|
);
|
|
132
141
|
}
|
|
142
|
+
if (libraryLoader) {
|
|
143
|
+
lines.push(
|
|
144
|
+
"- `load_library(source: str) -> dict`: load an EXTERNAL library, source tree, or document into a",
|
|
145
|
+
" NEW `context_N` REPL variable. `source` may be a local directory (repomix-packed to the same",
|
|
146
|
+
" list[dict] shape as `context`), a single file path (loaded as a plain str), or an https/git@ URL",
|
|
147
|
+
" (shallow-cloned, then packed). Returns {\"index\": N, \"var\": \"context_N\", \"files\", \"chars\"}",
|
|
148
|
+
" on success or an \"Error: ...\" string. Use it when the task requires learning an external lib's",
|
|
149
|
+
" API, structure, or docs that are not in `context`; then chunk `context_N` to sub-LLMs exactly like",
|
|
150
|
+
" `context`. Do not re-load a source that is already in a slot.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
133
153
|
if (recursion) {
|
|
134
154
|
lines.push(
|
|
135
155
|
"- `rlm_query(prompt, model=None)` / `rlm_query_batched(prompts, model=None)`: recursive RLM",
|
|
@@ -148,9 +168,16 @@ function replGlossary(kind: ContextKind, recursion: boolean, askUserQuestion: bo
|
|
|
148
168
|
}
|
|
149
169
|
if (pipeline) {
|
|
150
170
|
lines.push(
|
|
151
|
-
"- `
|
|
152
|
-
"
|
|
153
|
-
"
|
|
171
|
+
"- `save_artifact(kind: str, content: str) -> str`: persist a stage artifact under `.rlm/artifacts/`.",
|
|
172
|
+
" Kinds: `'clarification'` | `'research'` | `'plan'` | `'validation'`. Must match the current phase.",
|
|
173
|
+
" Frontmatter must eventually include `status: ready` before `advance_phase` will accept the transition.",
|
|
174
|
+
"- `advance_phase(phase: str, summary=None) -> str`: transition to the next pipeline phase.",
|
|
175
|
+
" Order: 'clarify' → 'research' → 'blueprint' → 'implement' → 'validate' (one step at a time;",
|
|
176
|
+
" clarify is skipped when ask_user_question is disabled).",
|
|
177
|
+
" **advance_phase is validated by the engine** — it measures the latest saved artifact",
|
|
178
|
+
" (status, structure, citations, blockers_count; clarify also requires ≥1 ask_user_question round).",
|
|
179
|
+
" A rejected transition returns the gate error for you to fix; the phase does NOT advance.",
|
|
180
|
+
" Only callable at root depth.",
|
|
154
181
|
);
|
|
155
182
|
}
|
|
156
183
|
lines.push(
|
|
@@ -197,7 +224,10 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
|
|
|
197
224
|
"",
|
|
198
225
|
howToRunCode(),
|
|
199
226
|
"",
|
|
200
|
-
replGlossary(
|
|
227
|
+
replGlossary(
|
|
228
|
+
kind, recursion, opts.askUserQuestion ?? false, opts.todo ?? false,
|
|
229
|
+
opts.pipeline ?? false, opts.libraryLoader ?? false,
|
|
230
|
+
),
|
|
201
231
|
"",
|
|
202
232
|
"REPL stdout over ~800 characters is truncated to a short excerpt — large results stay in your",
|
|
203
233
|
"REPL variables as buffers. Re-print only the slice you need (e.g. `print(result[:500])`); never",
|
|
@@ -232,6 +262,7 @@ function nativeReplGlossary(): string {
|
|
|
232
262
|
"- `rlm_query_batched(prompts, model=None) -> list[str]` — concurrent recursive RLM calls.",
|
|
233
263
|
"",
|
|
234
264
|
"- `todo(action, **kwargs) -> str` — manage a task list. Actions: create, update, list, get, delete, clear. Statuses: pending → in_progress → completed.",
|
|
265
|
+
"- `load_library(source)` → `context_N` (external).",
|
|
235
266
|
"- `SHOW_VARS() -> str` — list all variables currently in the REPL.",
|
|
236
267
|
"- `stage_edit(path, old_text, new_text) -> str`: stages an edit and returns an edit ID; apply IDs with `apply_edits`.",
|
|
237
268
|
"- `answer`: dict `{\"content\": \"\", \"ready\": False}`. Setting `answer[\"ready\"] = True` delivers it to the user; do not restate it.",
|
package/src/prompts/user.ts
CHANGED
|
@@ -3,16 +3,24 @@
|
|
|
3
3
|
* Native mode does not use these — pi's own loop supplies the turns.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export function buildTurnPrompt(
|
|
6
|
+
export function buildTurnPrompt(
|
|
7
|
+
iteration: number,
|
|
8
|
+
maxIterations: number,
|
|
9
|
+
gateMessage?: string,
|
|
10
|
+
phaseGuidanceText?: string,
|
|
11
|
+
): string {
|
|
12
|
+
const parts: string[] = [];
|
|
13
|
+
if (phaseGuidanceText) parts.push(phaseGuidanceText);
|
|
14
|
+
if (gateMessage) parts.push(gateMessage);
|
|
15
|
+
const prefix = parts.length > 0 ? `${parts.join("\n\n")}\n\n` : "";
|
|
7
16
|
const body = `Turn ${iteration + 1}/${maxIterations}:`;
|
|
8
|
-
const gate = gateMessage ? `\n${gateMessage}\n\n` : "";
|
|
9
17
|
if (iteration === 0) {
|
|
10
18
|
return (
|
|
11
19
|
"You have not interacted with the REPL or seen your context yet. Look at the context first; " +
|
|
12
|
-
`do not provide a final answer yet.\n\n${
|
|
20
|
+
`do not provide a final answer yet.\n\n${prefix}${body}`
|
|
13
21
|
);
|
|
14
22
|
}
|
|
15
|
-
return `${
|
|
23
|
+
return `${prefix}${body}`;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
/** Asked once when the engine runs out of turns without a submitted answer. */
|
package/src/sandbox/protocol.ts
CHANGED
|
@@ -21,6 +21,12 @@ export interface LlmReply {
|
|
|
21
21
|
readonly response?: string;
|
|
22
22
|
readonly responses?: readonly string[];
|
|
23
23
|
readonly answers?: readonly AskAnswer[];
|
|
24
|
+
/** load_library reply: temp file with the packed payload + assigned slot. */
|
|
25
|
+
readonly path?: string;
|
|
26
|
+
readonly json?: boolean;
|
|
27
|
+
readonly index?: number;
|
|
28
|
+
readonly files?: number;
|
|
29
|
+
readonly chars?: number;
|
|
24
30
|
readonly error?: string;
|
|
25
31
|
}
|
|
26
32
|
|
|
@@ -62,8 +68,10 @@ export type InterruptKind =
|
|
|
62
68
|
| "rlm_query"
|
|
63
69
|
| "rlm_query_batched"
|
|
64
70
|
| "advance_phase"
|
|
71
|
+
| "save_artifact"
|
|
65
72
|
| "ask_user_question"
|
|
66
|
-
| "todo"
|
|
73
|
+
| "todo"
|
|
74
|
+
| "load_library";
|
|
67
75
|
|
|
68
76
|
export interface AskOption {
|
|
69
77
|
readonly label: string;
|
|
@@ -111,6 +119,12 @@ interface AdvancePhaseInterrupt extends InterruptBase {
|
|
|
111
119
|
readonly summary?: string;
|
|
112
120
|
}
|
|
113
121
|
|
|
122
|
+
interface SaveArtifactInterrupt extends InterruptBase {
|
|
123
|
+
readonly type: "save_artifact";
|
|
124
|
+
readonly artifactKind?: string;
|
|
125
|
+
readonly content?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
114
128
|
export interface AskUserQuestionInterrupt extends InterruptBase {
|
|
115
129
|
readonly type: "ask_user_question";
|
|
116
130
|
readonly questions: readonly AskQuestion[];
|
|
@@ -132,13 +146,20 @@ export interface TodoInterrupt extends InterruptBase {
|
|
|
132
146
|
readonly includeDeleted?: boolean;
|
|
133
147
|
}
|
|
134
148
|
|
|
149
|
+
export interface LoadLibraryInterrupt extends InterruptBase {
|
|
150
|
+
readonly type: "load_library";
|
|
151
|
+
readonly source?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
135
154
|
/** A mid-exec sub-LLM/tool request from the worker. */
|
|
136
155
|
export type WorkerInterrupt =
|
|
137
156
|
| PromptInterrupt
|
|
138
157
|
| BatchedPromptInterrupt
|
|
139
158
|
| AdvancePhaseInterrupt
|
|
159
|
+
| SaveArtifactInterrupt
|
|
140
160
|
| AskUserQuestionInterrupt
|
|
141
|
-
| TodoInterrupt
|
|
161
|
+
| TodoInterrupt
|
|
162
|
+
| LoadLibraryInterrupt;
|
|
142
163
|
|
|
143
164
|
export type WorkerMessage = WorkerResponse | WorkerInterrupt;
|
|
144
165
|
|
|
@@ -148,8 +169,10 @@ export const INTERRUPT_KINDS = Object.freeze(new Set<InterruptKind>([
|
|
|
148
169
|
"rlm_query",
|
|
149
170
|
"rlm_query_batched",
|
|
150
171
|
"advance_phase",
|
|
172
|
+
"save_artifact",
|
|
151
173
|
"ask_user_question",
|
|
152
174
|
"todo",
|
|
175
|
+
"load_library",
|
|
153
176
|
]));
|
|
154
177
|
|
|
155
178
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -26,6 +26,14 @@ import {
|
|
|
26
26
|
} from "./protocol.ts";
|
|
27
27
|
import { formatError } from "../util/errors.ts";
|
|
28
28
|
|
|
29
|
+
/** Result of a host-side library pack requested by `load_library`. */
|
|
30
|
+
export interface LibraryLoadResult {
|
|
31
|
+
readonly payload: unknown; // string (single file) or ContextFile[] (packed dir/repo)
|
|
32
|
+
readonly index: number; // slot assigned by the host
|
|
33
|
+
readonly files?: number;
|
|
34
|
+
readonly chars: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
/** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
|
|
30
38
|
export interface SubLlmHandlers {
|
|
31
39
|
llmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
|
|
@@ -33,8 +41,10 @@ export interface SubLlmHandlers {
|
|
|
33
41
|
rlmQuery(prompt: string, model: string | null, depth: number): Promise<string>;
|
|
34
42
|
rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number): Promise<string[]>;
|
|
35
43
|
advancePhase(phase: string, summary: string | undefined, depth: number): Promise<string>;
|
|
44
|
+
saveArtifact(kind: string, content: string, depth: number): Promise<string>;
|
|
36
45
|
askUserQuestion(questions: readonly AskQuestion[], depth: number): Promise<AskAnswer[]>;
|
|
37
46
|
todo(action: string, params: Record<string, unknown>, depth: number): Promise<string>;
|
|
47
|
+
loadLibrary(source: string, depth: number): Promise<LibraryLoadResult>;
|
|
38
48
|
}
|
|
39
49
|
|
|
40
50
|
export interface SandboxOptions {
|
|
@@ -76,12 +86,14 @@ const REJECT: SubLlmHandlers = {
|
|
|
76
86
|
rlmQuery: async () => formatError("sub-LLM bridge not configured"),
|
|
77
87
|
rlmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
|
|
78
88
|
advancePhase: async () => formatError("phase advancement not available"),
|
|
89
|
+
saveArtifact: async () => formatError("save_artifact not available"),
|
|
79
90
|
askUserQuestion: async (questions) => questions.map((q) => ({
|
|
80
91
|
question: q.question,
|
|
81
92
|
selected: [],
|
|
82
93
|
custom: formatError("ask_user_question not configured"),
|
|
83
94
|
})),
|
|
84
95
|
todo: async () => formatError("todo not configured"),
|
|
96
|
+
loadLibrary: async () => { throw new Error("load_library not configured"); },
|
|
85
97
|
};
|
|
86
98
|
|
|
87
99
|
/** Distributive omit so each union member keeps its own fields (plain Omit collapses to shared keys). */
|
|
@@ -285,6 +297,15 @@ export class PythonSandbox {
|
|
|
285
297
|
}
|
|
286
298
|
}
|
|
287
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Refresh the parent-side request watchdog for every pending request.
|
|
302
|
+
* Used during long mid-exec work (e.g. serial implement fanout) that does not
|
|
303
|
+
* produce additional worker interrupts on this sandbox.
|
|
304
|
+
*/
|
|
305
|
+
refreshWatchdog(): void {
|
|
306
|
+
this.touchPending();
|
|
307
|
+
}
|
|
308
|
+
|
|
288
309
|
private send(msg: ParentMessage): void {
|
|
289
310
|
this.proc.stdin.write(`${JSON.stringify(msg)}\n`);
|
|
290
311
|
}
|
|
@@ -346,6 +367,9 @@ export class PythonSandbox {
|
|
|
346
367
|
} else if (msg.type === "advance_phase") {
|
|
347
368
|
const response = await h.advancePhase(msg.phase ?? "", msg.summary, d);
|
|
348
369
|
this.reply(msg.rid, { response });
|
|
370
|
+
} else if (msg.type === "save_artifact") {
|
|
371
|
+
const response = await h.saveArtifact(msg.artifactKind ?? "", msg.content ?? "", d);
|
|
372
|
+
this.reply(msg.rid, { response });
|
|
349
373
|
} else if (msg.type === "ask_user_question") {
|
|
350
374
|
const answers = await h.askUserQuestion(msg.questions ?? [], d);
|
|
351
375
|
this.reply(msg.rid, { answers });
|
|
@@ -355,13 +379,30 @@ export class PythonSandbox {
|
|
|
355
379
|
);
|
|
356
380
|
const response = await h.todo(msg.action ?? "list", params, d);
|
|
357
381
|
this.reply(msg.rid, { response });
|
|
382
|
+
} else if (msg.type === "load_library") {
|
|
383
|
+
const lib = await h.loadLibrary(msg.source ?? "", d);
|
|
384
|
+
const isJson = typeof lib.payload !== "string";
|
|
385
|
+
const path = await this.writeContextFile(lib.payload, isJson);
|
|
386
|
+
// Worker reads then unlinks (worker._load_library). Host must not unlink here —
|
|
387
|
+
// if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
|
|
388
|
+
this.reply(msg.rid, { path, json: isJson, index: lib.index, files: lib.files, chars: lib.chars });
|
|
358
389
|
}
|
|
359
390
|
} catch (err) {
|
|
360
391
|
this.reply(msg.rid, { error: err instanceof Error ? err.message : String(err) });
|
|
361
392
|
}
|
|
362
393
|
}
|
|
363
394
|
|
|
364
|
-
private reply(rid: string, body: {
|
|
395
|
+
private reply(rid: string, body: {
|
|
396
|
+
response?: string;
|
|
397
|
+
responses?: string[];
|
|
398
|
+
answers?: AskAnswer[];
|
|
399
|
+
path?: string;
|
|
400
|
+
json?: boolean;
|
|
401
|
+
index?: number;
|
|
402
|
+
files?: number;
|
|
403
|
+
chars?: number;
|
|
404
|
+
error?: string;
|
|
405
|
+
}): void {
|
|
365
406
|
if (!this.disposed) this.send({ type: "llm_reply", rid, ...body });
|
|
366
407
|
}
|
|
367
408
|
|
package/src/sandbox/worker.py
CHANGED
|
@@ -7,9 +7,9 @@ This is NOT a security sandbox: __import__ and open are available, so code can i
|
|
|
7
7
|
Protocol (parent -> worker): {"id","type":"exec"|"load_context"|"shutdown", ...}
|
|
8
8
|
Protocol (worker -> parent): {"id","ok",...result} # response to a request
|
|
9
9
|
{"type":"llm_query"|"llm_query_batched"|"rlm_query"|...
|
|
10
|
-
"advance_phase"|"ask_user_question"|"todo","rid",...}
|
|
10
|
+
"advance_phase"|"save_artifact"|"ask_user_question"|"todo","rid",...}
|
|
11
11
|
# mid-exec helper request
|
|
12
|
-
When sandbox code calls llm_query/rlm_query/advance_phase/ask_user_question/todo, the worker writes a request line
|
|
12
|
+
When sandbox code calls llm_query/rlm_query/advance_phase/save_artifact/ask_user_question/todo, the worker writes a request line
|
|
13
13
|
and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives. The parent
|
|
14
14
|
services the request in-process (it holds API keys).
|
|
15
15
|
"""
|
|
@@ -66,9 +66,9 @@ RESERVED = frozenset(
|
|
|
66
66
|
{
|
|
67
67
|
"llm_query", "llm_query_batched", "llm_query_chunked",
|
|
68
68
|
"rlm_query", "rlm_query_batched",
|
|
69
|
-
"advance_phase",
|
|
69
|
+
"advance_phase", "save_artifact",
|
|
70
70
|
"ask_user_question", "todo",
|
|
71
|
-
"stage_edit",
|
|
71
|
+
"stage_edit", "load_library",
|
|
72
72
|
"SHOW_VARS", "answer", "context",
|
|
73
73
|
}
|
|
74
74
|
)
|
|
@@ -148,9 +148,11 @@ class Worker:
|
|
|
148
148
|
ns["rlm_query"] = self._rlm_query
|
|
149
149
|
ns["rlm_query_batched"] = self._rlm_query_batched
|
|
150
150
|
ns["advance_phase"] = self._advance_phase
|
|
151
|
+
ns["save_artifact"] = self._save_artifact
|
|
151
152
|
ns["ask_user_question"] = self._ask_user_question
|
|
152
153
|
ns["todo"] = self._todo
|
|
153
154
|
ns["stage_edit"] = self._stage_edit
|
|
155
|
+
ns["load_library"] = self._load_library
|
|
154
156
|
ns["SHOW_VARS"] = self._show_vars
|
|
155
157
|
if not isinstance(ns.get("answer"), _AnswerDict):
|
|
156
158
|
cur = ns.get("answer")
|
|
@@ -331,11 +333,30 @@ class Worker:
|
|
|
331
333
|
self._staged_edits.append({"id": edit_id, "path": path, "oldText": old_text, "newText": new_text})
|
|
332
334
|
return edit_id
|
|
333
335
|
|
|
336
|
+
def _load_library(self, source: str) -> dict[str, Any] | str:
|
|
337
|
+
"""Ask the host to pack an external dir/file/git-URL and load it as a new context slot."""
|
|
338
|
+
r = self._rpc("load_library", {"source": str(source)})
|
|
339
|
+
if r.get("error"):
|
|
340
|
+
return f"Error: {r['error']}"
|
|
341
|
+
path = r.get("path")
|
|
342
|
+
if not isinstance(path, str):
|
|
343
|
+
return "Error: malformed load_library reply (no path)"
|
|
344
|
+
try:
|
|
345
|
+
idx = self.load_context(path, r.get("index"), bool(r.get("json")))
|
|
346
|
+
finally:
|
|
347
|
+
try:
|
|
348
|
+
os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
|
|
349
|
+
except OSError:
|
|
350
|
+
pass
|
|
351
|
+
return {"index": idx, "var": f"context_{idx}",
|
|
352
|
+
"files": r.get("files"), "chars": r.get("chars")}
|
|
353
|
+
|
|
334
354
|
def _advance_phase(self, phase: str, summary: str | None = None) -> str:
|
|
335
355
|
"""Transition the root RLM pipeline to a new phase.
|
|
336
356
|
|
|
337
357
|
Only callable at depth 0. The parent handler validates the transition
|
|
338
|
-
against the phase state machine (research → blueprint → implement → validate)
|
|
358
|
+
against the phase state machine (research → blueprint → implement → validate)
|
|
359
|
+
and runs deterministic artifact gates before accepting the transition.
|
|
339
360
|
Returns a short confirmation, or an `Error: …` string the model can act on.
|
|
340
361
|
"""
|
|
341
362
|
if self.depth > 0:
|
|
@@ -348,6 +369,22 @@ class Worker:
|
|
|
348
369
|
return response
|
|
349
370
|
return response if isinstance(response, str) else "ok"
|
|
350
371
|
|
|
372
|
+
def _save_artifact(self, kind: str, content: str) -> str:
|
|
373
|
+
"""Persist a stage artifact (research/plan/validation) under .rlm/artifacts/.
|
|
374
|
+
|
|
375
|
+
Only callable at depth 0. The engine gates advance_phase against the latest
|
|
376
|
+
saved artifact for the current stage.
|
|
377
|
+
"""
|
|
378
|
+
if self.depth > 0:
|
|
379
|
+
return "Error: save_artifact is only available at the root RLM depth"
|
|
380
|
+
r = self._rpc("save_artifact", {"artifactKind": str(kind), "content": str(content)})
|
|
381
|
+
if r.get("error"):
|
|
382
|
+
return f"Error: {r['error']}"
|
|
383
|
+
response = r.get("response", "ok")
|
|
384
|
+
if isinstance(response, str) and response.startswith("Error:"):
|
|
385
|
+
return response
|
|
386
|
+
return response if isinstance(response, str) else "ok"
|
|
387
|
+
|
|
351
388
|
def _rlm_query_batched(self, prompts, model: str | None = None) -> list[str]:
|
|
352
389
|
prompts = [str(p) for p in prompts]
|
|
353
390
|
if not prompts:
|
package/src/state/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type {
|
|
|
18
18
|
} from "./rows.ts";
|
|
19
19
|
export { STATE_SCHEMA_VERSION, isHeader, isTurn, isCompaction, isPhase, isTodo, isTerminal, isRow } from "./rows.ts";
|
|
20
20
|
export { appendRow, appendTodoRow, pruneRuns, writeContextSidecar } from "./writes.ts";
|
|
21
|
-
export { readRows, readHeader, readContextSidecar, listRunIds, resolveRunId } from "./reads.ts";
|
|
21
|
+
export { readRows, readHeader, readContextSidecar, readLibrarySidecars, listRunIds, resolveRunId } from "./reads.ts";
|
|
22
|
+
export type { LibrarySlot } from "./reads.ts";
|
|
22
23
|
export { reconstructRlmState } from "./resume.ts";
|
|
23
24
|
export type { PhaseRecon, ReconstructResult } from "./resume.ts";
|
package/src/state/paths.ts
CHANGED
|
@@ -32,8 +32,10 @@ export const runDir = (cwd: string, dir: string, runId: string): string => join(
|
|
|
32
32
|
|
|
33
33
|
export const trailPath = (cwd: string, dir: string, runId: string): string => join(runDir(cwd, dir, runId), "trail.jsonl");
|
|
34
34
|
|
|
35
|
-
export const contextPath = (cwd: string, dir: string, runId: string, json: boolean): string =>
|
|
36
|
-
join(runDir(cwd, dir, runId),
|
|
35
|
+
export const contextPath = (cwd: string, dir: string, runId: string, json: boolean, index = 0): string =>
|
|
36
|
+
join(runDir(cwd, dir, runId), index === 0
|
|
37
|
+
? (json ? "context.json" : "context.txt")
|
|
38
|
+
: `context.${index}.${json ? "json" : "txt"}`);
|
|
37
39
|
|
|
38
40
|
/** R-C1: per-turn snapshot files — `sandbox-<turn>.pkl` so resume can fall back to a prior turn if the latest rename failed. */
|
|
39
41
|
export function snapshotPath(cwd: string, dir: string, runId: string, turn?: number): string {
|
package/src/state/reads.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* by the slug (ISO-like timestamps are self-sorting).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { open, readFile } from "node:fs/promises";
|
|
10
|
-
import {
|
|
9
|
+
import { open, readdir, readFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { runsDir, runDir, trailPath, contextPath } from "./paths.ts";
|
|
11
12
|
import { isHeader, isRow, type RunHeader, type Row } from "./rows.ts";
|
|
12
13
|
import { errorMessage, failSoft, listDirectoriesSorted, pathExists, warn } from "./internal.ts";
|
|
13
14
|
|
|
@@ -83,6 +84,34 @@ export async function readContextSidecar(cwd: string, dir: string, runId: string
|
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
const LIBRARY_SIDECAR = /^context\.(\d+)\.(json|txt)$/;
|
|
88
|
+
|
|
89
|
+
export interface LibrarySlot {
|
|
90
|
+
readonly index: number;
|
|
91
|
+
readonly payload: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Fail-soft lister for load_library resume sidecars (`context.<index>.json|txt`). */
|
|
95
|
+
export async function readLibrarySidecars(cwd: string, dir: string, runId: string): Promise<LibrarySlot[]> {
|
|
96
|
+
const entries = await failSoft(() => readdir(runDir(cwd, dir, runId)), [] as string[]);
|
|
97
|
+
const slots: LibrarySlot[] = [];
|
|
98
|
+
for (const name of entries) {
|
|
99
|
+
const m = LIBRARY_SIDECAR.exec(name);
|
|
100
|
+
if (!m) continue;
|
|
101
|
+
const index = Number(m[1]);
|
|
102
|
+
const json = m[2] === "json";
|
|
103
|
+
const content = await failSoft(
|
|
104
|
+
() => readFile(join(runDir(cwd, dir, runId), name), "utf-8"),
|
|
105
|
+
undefined as string | undefined,
|
|
106
|
+
);
|
|
107
|
+
if (content === undefined) continue;
|
|
108
|
+
try {
|
|
109
|
+
slots.push({ index, payload: json ? JSON.parse(content) as unknown : content });
|
|
110
|
+
} catch (e) { warn(e); }
|
|
111
|
+
}
|
|
112
|
+
return slots.sort((a, b) => a.index - b.index);
|
|
113
|
+
}
|
|
114
|
+
|
|
86
115
|
/** Enumerate run-ids by directory listing; newest first (slug sorts chronologically). */
|
|
87
116
|
export async function listRunIds(cwd: string, dir: string): Promise<string[]> {
|
|
88
117
|
return await failSoft(() => listDirectoriesSorted(runsDir(cwd, dir)), [], { warn: false });
|
package/src/state/resume.ts
CHANGED
|
@@ -32,6 +32,9 @@ export interface PhaseRecon {
|
|
|
32
32
|
readonly current: string;
|
|
33
33
|
readonly advancedAt: number;
|
|
34
34
|
readonly summary?: string;
|
|
35
|
+
/** Repo-relative artifact paths keyed by the phase that produced them. */
|
|
36
|
+
readonly artifacts?: Readonly<Partial<Record<string, string>>>;
|
|
37
|
+
readonly backwardJumps?: number;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export type ReconstructResult =
|
|
@@ -107,6 +110,8 @@ export async function reconstructRlmState(
|
|
|
107
110
|
const todoRows: { action: string; params: Record<string, unknown>; result: string }[] = [];
|
|
108
111
|
let terminated = false;
|
|
109
112
|
let phase: PhaseRecon | undefined;
|
|
113
|
+
// Accumulate artifact paths keyed by the producing phase (artifactPhase on the row).
|
|
114
|
+
const artifactsAcc: Record<string, string> = {};
|
|
110
115
|
|
|
111
116
|
for (const row of rows) {
|
|
112
117
|
if (isHeader(row)) continue;
|
|
@@ -139,7 +144,20 @@ export async function reconstructRlmState(
|
|
|
139
144
|
continue;
|
|
140
145
|
}
|
|
141
146
|
if (isPhase(row)) {
|
|
142
|
-
|
|
147
|
+
if (row.artifactPath !== undefined && row.artifactPhase !== undefined) {
|
|
148
|
+
artifactsAcc[row.artifactPhase] = row.artifactPath;
|
|
149
|
+
}
|
|
150
|
+
// On loop-back to blueprint, drop stale plan so resume cannot re-gate with it.
|
|
151
|
+
if (row.phase === "blueprint" && row.backwardJumps !== undefined && row.backwardJumps > 0) {
|
|
152
|
+
delete artifactsAcc.blueprint;
|
|
153
|
+
}
|
|
154
|
+
phase = {
|
|
155
|
+
current: row.phase,
|
|
156
|
+
advancedAt: row.turn - 1,
|
|
157
|
+
summary: row.summary,
|
|
158
|
+
artifacts: Object.keys(artifactsAcc).length > 0 ? { ...artifactsAcc } : undefined,
|
|
159
|
+
backwardJumps: row.backwardJumps,
|
|
160
|
+
};
|
|
143
161
|
continue;
|
|
144
162
|
}
|
|
145
163
|
if (isTodo(row)) {
|
package/src/state/rows.ts
CHANGED
|
@@ -88,6 +88,12 @@ export interface PhaseRow {
|
|
|
88
88
|
readonly ts: string;
|
|
89
89
|
readonly phase: string;
|
|
90
90
|
readonly summary?: string;
|
|
91
|
+
/** Optional fields (no schema-version break — isPhase guard unchanged). */
|
|
92
|
+
readonly artifactPath?: string;
|
|
93
|
+
/** Phase that produced `artifactPath` (not inferred from order — loop-back safe). */
|
|
94
|
+
readonly artifactPhase?: string;
|
|
95
|
+
readonly blockersCount?: number;
|
|
96
|
+
readonly backwardJumps?: number;
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
export type Row = RunHeader | TurnRow | CompactionRow | TodoRow | TerminalRow | PhaseRow;
|
package/src/state/writes.ts
CHANGED
|
@@ -32,11 +32,13 @@ export async function appendTodoRow(cwd: string, dir: string, runId: string, row
|
|
|
32
32
|
return await appendRow(cwd, dir, runId, { kind: "todo", ...row });
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/** Persist
|
|
36
|
-
export async function writeContextSidecar(
|
|
35
|
+
/** Persist a context payload for resume. Slot 0 = repo context; index ≥ 1 = load_library slots. */
|
|
36
|
+
export async function writeContextSidecar(
|
|
37
|
+
cwd: string, dir: string, runId: string, context: unknown, json: boolean, index = 0,
|
|
38
|
+
): Promise<boolean> {
|
|
37
39
|
return await failSoft(async () => {
|
|
38
40
|
await mkdir(runDir(cwd, dir, runId), { recursive: true });
|
|
39
|
-
await writeFile(contextPath(cwd, dir, runId, json), json ? JSON.stringify(context) : String(context), "utf-8");
|
|
41
|
+
await writeFile(contextPath(cwd, dir, runId, json, index), json ? JSON.stringify(context) : String(context), "utf-8");
|
|
40
42
|
return true;
|
|
41
43
|
}, false);
|
|
42
44
|
}
|