@hicaru/pi-rlm 0.2.0 → 0.2.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.
Files changed (68) hide show
  1. package/README.md +12 -35
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +1 -1
  5. package/src/bridge/library.ts +61 -26
  6. package/src/bridge/subcall-handlers.ts +382 -0
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +7 -15
  10. package/src/config/settings.ts +8 -32
  11. package/src/context/library-context.ts +90 -17
  12. package/src/core/engine.ts +115 -360
  13. package/src/core/history.ts +1 -1
  14. package/src/core/limits.ts +5 -12
  15. package/src/core/resource-limits.ts +0 -2
  16. package/src/core/types.ts +3 -36
  17. package/src/index.ts +49 -10
  18. package/src/mode/llm-model.ts +54 -0
  19. package/src/mode/rlm-mode.ts +26 -57
  20. package/src/prompts/glossary.ts +287 -0
  21. package/src/prompts/native.ts +127 -0
  22. package/src/prompts/system.ts +14 -386
  23. package/src/sandbox/context-file.ts +154 -0
  24. package/src/sandbox/interrupts.ts +145 -0
  25. package/src/sandbox/protocol.ts +14 -69
  26. package/src/sandbox/py/guards.py +150 -0
  27. package/src/sandbox/py/retrieval.py +265 -0
  28. package/src/sandbox/py/tasks.py +116 -0
  29. package/src/sandbox/py/worker.py +836 -0
  30. package/src/sandbox/sandbox-manager.ts +33 -6
  31. package/src/sandbox/sandbox.ts +153 -182
  32. package/src/text/tokens.ts +29 -3
  33. package/src/tool/background-tasks.ts +95 -0
  34. package/src/tool/repl-details.ts +4 -2
  35. package/src/tool/repl-render.ts +58 -0
  36. package/src/tool/repl-result.ts +70 -0
  37. package/src/tool/repl-tool.ts +178 -216
  38. package/src/tool/rlm-aggregator.ts +2 -10
  39. package/src/tool/rlm-details.ts +0 -2
  40. package/src/tool/rlm-events.ts +10 -16
  41. package/src/tool/rlm-tool.ts +1 -12
  42. package/src/tool/subcall-render.ts +15 -3
  43. package/src/tool/subcall-store.ts +57 -1
  44. package/src/ui/config-panel.ts +4 -16
  45. package/src/ui/intro.ts +1 -2
  46. package/src/ui/model-picker.ts +34 -10
  47. package/src/ui/status.ts +3 -7
  48. package/src/util/concurrency.ts +91 -13
  49. package/src/util/trace.ts +42 -0
  50. package/src/bridge/fallback-todo.ts +0 -137
  51. package/src/bridge/interactive.ts +0 -65
  52. package/src/bridge/llm-query.ts +0 -156
  53. package/src/bridge/pi-interactive.ts +0 -41
  54. package/src/bridge/rlm-query.ts +0 -108
  55. package/src/core/artifacts.ts +0 -89
  56. package/src/core/critique.ts +0 -92
  57. package/src/core/gates.ts +0 -301
  58. package/src/core/pipeline-handlers.ts +0 -319
  59. package/src/core/pipeline.ts +0 -268
  60. package/src/prompts/phases.ts +0 -104
  61. package/src/sandbox/worker.py +0 -1078
  62. package/src/state/index.ts +0 -24
  63. package/src/state/internal.ts +0 -46
  64. package/src/state/paths.ts +0 -44
  65. package/src/state/reads.ts +0 -133
  66. package/src/state/resume.ts +0 -173
  67. package/src/state/rows.ts +0 -123
  68. package/src/state/writes.ts +0 -58
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The interrupt surface: what the worker can ask the host for mid-exec, and how each request is
3
+ * turned into a reply frame.
4
+ *
5
+ * Split from sandbox.ts, which owns the subprocess and the JSONL pump. Adding a sandbox function
6
+ * touches this file and worker.py; the transport underneath does not change.
7
+ */
8
+
9
+ import type { WorkerInterrupt } from "./protocol.ts";
10
+ import { writeContextTempFile } from "./context-file.ts";
11
+ import { errorMessage, formatError } from "../util/errors.ts";
12
+
13
+ /** Result of a host-side library pack requested by `load_library`. */
14
+ export interface LibraryLoadResult {
15
+ readonly payload: unknown; // always ContextFile[] under lib/<id>/
16
+ readonly files?: number;
17
+ readonly chars: number;
18
+ readonly sourceId: string;
19
+ readonly pathPrefix: string;
20
+ /** Host already has this library — no pack, empty payload. */
21
+ readonly alreadyLoaded?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Per-interrupt routing context for the sub-LLM handlers.
26
+ *
27
+ * Only the four sub-call kinds can be spawned, so only they carry it; load_library is
28
+ * always synchronous within one exec.
29
+ */
30
+ export interface SubcallOpts {
31
+ /** Started via `spawn()` — route to session-scoped state, not the current invocation. */
32
+ readonly detached: boolean;
33
+ /**
34
+ * `rlm_query(paths=[…])` — path prefixes narrowing the child's inherited context.
35
+ * Absent on every other path; `llm_query` never carries it.
36
+ */
37
+ readonly paths?: readonly string[];
38
+ }
39
+
40
+ /** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
41
+ export interface SubLlmHandlers {
42
+ llmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
43
+ llmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
44
+ rlmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
45
+ rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
46
+ loadLibrary(source: string, depth: number): Promise<LibraryLoadResult>;
47
+ }
48
+
49
+ /** Narrow an unknown JSON value to a frozen string array. Non-strings and blanks are dropped. */
50
+ function toStringArray(value: unknown): readonly string[] | undefined {
51
+ if (!Array.isArray(value)) return undefined;
52
+ const out = new Array<string>(value.length);
53
+ let n = 0;
54
+ for (let i = 0; i < value.length; i++) {
55
+ const item: unknown = value[i];
56
+ if (typeof item === "string" && item.trim() !== "") out[n++] = item;
57
+ }
58
+ out.length = n;
59
+ return n > 0 ? Object.freeze(out) : undefined;
60
+ }
61
+
62
+ /** Default handlers — every sandbox function refuses until a bridge installs a real one. */
63
+ export const REJECT: SubLlmHandlers = {
64
+ llmQuery: async () => formatError("sub-LLM bridge not configured"),
65
+ llmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
66
+ rlmQuery: async () => formatError("sub-LLM bridge not configured"),
67
+ rlmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
68
+ loadLibrary: async () => { throw new Error("load_library not configured"); },
69
+ };
70
+
71
+ /** Body of a reply frame — the union of every handler's payload shape. */
72
+ export interface ReplyBody {
73
+ response?: string;
74
+ responses?: string[];
75
+ path?: string;
76
+ json?: boolean;
77
+ files?: number;
78
+ chars?: number;
79
+ source_id?: string;
80
+ path_prefix?: string;
81
+ already_loaded?: boolean;
82
+ error?: string;
83
+ }
84
+
85
+ /**
86
+ * Service one interrupt and hand the reply body to `reply`.
87
+ *
88
+ * Errors are replied, never thrown: the caller invokes this from the stdio pump, where a
89
+ * rejection would surface as an unhandled promise and leave the worker parked forever.
90
+ */
91
+ export async function serviceInterrupt(
92
+ msg: WorkerInterrupt,
93
+ h: SubLlmHandlers,
94
+ reply: (rid: string, body: ReplyBody) => void,
95
+ ): Promise<void> {
96
+ const d = msg.depth;
97
+ const opts: SubcallOpts = Object.freeze({
98
+ detached: msg.detached === true,
99
+ // Only the recursive kinds carry a context slice; the value crossed JSON, so guard it.
100
+ paths: msg.type === "rlm_query" || msg.type === "rlm_query_batched"
101
+ ? toStringArray(msg.paths)
102
+ : undefined,
103
+ });
104
+ try {
105
+ if (msg.type === "llm_query") {
106
+ const response = await h.llmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
107
+ reply(msg.rid, { response });
108
+ } else if (msg.type === "rlm_query") {
109
+ const response = await h.rlmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
110
+ reply(msg.rid, { response });
111
+ } else if (msg.type === "llm_query_batched") {
112
+ const responses = await h.llmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
113
+ reply(msg.rid, { responses });
114
+ } else if (msg.type === "rlm_query_batched") {
115
+ const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
116
+ reply(msg.rid, { responses });
117
+ } else if (msg.type === "load_library") {
118
+ const lib = await h.loadLibrary(msg.source ?? "", d);
119
+ if (lib.alreadyLoaded) {
120
+ // No temp file — worker short-circuits on already_loaded.
121
+ reply(msg.rid, {
122
+ already_loaded: true,
123
+ files: 0,
124
+ chars: lib.chars,
125
+ source_id: lib.sourceId,
126
+ path_prefix: lib.pathPrefix,
127
+ });
128
+ } else {
129
+ const { path, json: isJson } = await writeContextTempFile(lib.payload);
130
+ // Worker reads then unlinks (worker._load_library). Host must not unlink here —
131
+ // if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
132
+ reply(msg.rid, {
133
+ path,
134
+ json: isJson,
135
+ files: lib.files,
136
+ chars: lib.chars,
137
+ source_id: lib.sourceId,
138
+ path_prefix: lib.pathPrefix,
139
+ });
140
+ }
141
+ }
142
+ } catch (err) {
143
+ reply(msg.rid, { error: errorMessage(err) });
144
+ }
145
+ }
@@ -10,8 +10,6 @@
10
10
  export type WorkerRequest =
11
11
  | { readonly id: string; readonly type: "exec"; readonly code: string }
12
12
  | { readonly id: string; readonly type: "load_context"; readonly path: string; readonly index?: number; readonly json: boolean }
13
- | { readonly id: string; readonly type: "snapshot"; readonly path: string; readonly nonce: string }
14
- | { readonly id: string; readonly type: "restore"; readonly path: string; readonly nonce: string }
15
13
  | { readonly id: string; readonly type: "shutdown" };
16
14
 
17
15
  /** Reply the parent sends to satisfy a sub-LLM interrupt. */
@@ -20,11 +18,9 @@ export interface LlmReply {
20
18
  readonly rid: string;
21
19
  readonly response?: string;
22
20
  readonly responses?: readonly string[];
23
- readonly answers?: readonly AskAnswer[];
24
- /** load_library reply: temp file with the packed payload (+ resume index / namespace). */
21
+ /** load_library reply: temp file with the packed payload (+ namespace metadata). */
25
22
  readonly path?: string;
26
23
  readonly json?: boolean;
27
- readonly index?: number;
28
24
  readonly files?: number;
29
25
  readonly chars?: number;
30
26
  readonly source_id?: string;
@@ -60,79 +56,36 @@ export type InterruptKind =
60
56
  | "llm_query_batched"
61
57
  | "rlm_query"
62
58
  | "rlm_query_batched"
63
- | "advance_phase"
64
- | "save_artifact"
65
- | "ask_user_question"
66
- | "todo"
67
59
  | "load_library";
68
60
 
69
- export interface AskOption {
70
- readonly label: string;
71
- readonly description?: string;
72
- readonly preview?: string;
73
- }
74
-
75
- export interface AskQuestion {
76
- readonly question: string;
77
- readonly header: string;
78
- readonly multiSelect?: boolean;
79
- readonly options: readonly AskOption[];
80
- }
81
-
82
- export interface AskAnswer {
83
- readonly question: string;
84
- readonly selected: readonly string[];
85
- readonly custom?: string;
86
- }
87
-
88
61
  interface InterruptBase {
89
62
  readonly rid: string;
90
63
  readonly depth: number;
64
+ /**
65
+ * Started via `spawn()`: the request may outlive the `exec` that issued it, so the host
66
+ * must not attach it to that invocation's emitter or LimitGuard. Absent on the
67
+ * synchronous path.
68
+ */
69
+ readonly detached?: boolean;
91
70
  }
92
71
 
93
72
  interface PromptInterrupt extends InterruptBase {
94
73
  readonly type: "llm_query" | "rlm_query";
95
74
  readonly prompt?: string;
96
75
  readonly model?: string | null;
76
+ /**
77
+ * `rlm_query` only — path prefixes narrowing the child's inherited context. Never sent for
78
+ * `llm_query`, whose frame stays byte-identical to before.
79
+ */
80
+ readonly paths?: readonly string[];
97
81
  }
98
82
 
99
83
  interface BatchedPromptInterrupt extends InterruptBase {
100
84
  readonly type: "llm_query_batched" | "rlm_query_batched";
101
85
  readonly prompts?: readonly string[];
102
86
  readonly model?: string | null;
103
- }
104
-
105
- interface AdvancePhaseInterrupt extends InterruptBase {
106
- readonly type: "advance_phase";
107
- readonly phase?: string;
108
- readonly summary?: string;
109
- }
110
-
111
- interface SaveArtifactInterrupt extends InterruptBase {
112
- readonly type: "save_artifact";
113
- readonly artifactKind?: string;
114
- readonly content?: string;
115
- }
116
-
117
- export interface AskUserQuestionInterrupt extends InterruptBase {
118
- readonly type: "ask_user_question";
119
- readonly questions: readonly AskQuestion[];
120
- }
121
-
122
- export interface TodoInterrupt extends InterruptBase {
123
- readonly type: "todo";
124
- readonly action: "create" | "update" | "list" | "get" | "delete" | "clear";
125
- readonly id?: number;
126
- readonly subject?: string;
127
- readonly description?: string;
128
- readonly status?: "pending" | "in_progress" | "completed" | "deleted";
129
- readonly activeForm?: string;
130
- readonly blockedBy?: readonly number[];
131
- readonly addBlockedBy?: readonly number[];
132
- readonly removeBlockedBy?: readonly number[];
133
- readonly owner?: string;
134
- readonly filterStatus?: string;
135
- readonly includeDeleted?: boolean;
87
+ /** `rlm_query_batched` only — one prefix set shared by every prompt in the batch. */
88
+ readonly paths?: readonly string[];
136
89
  }
137
90
 
138
91
  export interface LoadLibraryInterrupt extends InterruptBase {
@@ -144,10 +97,6 @@ export interface LoadLibraryInterrupt extends InterruptBase {
144
97
  export type WorkerInterrupt =
145
98
  | PromptInterrupt
146
99
  | BatchedPromptInterrupt
147
- | AdvancePhaseInterrupt
148
- | SaveArtifactInterrupt
149
- | AskUserQuestionInterrupt
150
- | TodoInterrupt
151
100
  | LoadLibraryInterrupt;
152
101
 
153
102
  export type WorkerMessage = WorkerResponse | WorkerInterrupt;
@@ -157,10 +106,6 @@ export const INTERRUPT_KINDS = Object.freeze(new Set<InterruptKind>([
157
106
  "llm_query_batched",
158
107
  "rlm_query",
159
108
  "rlm_query_batched",
160
- "advance_phase",
161
- "save_artifact",
162
- "ask_user_question",
163
- "todo",
164
109
  "load_library",
165
110
  ]));
166
111
 
@@ -0,0 +1,150 @@
1
+ """Execution guardrails for the RLM sandbox worker.
2
+
3
+ The restricted builtin table, the reserved-name set that keeps scaffold functions out of
4
+ SHOW_VARS, the protocol writer that must always reach the REAL stdout (user prints are
5
+ captured into a buffer), and the per-exec stall alarm.
6
+
7
+ This is steering, not a security boundary: `__import__` and `open` are deliberately available,
8
+ so model code can still reach the network and the filesystem. What it does buy is that the
9
+ scaffold cannot be clobbered silently and that a blocked builtin explains itself instead of
10
+ failing as "'NoneType' object is not callable".
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ import signal
18
+ import sys
19
+ from contextlib import contextmanager
20
+ from typing import Any
21
+
22
+ # Capture the REAL stdio before exec() redirects sys.stdout/sys.stderr into buffers.
23
+ # All protocol writes must go to the real stdout even while user code's prints are captured.
24
+ REAL_STDOUT = sys.stdout
25
+ REAL_STDIN = sys.stdin
26
+ REAL_STDERR = sys.stderr
27
+
28
+
29
+ def _builtin(name: str):
30
+ return __builtins__[name] if isinstance(__builtins__, dict) else getattr(__builtins__, name, None)
31
+
32
+
33
+ # Restricted builtins: enough for real data work, minus the dangerous reflection escapes.
34
+ _SAFE_BUILTINS = {
35
+ name: _builtin(name)
36
+ for name in (
37
+ "abs", "all", "any", "ascii", "bin", "bool", "bytearray", "bytes", "callable",
38
+ "chr", "classmethod", "complex", "dict", "dir", "divmod", "enumerate", "filter",
39
+ "float", "format", "frozenset", "getattr", "hasattr", "hash", "hex", "id", "int",
40
+ "isinstance", "issubclass", "iter", "len", "list", "map", "max", "min", "next",
41
+ "object", "oct", "ord", "pow", "print", "property", "range", "repr", "reversed",
42
+ "round", "set", "setattr", "slice", "sorted", "staticmethod", "str", "sum", "super",
43
+ "tuple", "type", "vars", "zip", "delattr", "memoryview", "__import__", "__build_class__",
44
+ "Exception", "BaseException", "ValueError", "TypeError", "KeyError", "IndexError",
45
+ "AttributeError", "FileNotFoundError", "OSError", "IOError", "RuntimeError",
46
+ "NameError", "ImportError", "StopIteration", "AssertionError", "NotImplementedError",
47
+ "ArithmeticError", "ZeroDivisionError", "LookupError", "Warning", "True", "False", "None",
48
+ )
49
+ }
50
+ # `open` is allowed for data work; eval/exec/compile/input/globals/locals are not.
51
+ # _builtin()'s getattr(..., None) fallback would silently inject None for a name this
52
+ # interpreter lacks, surfacing much later as "'NoneType' object is not callable" inside model
53
+ # code. Fail at startup instead. Note "None" is legitimately None, and the block-list below is
54
+ # deliberate — which is why this check runs BEFORE it.
55
+ _MISSING = sorted(name for name, value in _SAFE_BUILTINS.items() if value is None and name != "None")
56
+ if _MISSING:
57
+ raise RuntimeError(f"unsupported Python interpreter: missing builtins {_MISSING}")
58
+
59
+ def _blocked_builtin(name: str):
60
+ """Bind a disabled builtin to a callable that explains itself.
61
+
62
+ Binding these to None made `eval(...)` fail with a bare "'NoneType' object is not callable",
63
+ which reads as a broken sandbox rather than a deliberate block: an audit session spent six
64
+ execs on it and filed a phantom "namespace corruption" bug. Saying so at the point of failure
65
+ fixes it for every model without spending native-prompt budget on a rule most runs never hit.
66
+ """
67
+ def blocked(*_args, **_kwargs):
68
+ raise PermissionError(
69
+ f"{name}() is disabled in the RLM sandbox by design — it is not missing and the "
70
+ "namespace is not corrupt. Names are already bound, so reference them directly; "
71
+ "inspect `context` with search() / grep_context() / outline()."
72
+ )
73
+ blocked.__name__ = name
74
+ return blocked
75
+
76
+
77
+ # Blocked on purpose (NOT missing) — see _blocked_builtin. The _MISSING check above runs first,
78
+ # so a genuinely absent builtin is still a startup failure rather than a silent None.
79
+ for _blocked in ("eval", "exec", "compile", "input", "globals", "locals"):
80
+ _SAFE_BUILTINS[_blocked] = _blocked_builtin(_blocked)
81
+
82
+ RESERVED = frozenset(
83
+ {
84
+ "llm_query", "llm_query_batched", "llm_query_chunked",
85
+ "rlm_query", "rlm_query_batched",
86
+ "spawn", "rlm_await", "rlm_await_all",
87
+ "map_files", "llm_map_reduce",
88
+ "search", "grep_context", "outline",
89
+ "load_library",
90
+ "SHOW_VARS", "answer", "context",
91
+ }
92
+ )
93
+ # NOTE: `answers` and `plan` are deliberately NOT reserved. They are seeded by the scaffold but
94
+ # owned by the model, so they must appear in SHOW_VARS.
95
+ # Only the single name `context` is the packed world. Legacy context_N names are filtered out.
96
+ _CONTEXT_NAME = re.compile(r"context(_\d+)?\Z")
97
+
98
+ def _send(obj: dict[str, Any]) -> None:
99
+ REAL_STDOUT.write(json.dumps(obj, ensure_ascii=False) + "\n")
100
+ REAL_STDOUT.flush()
101
+
102
+
103
+ class _StallTimeout(Exception):
104
+ """No frame from the host while a sub-call was pending."""
105
+
106
+
107
+ @contextmanager
108
+ def _stall_alarm(exec_timeout_s: float, stall_timeout_s: float):
109
+ """Swap the per-cell alarm for a stall alarm while blocked on the parent.
110
+
111
+ Sub-LLM latency is network time, not cell compute time, so it must not count against the
112
+ ```repl``` block timeout — but an unbounded wait is exactly how a lost reply turns into a
113
+ dead session. The yielded `rearm()` restarts the stall clock on every frame, so a healthy
114
+ long-running child never trips it.
115
+ """
116
+ use = hasattr(signal, "SIGALRM")
117
+ remaining = signal.getitimer(signal.ITIMER_REAL)[0] if (use and exec_timeout_s > 0) else 0.0
118
+
119
+ def _fire(signum, frame): # noqa: ARG001
120
+ raise _StallTimeout(
121
+ f"sub-call stalled — no reply from the host for {stall_timeout_s:g}s "
122
+ "(the task may still be running; rlm_await it again in a later block)"
123
+ )
124
+
125
+ old = signal.signal(signal.SIGALRM, _fire) if use else None
126
+
127
+ def rearm() -> None:
128
+ if use and stall_timeout_s > 0:
129
+ signal.setitimer(signal.ITIMER_REAL, stall_timeout_s)
130
+
131
+ rearm()
132
+ try:
133
+ yield rearm
134
+ finally:
135
+ if use:
136
+ signal.setitimer(signal.ITIMER_REAL, 0)
137
+ if old is not None:
138
+ signal.signal(signal.SIGALRM, old)
139
+ if remaining > 0: # restore the cell's remaining budget
140
+ signal.setitimer(signal.ITIMER_REAL, remaining)
141
+
142
+
143
+ def _surfaced_error(message: str) -> str:
144
+ """The "Error: …" contract value, ALSO written to the cell's stderr.
145
+
146
+ A spawn/await misuse whose only trace is the returned value reads to the model as a random
147
+ string much later — which is exactly how `tasks.items()` blew up on a str.
148
+ """
149
+ print(f"[rlm] {message}", file=sys.stderr)
150
+ return f"Error: {message}"