@hicaru/pi-rlm 0.2.2 → 0.3.1
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 +38 -16
- package/README.ru.md +2 -2
- package/README.zh-CN.md +2 -2
- package/package.json +22 -19
- package/src/bridge/add-context.ts +322 -0
- package/src/bridge/subcall-handlers.ts +1 -1
- package/src/config/defaults.ts +2 -1
- package/src/config/settings.ts +5 -2
- package/src/context/anydoc.ts +67 -0
- package/src/context/listing.ts +70 -0
- package/src/context/md-cache.ts +112 -0
- package/src/context/merge.ts +97 -0
- package/src/context/namespace.ts +180 -0
- package/src/context/resolve.ts +122 -0
- package/src/context/source-dir.ts +166 -0
- package/src/context/source-doc.ts +71 -0
- package/src/context/source-git.ts +51 -0
- package/src/context/source-text.ts +45 -0
- package/src/context/types.ts +88 -0
- package/src/context/walk.ts +250 -0
- package/src/core/engine.ts +15 -19
- package/src/core/types.ts +7 -2
- package/src/index.ts +119 -47
- package/src/mode/rlm-mode.ts +5 -4
- package/src/mode/subagent.ts +68 -0
- package/src/prompts/glossary.ts +31 -28
- package/src/prompts/native.ts +4 -4
- package/src/prompts/system.ts +2 -2
- package/src/sandbox/context-file.ts +4 -4
- package/src/sandbox/interrupts.ts +25 -10
- package/src/sandbox/protocol.ts +13 -7
- 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 -1
- package/src/sandbox/py/hostio.py +57 -0
- package/src/sandbox/py/retrieval.py +1 -1
- package/src/sandbox/py/tasks.py +17 -4
- package/src/sandbox/py/worker.py +71 -52
- package/src/sandbox/sandbox-manager.ts +18 -16
- package/src/sandbox/sandbox.ts +9 -2
- package/src/text/tokens.ts +3 -3
- package/src/tool/repl-details.ts +1 -1
- package/src/tool/repl-tool.ts +31 -19
- package/src/tool/rlm-tool.ts +1 -1
- package/src/ui/config-panel.ts +8 -4
- package/src/bridge/library.ts +0 -190
- package/src/context/library-context.ts +0 -339
- package/src/context/repomix-context.ts +0 -204
|
@@ -10,21 +10,27 @@ import type { WorkerInterrupt } from "./protocol.ts";
|
|
|
10
10
|
import { writeContextTempFile } from "./context-file.ts";
|
|
11
11
|
import { errorMessage, formatError } from "../util/errors.ts";
|
|
12
12
|
|
|
13
|
-
/** Result of a host-side
|
|
14
|
-
export interface
|
|
15
|
-
readonly payload: unknown; // always ContextFile[] under
|
|
13
|
+
/** Result of a host-side pack requested by `add_context`. */
|
|
14
|
+
export interface AddContextResult {
|
|
15
|
+
readonly payload: unknown; // always ContextFile[] under ctx/<id>/ (or un-prefixed for cwd)
|
|
16
16
|
readonly files?: number;
|
|
17
17
|
readonly chars: number;
|
|
18
18
|
readonly sourceId: string;
|
|
19
19
|
readonly pathPrefix: string;
|
|
20
|
-
/** Host already has this
|
|
20
|
+
/** Host already has this source — no pack, empty payload. */
|
|
21
21
|
readonly alreadyLoaded?: boolean;
|
|
22
|
+
/** Document-type files in the payload (fresh + cache hits). */
|
|
23
|
+
readonly documents?: number;
|
|
24
|
+
/** Documents freshly converted this call (cache hits excluded). */
|
|
25
|
+
readonly converted?: number;
|
|
26
|
+
/** Paths skipped during packing (model-facing). */
|
|
27
|
+
readonly skipped?: readonly { readonly path: string; readonly reason: string }[];
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
31
|
* Per-interrupt routing context for the sub-LLM handlers.
|
|
26
32
|
*
|
|
27
|
-
* Only the four sub-call kinds can be spawned, so only they carry it;
|
|
33
|
+
* Only the four sub-call kinds can be spawned, so only they carry it; add_context is
|
|
28
34
|
* always synchronous within one exec.
|
|
29
35
|
*/
|
|
30
36
|
export interface SubcallOpts {
|
|
@@ -43,7 +49,7 @@ export interface SubLlmHandlers {
|
|
|
43
49
|
llmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
|
|
44
50
|
rlmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
|
|
45
51
|
rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
|
|
46
|
-
|
|
52
|
+
addContext(source: string, depth: number): Promise<AddContextResult>;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
/** Narrow an unknown JSON value to a frozen string array. Non-strings and blanks are dropped. */
|
|
@@ -65,7 +71,7 @@ export const REJECT: SubLlmHandlers = {
|
|
|
65
71
|
llmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
|
|
66
72
|
rlmQuery: async () => formatError("sub-LLM bridge not configured"),
|
|
67
73
|
rlmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
|
|
68
|
-
|
|
74
|
+
addContext: async () => { throw new Error("add_context not configured"); },
|
|
69
75
|
};
|
|
70
76
|
|
|
71
77
|
/** Body of a reply frame — the union of every handler's payload shape. */
|
|
@@ -79,6 +85,9 @@ export interface ReplyBody {
|
|
|
79
85
|
source_id?: string;
|
|
80
86
|
path_prefix?: string;
|
|
81
87
|
already_loaded?: boolean;
|
|
88
|
+
documents?: number;
|
|
89
|
+
converted?: number;
|
|
90
|
+
skipped?: readonly { readonly path: string; readonly reason: string }[];
|
|
82
91
|
error?: string;
|
|
83
92
|
}
|
|
84
93
|
|
|
@@ -114,8 +123,8 @@ export async function serviceInterrupt(
|
|
|
114
123
|
} else if (msg.type === "rlm_query_batched") {
|
|
115
124
|
const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
|
|
116
125
|
reply(msg.rid, { responses });
|
|
117
|
-
} else if (msg.type === "
|
|
118
|
-
const lib = await h.
|
|
126
|
+
} else if (msg.type === "add_context") {
|
|
127
|
+
const lib = await h.addContext(msg.source ?? "", d);
|
|
119
128
|
if (lib.alreadyLoaded) {
|
|
120
129
|
// No temp file — worker short-circuits on already_loaded.
|
|
121
130
|
reply(msg.rid, {
|
|
@@ -124,10 +133,13 @@ export async function serviceInterrupt(
|
|
|
124
133
|
chars: lib.chars,
|
|
125
134
|
source_id: lib.sourceId,
|
|
126
135
|
path_prefix: lib.pathPrefix,
|
|
136
|
+
documents: lib.documents ?? 0,
|
|
137
|
+
converted: lib.converted ?? 0,
|
|
138
|
+
skipped: lib.skipped,
|
|
127
139
|
});
|
|
128
140
|
} else {
|
|
129
141
|
const { path, json: isJson } = await writeContextTempFile(lib.payload);
|
|
130
|
-
// Worker reads then unlinks (worker.
|
|
142
|
+
// Worker reads then unlinks (worker._add_context). Host must not unlink here —
|
|
131
143
|
// if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
|
|
132
144
|
reply(msg.rid, {
|
|
133
145
|
path,
|
|
@@ -136,6 +148,9 @@ export async function serviceInterrupt(
|
|
|
136
148
|
chars: lib.chars,
|
|
137
149
|
source_id: lib.sourceId,
|
|
138
150
|
path_prefix: lib.pathPrefix,
|
|
151
|
+
documents: lib.documents ?? 0,
|
|
152
|
+
converted: lib.converted ?? 0,
|
|
153
|
+
skipped: lib.skipped,
|
|
139
154
|
});
|
|
140
155
|
}
|
|
141
156
|
}
|
package/src/sandbox/protocol.ts
CHANGED
|
@@ -18,15 +18,21 @@ export interface LlmReply {
|
|
|
18
18
|
readonly rid: string;
|
|
19
19
|
readonly response?: string;
|
|
20
20
|
readonly responses?: readonly string[];
|
|
21
|
-
/**
|
|
21
|
+
/** add_context reply: temp file with the packed payload (+ namespace metadata). */
|
|
22
22
|
readonly path?: string;
|
|
23
23
|
readonly json?: boolean;
|
|
24
24
|
readonly files?: number;
|
|
25
25
|
readonly chars?: number;
|
|
26
26
|
readonly source_id?: string;
|
|
27
27
|
readonly path_prefix?: string;
|
|
28
|
-
/** Host-side idempotency:
|
|
28
|
+
/** Host-side idempotency: source already loaded — no path payload. */
|
|
29
29
|
readonly already_loaded?: boolean;
|
|
30
|
+
/** Document-type files in the payload (fresh + cache hits). */
|
|
31
|
+
readonly documents?: number;
|
|
32
|
+
/** Documents freshly converted this call (cache hits excluded). */
|
|
33
|
+
readonly converted?: number;
|
|
34
|
+
/** Paths skipped during packing (binary, no-converter, …). */
|
|
35
|
+
readonly skipped?: readonly { readonly path: string; readonly reason: string }[];
|
|
30
36
|
readonly error?: string;
|
|
31
37
|
}
|
|
32
38
|
|
|
@@ -56,7 +62,7 @@ export type InterruptKind =
|
|
|
56
62
|
| "llm_query_batched"
|
|
57
63
|
| "rlm_query"
|
|
58
64
|
| "rlm_query_batched"
|
|
59
|
-
| "
|
|
65
|
+
| "add_context";
|
|
60
66
|
|
|
61
67
|
interface InterruptBase {
|
|
62
68
|
readonly rid: string;
|
|
@@ -88,8 +94,8 @@ interface BatchedPromptInterrupt extends InterruptBase {
|
|
|
88
94
|
readonly paths?: readonly string[];
|
|
89
95
|
}
|
|
90
96
|
|
|
91
|
-
export interface
|
|
92
|
-
readonly type: "
|
|
97
|
+
export interface AddContextInterrupt extends InterruptBase {
|
|
98
|
+
readonly type: "add_context";
|
|
93
99
|
readonly source?: string;
|
|
94
100
|
}
|
|
95
101
|
|
|
@@ -97,7 +103,7 @@ export interface LoadLibraryInterrupt extends InterruptBase {
|
|
|
97
103
|
export type WorkerInterrupt =
|
|
98
104
|
| PromptInterrupt
|
|
99
105
|
| BatchedPromptInterrupt
|
|
100
|
-
|
|
|
106
|
+
| AddContextInterrupt;
|
|
101
107
|
|
|
102
108
|
export type WorkerMessage = WorkerResponse | WorkerInterrupt;
|
|
103
109
|
|
|
@@ -106,7 +112,7 @@ export const INTERRUPT_KINDS = Object.freeze(new Set<InterruptKind>([
|
|
|
106
112
|
"llm_query_batched",
|
|
107
113
|
"rlm_query",
|
|
108
114
|
"rlm_query_batched",
|
|
109
|
-
"
|
|
115
|
+
"add_context",
|
|
110
116
|
]));
|
|
111
117
|
|
|
112
118
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/sandbox/py/guards.py
CHANGED
|
@@ -19,6 +19,13 @@ import sys
|
|
|
19
19
|
from contextlib import contextmanager
|
|
20
20
|
from typing import Any
|
|
21
21
|
|
|
22
|
+
from hostio import pin_stdio_utf8
|
|
23
|
+
|
|
24
|
+
# Pin UTF-8 before anything reads or writes a frame. On Windows these three default to the
|
|
25
|
+
# locale encoding (cp1252), while the JSONL protocol Node speaks is UTF-8 both ways — see
|
|
26
|
+
# issue #7. reconfigure() mutates in place, so the REAL_* captures below are the same objects.
|
|
27
|
+
pin_stdio_utf8()
|
|
28
|
+
|
|
22
29
|
# Capture the REAL stdio before exec() redirects sys.stdout/sys.stderr into buffers.
|
|
23
30
|
# All protocol writes must go to the real stdout even while user code's prints are captured.
|
|
24
31
|
REAL_STDOUT = sys.stdout
|
|
@@ -86,7 +93,7 @@ RESERVED = frozenset(
|
|
|
86
93
|
"spawn", "rlm_await", "rlm_await_all",
|
|
87
94
|
"map_files", "llm_map_reduce",
|
|
88
95
|
"search", "grep_context", "outline",
|
|
89
|
-
"
|
|
96
|
+
"add_context",
|
|
90
97
|
"SHOW_VARS", "answer", "context",
|
|
91
98
|
}
|
|
92
99
|
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Host↔worker transport, pinned to UTF-8.
|
|
2
|
+
|
|
3
|
+
Every byte the host hands this worker is UTF-8: the context temp files come from Node's
|
|
4
|
+
FileHandle.write(string) (default utf8, see sandbox/context-file.ts) and the JSONL frames come
|
|
5
|
+
off a pipe Node writes the same way. Python does NOT match that — text I/O defaults to the
|
|
6
|
+
locale encoding, which is cp1252 on a Western Windows install. That mismatch is issue #7: a
|
|
7
|
+
packed repo holding any non-ASCII byte raised UnicodeDecodeError before the REPL ever ran.
|
|
8
|
+
|
|
9
|
+
Encoding is therefore never left to the locale here. Interpreter-wide UTF-8 mode (-X utf8=1,
|
|
10
|
+
set by the host in sandbox.ts) covers model-written open(); these two functions cover the
|
|
11
|
+
scaffold's own I/O, and they win outright — PYTHONIOENCODING overrides -X utf8=1, and
|
|
12
|
+
reconfigure() overrides PYTHONIOENCODING.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
ENCODING = "utf-8"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_host_payload(path: str, is_json: bool) -> Any:
|
|
26
|
+
"""Read a payload the host serialized to a temp file.
|
|
27
|
+
|
|
28
|
+
Strict on decode errors by design. The host always writes UTF-8, so a failure here is a
|
|
29
|
+
transport bug, not bad input — and errors="replace" would hand the model silently
|
|
30
|
+
mojibake'd source code instead of surfacing the problem.
|
|
31
|
+
|
|
32
|
+
The explicit encoding= is intentionally redundant with -X utf8=1 under a normal spawn:
|
|
33
|
+
UTF-8 mode already makes the default encoding UTF-8. It still matters so a standalone
|
|
34
|
+
import of this module (or a worker started without -X utf8=1) cannot silently reintroduce
|
|
35
|
+
issue #7. phase-encoding.ts check 6 (AST / EncodingWarning) is the only automated guard
|
|
36
|
+
that encoding= is present — do not drop either side of that net.
|
|
37
|
+
"""
|
|
38
|
+
with io.open(path, "r", encoding=ENCODING) as f:
|
|
39
|
+
return json.load(f) if is_json else f.read()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pin_stdio_utf8() -> None:
|
|
43
|
+
"""Force the three real streams to UTF-8, whatever the locale or PYTHONIOENCODING says.
|
|
44
|
+
|
|
45
|
+
The write side uses surrogatepass, not strict: model code can synthesize a lone surrogate
|
|
46
|
+
(a bad decode, a truncated pair), and json.dumps(ensure_ascii=False) would then raise
|
|
47
|
+
inside _send — killing the worker on a request it had already completed. The read side
|
|
48
|
+
uses replace so a corrupt frame comes back as a "bad json" error response instead of an
|
|
49
|
+
exception that escapes main()'s loop and takes the worker down (fail-soft I/O, AGENTS.md).
|
|
50
|
+
"""
|
|
51
|
+
for stream, errors in (
|
|
52
|
+
(sys.stdin, "replace"),
|
|
53
|
+
(sys.stdout, "surrogatepass"),
|
|
54
|
+
(sys.stderr, "surrogatepass"),
|
|
55
|
+
):
|
|
56
|
+
if isinstance(stream, io.TextIOWrapper): # not a TextIOWrapper under some test harnesses
|
|
57
|
+
stream.reconfigure(encoding=ENCODING, errors=errors)
|
|
@@ -46,7 +46,7 @@ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
_INDEX_WINDOW_LINES = 40 # a window is the retrieval unit: big enough to carry meaning
|
|
49
|
-
_INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge
|
|
49
|
+
_INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge add_context() cannot exhaust worker memory
|
|
50
50
|
_SNIPPET_CHARS = 400
|
|
51
51
|
_GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whatever k asks for
|
|
52
52
|
_BM25_K1 = 1.2
|
package/src/sandbox/py/tasks.py
CHANGED
|
@@ -41,14 +41,27 @@ def _reduce_batch(n: int):
|
|
|
41
41
|
return reduce
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
def _reduce_chunked(sizes: list[int]):
|
|
45
|
-
"""Concatenate several llm_query_batched replies back into one flat chunk list.
|
|
44
|
+
def _reduce_chunked(sizes: list[int], drop_empty: bool = True):
|
|
45
|
+
"""Concatenate several llm_query_batched replies back into one flat chunk list.
|
|
46
|
+
|
|
47
|
+
drop_empty=True (llm_query_chunked): filter "" replies so results never degrade
|
|
48
|
+
to blank entries; the flattened list may then be shorter than the chunk count,
|
|
49
|
+
so callers must consume answers in order and never index-match a chunk to a slot.
|
|
50
|
+
drop_empty=False (map_files): keep "" placeholders because _reduce_map_files
|
|
51
|
+
regroups by position — dropping an empty reply there would shift every later
|
|
52
|
+
slice and merge file contents into the wrong paths.
|
|
53
|
+
"""
|
|
46
54
|
per = [_reduce_batch(n) for n in sizes]
|
|
47
55
|
|
|
48
56
|
def reduce(replies: list[dict[str, Any]]) -> list[str]:
|
|
57
|
+
if len(replies) != len(per):
|
|
58
|
+
return [f"Error: chunk reply count mismatch ({len(replies)} != {len(per)})"] * sum(sizes)
|
|
49
59
|
out: list[str] = []
|
|
50
60
|
for red, rep in zip(per, replies):
|
|
51
|
-
|
|
61
|
+
if drop_empty:
|
|
62
|
+
out.extend(x for x in red([rep]) if x)
|
|
63
|
+
else:
|
|
64
|
+
out.extend(red([rep]))
|
|
52
65
|
return out
|
|
53
66
|
return reduce
|
|
54
67
|
|
|
@@ -59,7 +72,7 @@ def _reduce_map_files(sizes: list[int], spans: list[tuple[str, int]]):
|
|
|
59
72
|
A file larger than the per-prompt budget contributed several requests; its answers rejoin
|
|
60
73
|
in order, which is what makes map_files a {path: answer} dict rather than a flat list.
|
|
61
74
|
"""
|
|
62
|
-
flatten = _reduce_chunked(sizes)
|
|
75
|
+
flatten = _reduce_chunked(sizes, drop_empty=False)
|
|
63
76
|
|
|
64
77
|
def reduce(replies: list[dict[str, Any]]) -> dict[str, str]:
|
|
65
78
|
responses = flatten(replies)
|
package/src/sandbox/py/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
|
-
"rlm_query_batched"|"
|
|
10
|
+
"rlm_query_batched"|"add_context","rid",...}
|
|
11
11
|
# mid-exec helper request
|
|
12
|
-
When sandbox code calls llm_query/rlm_query/
|
|
12
|
+
When sandbox code calls llm_query/rlm_query/add_context, the worker writes a
|
|
13
13
|
request line and BLOCKS reading stdin until the matching {"type":"llm_reply","rid",...} arrives.
|
|
14
14
|
The parent services the request in-process (it holds API keys).
|
|
15
15
|
|
|
@@ -44,6 +44,7 @@ from guards import (
|
|
|
44
44
|
REAL_STDERR as _REAL_STDERR,
|
|
45
45
|
REAL_STDIN as _REAL_STDIN,
|
|
46
46
|
)
|
|
47
|
+
from hostio import read_host_payload
|
|
47
48
|
from retrieval import (
|
|
48
49
|
_Bm25Index,
|
|
49
50
|
_chunk_text,
|
|
@@ -113,7 +114,7 @@ class Worker:
|
|
|
113
114
|
builtins = _SAFE_BUILTINS.copy()
|
|
114
115
|
builtins["open"] = open
|
|
115
116
|
self.ns = {"__builtins__": builtins, "__name__": "__main__"}
|
|
116
|
-
self._context_payload: Any
|
|
117
|
+
self._context_payload: Any = [] # empty list — the only starting value that needs no bootstrap branch
|
|
117
118
|
self._nudged: set[str] = set()
|
|
118
119
|
self._index: _Bm25Index | None = None
|
|
119
120
|
self._index_stamp: tuple[int, int] | None = None # (id(context), len(context))
|
|
@@ -144,7 +145,7 @@ class Worker:
|
|
|
144
145
|
ns["answers"] = {}
|
|
145
146
|
if not isinstance(ns.get("plan"), dict):
|
|
146
147
|
ns["plan"] = {}
|
|
147
|
-
ns["
|
|
148
|
+
ns["add_context"] = self._add_context
|
|
148
149
|
ns["SHOW_VARS"] = self._show_vars
|
|
149
150
|
if not isinstance(ns.get("answer"), _AnswerDict):
|
|
150
151
|
cur = ns.get("answer")
|
|
@@ -157,9 +158,8 @@ class Worker:
|
|
|
157
158
|
ns["answer"] = ans
|
|
158
159
|
# Single context variable (RLM paper: the context lives in the environment and
|
|
159
160
|
# the model may transform it in place). Re-inject only if the model deleted the
|
|
160
|
-
# name entirely; mutations and re-binds persist within the run.
|
|
161
|
-
|
|
162
|
-
ns.setdefault("context", self._context_payload)
|
|
161
|
+
# name entirely; mutations and re-binds persist within the run. Always a list.
|
|
162
|
+
ns.setdefault("context", self._context_payload)
|
|
163
163
|
# Scrub any legacy context_N names so the model never sees multi-slot APIs.
|
|
164
164
|
for k in list(ns.keys()):
|
|
165
165
|
if k != "context" and _CONTEXT_NAME.match(k):
|
|
@@ -412,7 +412,7 @@ class Worker:
|
|
|
412
412
|
"""Build the BM25 index on first use; rebuild when `context` was replaced or resized.
|
|
413
413
|
|
|
414
414
|
Identity+length is a cheap stamp that catches the two ways context actually changes:
|
|
415
|
-
|
|
415
|
+
add_context() extending the list, and the model re-binding the name. In-place edits
|
|
416
416
|
that preserve length are not detected — documented, and rare in practice.
|
|
417
417
|
"""
|
|
418
418
|
ctx = self.ns.get("context")
|
|
@@ -556,23 +556,24 @@ class Worker:
|
|
|
556
556
|
def _rlm_query(self, prompt: str, model: str | None = None, paths=None) -> str:
|
|
557
557
|
return self._await_task(self._start_rlm_query(prompt, model, paths))
|
|
558
558
|
|
|
559
|
-
def
|
|
559
|
+
def _add_context(self, source: str) -> dict[str, Any] | str:
|
|
560
560
|
"""Pack an external dir/file/git-URL on the host and append it into `context`.
|
|
561
561
|
|
|
562
|
-
Paths are namespaced under
|
|
562
|
+
Paths are namespaced under ctx/<source_id>/ (host). Content is always in the
|
|
563
563
|
single `context` list — never a new context_N variable.
|
|
564
564
|
Host-side idempotency may return already_loaded without a payload path.
|
|
565
|
+
Documents (PDF/DOCX/…) are converted to Markdown on the host.
|
|
565
566
|
"""
|
|
566
|
-
r = self._rpc("
|
|
567
|
+
r = self._rpc("add_context", {"source": str(source)})
|
|
567
568
|
if r.get("error"):
|
|
568
569
|
return f"Error: {r['error']}"
|
|
569
570
|
if r.get("already_loaded"):
|
|
570
|
-
source_id = r.get("source_id") if isinstance(r.get("source_id"), str) else "
|
|
571
|
-
path_prefix = r.get("path_prefix") if isinstance(r.get("path_prefix"), str) else f"
|
|
571
|
+
source_id = r.get("source_id") if isinstance(r.get("source_id"), str) else "ctx"
|
|
572
|
+
path_prefix = r.get("path_prefix") if isinstance(r.get("path_prefix"), str) else f"ctx/{source_id}/"
|
|
572
573
|
ctx = self.ns.get("context")
|
|
573
574
|
ctx_len = len(ctx) if isinstance(ctx, list) else 0
|
|
574
575
|
print(
|
|
575
|
-
f"[rlm]
|
|
576
|
+
f"[rlm] add_context: already loaded {source_id} "
|
|
576
577
|
f"(paths under {path_prefix}, context len={ctx_len})"
|
|
577
578
|
)
|
|
578
579
|
return {
|
|
@@ -583,69 +584,85 @@ class Worker:
|
|
|
583
584
|
"chars": r.get("chars"),
|
|
584
585
|
"context_len": ctx_len,
|
|
585
586
|
"already_loaded": True,
|
|
587
|
+
"documents": 0,
|
|
588
|
+
"converted": 0,
|
|
589
|
+
"skipped": [],
|
|
586
590
|
}
|
|
587
591
|
path = r.get("path")
|
|
588
592
|
if not isinstance(path, str):
|
|
589
|
-
return "Error: malformed
|
|
593
|
+
return "Error: malformed add_context reply (no path)"
|
|
590
594
|
try:
|
|
591
|
-
|
|
592
|
-
payload = json.load(f) if r.get("json") else f.read()
|
|
595
|
+
payload = read_host_payload(path, bool(r.get("json")))
|
|
593
596
|
finally:
|
|
594
597
|
try:
|
|
595
598
|
os.remove(path) # worker owns temp-file cleanup (host does NOT unlink)
|
|
596
599
|
except OSError:
|
|
597
600
|
pass
|
|
598
|
-
return self.
|
|
601
|
+
return self._append_context(str(source), payload, r)
|
|
599
602
|
|
|
600
|
-
def
|
|
601
|
-
"""Append host-packed
|
|
603
|
+
def _append_context(self, source: str, payload: Any, meta: dict[str, Any]) -> dict[str, Any] | str:
|
|
604
|
+
"""Append host-packed files into `context` (idempotent by path prefix).
|
|
602
605
|
|
|
603
606
|
The two refusals below are pre-flighted host-side by LIST_CONTEXT_REQUIRED /
|
|
604
|
-
NO_FILES_PRODUCED in src/bridge/
|
|
607
|
+
NO_FILES_PRODUCED in src/bridge/add-context.ts, so the host never commits a
|
|
605
608
|
loaded-prefix for an append that fails here. Reaching either one means host and worker
|
|
606
609
|
disagree about `context`; keep the wording identical to its twin.
|
|
607
610
|
"""
|
|
608
611
|
ctx = self.ns.get("context")
|
|
609
612
|
if not isinstance(ctx, list):
|
|
610
613
|
kind = type(ctx).__name__ if ctx is not None else "None"
|
|
611
|
-
return f"Error:
|
|
614
|
+
return f"Error: add_context requires list context (file bundle); got {kind}"
|
|
612
615
|
|
|
613
616
|
source_id = meta.get("source_id")
|
|
614
617
|
if not isinstance(source_id, str) or not source_id:
|
|
615
|
-
source_id = "
|
|
618
|
+
source_id = "ctx"
|
|
616
619
|
path_prefix = meta.get("path_prefix")
|
|
617
|
-
if not isinstance(path_prefix, str)
|
|
618
|
-
path_prefix = f"
|
|
619
|
-
|
|
620
|
-
#
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
)
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
620
|
+
if not isinstance(path_prefix, str):
|
|
621
|
+
path_prefix = f"ctx/{source_id}/"
|
|
622
|
+
# Empty path_prefix is valid (cwd seed) but add_context always sends a non-empty ctx/ prefix.
|
|
623
|
+
# Guard startsWith on empty prefix: "anything".startswith("") is always True.
|
|
624
|
+
check_prefix = path_prefix if path_prefix != "" else None
|
|
625
|
+
|
|
626
|
+
# Idempotent: already present if any path uses this prefix.
|
|
627
|
+
if check_prefix is not None:
|
|
628
|
+
for item in ctx:
|
|
629
|
+
if isinstance(item, dict) and str(item.get("path", "")).startswith(check_prefix):
|
|
630
|
+
print(
|
|
631
|
+
f"[rlm] add_context: already loaded {source_id} "
|
|
632
|
+
f"(paths under {path_prefix}, context len={len(ctx)})"
|
|
633
|
+
)
|
|
634
|
+
return {
|
|
635
|
+
"source": source,
|
|
636
|
+
"source_id": source_id,
|
|
637
|
+
"path_prefix": path_prefix,
|
|
638
|
+
"files": 0,
|
|
639
|
+
"chars": meta.get("chars"),
|
|
640
|
+
"context_len": len(ctx),
|
|
641
|
+
"already_loaded": True,
|
|
642
|
+
"documents": 0,
|
|
643
|
+
"converted": 0,
|
|
644
|
+
"skipped": [],
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
files = self._context_file_entries(payload, path_prefix)
|
|
638
648
|
if not files:
|
|
639
|
-
return "Error:
|
|
649
|
+
return "Error: add_context produced no files"
|
|
640
650
|
|
|
641
651
|
ctx.extend(files)
|
|
642
652
|
# Keep restore payload in sync with the live list.
|
|
643
653
|
self._context_payload = ctx
|
|
644
654
|
self.ns["context"] = ctx
|
|
645
655
|
|
|
656
|
+
documents = meta.get("documents") if isinstance(meta.get("documents"), int) else 0
|
|
657
|
+
converted = meta.get("converted") if isinstance(meta.get("converted"), int) else 0
|
|
658
|
+
skipped = meta.get("skipped") if isinstance(meta.get("skipped"), list) else []
|
|
659
|
+
skip_n = len(skipped)
|
|
660
|
+
extra = ""
|
|
661
|
+
if documents or converted or skip_n:
|
|
662
|
+
extra = f"; documents={documents}, converted={converted}, skipped={skip_n}"
|
|
646
663
|
print(
|
|
647
|
-
f"[rlm]
|
|
648
|
-
f"(len={len(ctx)}); paths under {path_prefix}"
|
|
664
|
+
f"[rlm] add_context: +{len(files)} files into context "
|
|
665
|
+
f"(len={len(ctx)}); paths under {path_prefix}{extra}"
|
|
649
666
|
)
|
|
650
667
|
return {
|
|
651
668
|
"source": source,
|
|
@@ -655,14 +672,17 @@ class Worker:
|
|
|
655
672
|
"chars": meta.get("chars"),
|
|
656
673
|
"context_len": len(ctx),
|
|
657
674
|
"already_loaded": False,
|
|
675
|
+
"documents": documents,
|
|
676
|
+
"converted": converted,
|
|
677
|
+
"skipped": skipped,
|
|
658
678
|
}
|
|
659
679
|
|
|
660
680
|
@staticmethod
|
|
661
|
-
def
|
|
681
|
+
def _context_file_entries(payload: Any, path_prefix: str) -> list[dict[str, Any]]:
|
|
662
682
|
"""Normalize host payload to list[dict]. Host already namespaces; string is fallback."""
|
|
663
683
|
if isinstance(payload, str):
|
|
664
684
|
return [{
|
|
665
|
-
"path": f"{path_prefix}content",
|
|
685
|
+
"path": f"{path_prefix}content" if path_prefix else "content",
|
|
666
686
|
"content": payload,
|
|
667
687
|
"tokens": max(1, (len(payload) + 3) // 4),
|
|
668
688
|
}]
|
|
@@ -684,10 +704,9 @@ class Worker:
|
|
|
684
704
|
"""Load the packed world into the single REPL variable `context`.
|
|
685
705
|
|
|
686
706
|
`index` is accepted for protocol compatibility but ignored — there is only
|
|
687
|
-
one context slot.
|
|
707
|
+
one context slot. Sources are merged on the host (or via add_context).
|
|
688
708
|
"""
|
|
689
|
-
|
|
690
|
-
payload = json.load(f) if is_json else f.read()
|
|
709
|
+
payload = read_host_payload(path, bool(is_json))
|
|
691
710
|
self._context_payload = payload
|
|
692
711
|
self.ns["context"] = payload
|
|
693
712
|
# Drop legacy multi-slot names if present.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { PythonSandbox, type SubLlmHandlers } from "./sandbox.ts";
|
|
8
8
|
import type { ReplResult } from "./protocol.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { mergeIntoContext } from "../context/merge.ts";
|
|
10
10
|
|
|
11
11
|
/** Static configuration for sandbox creation — set once, reused across getOrCreate calls. */
|
|
12
12
|
export interface SandboxManagerConfig {
|
|
@@ -28,23 +28,26 @@ export class SandboxManager {
|
|
|
28
28
|
/** Serialized execution queue — concurrent repl() calls wait for predecessor. */
|
|
29
29
|
private execQueue: Promise<void> = Promise.resolve();
|
|
30
30
|
private pendingExecCount = 0;
|
|
31
|
-
/**
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Context payload to load on first sandbox creation. Starts as an empty list — context is
|
|
33
|
+
* empty by default; the first repl() seeds the cwd when autoSeedCwd is on.
|
|
34
|
+
*/
|
|
35
|
+
contextPayload: unknown = [];
|
|
33
36
|
/** True once contextPayload has been loaded into the sandbox (prevents reload + race fix). */
|
|
34
37
|
private contextLoaded = false;
|
|
35
38
|
|
|
36
39
|
constructor(private readonly config: SandboxManagerConfig) {}
|
|
37
40
|
|
|
38
41
|
/**
|
|
39
|
-
* Append a
|
|
42
|
+
* Append a source payload to the context this manager replays on death-recreate.
|
|
40
43
|
*
|
|
41
|
-
* The live worker has ALREADY appended it in-process (worker.py `
|
|
44
|
+
* The live worker has ALREADY appended it in-process (worker.py `_append_context`), so this
|
|
42
45
|
* deliberately does not reload — it only keeps the host's replay copy truthful. Without it a
|
|
43
|
-
* recreate silently rolls the sandbox back to a
|
|
44
|
-
* this payload would never see the
|
|
46
|
+
* recreate silently rolls the sandbox back to a pre-append context, and any child inheriting
|
|
47
|
+
* this payload would never see the source. Dedups by `ctx/<id>/` prefix.
|
|
45
48
|
*/
|
|
46
|
-
|
|
47
|
-
this.contextPayload =
|
|
49
|
+
appendContext(payload: unknown): void {
|
|
50
|
+
this.contextPayload = mergeIntoContext(this.contextPayload, payload);
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
/**
|
|
@@ -52,15 +55,14 @@ export class SandboxManager {
|
|
|
52
55
|
* given handlers. Subsequent calls return the existing sandbox immediately.
|
|
53
56
|
* Deduplicates concurrent calls via initPromise.
|
|
54
57
|
*
|
|
55
|
-
* If contextPayload is
|
|
58
|
+
* If contextPayload is defined, it is loaded before the sandbox is returned.
|
|
56
59
|
*/
|
|
57
60
|
async getOrCreate(handlers: Partial<SubLlmHandlers>): Promise<PythonSandbox> {
|
|
58
61
|
if (this.disposed) throw new Error("SandboxManager disposed");
|
|
59
62
|
if (this.sandbox) {
|
|
60
|
-
// RACE FIX: contextPayload may arrive after the sandbox was created (
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
if (this.contextPayload !== null && !this.contextLoaded) {
|
|
63
|
+
// RACE FIX: contextPayload may arrive after the sandbox was created (lazy seed
|
|
64
|
+
// resolves after the first getOrCreate). Load it into the live sandbox now if pending.
|
|
65
|
+
if (this.contextPayload !== undefined && !this.contextLoaded) {
|
|
64
66
|
await this.sandbox.loadContext(this.contextPayload);
|
|
65
67
|
this.contextLoaded = true;
|
|
66
68
|
}
|
|
@@ -79,8 +81,8 @@ export class SandboxManager {
|
|
|
79
81
|
awaitTimeoutS: this.config.awaitTimeoutS,
|
|
80
82
|
handlers,
|
|
81
83
|
}).then(async (s) => {
|
|
82
|
-
// Load context on first creation if available.
|
|
83
|
-
if (this.contextPayload !==
|
|
84
|
+
// Load context on first creation if available (empty list is a valid starting value).
|
|
85
|
+
if (this.contextPayload !== undefined) {
|
|
84
86
|
await s.loadContext(this.contextPayload);
|
|
85
87
|
this.contextLoaded = true;
|
|
86
88
|
}
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { pinContext, type PinnedContext } from "./context-file.ts";
|
|
|
24
24
|
import { REJECT, serviceInterrupt, type ReplyBody, type SubLlmHandlers } from "./interrupts.ts";
|
|
25
25
|
import { trace, traceEnabled } from "../util/trace.ts";
|
|
26
26
|
|
|
27
|
-
export type {
|
|
27
|
+
export type { AddContextResult, SubcallOpts, SubLlmHandlers } from "./interrupts.ts";
|
|
28
28
|
|
|
29
29
|
export interface SandboxOptions {
|
|
30
30
|
/** Sandbox recursion depth label (passed to the worker, used in interrupt routing). */
|
|
@@ -111,6 +111,11 @@ export class PythonSandbox {
|
|
|
111
111
|
this.initTimeoutMs = opts.initTimeoutMs ?? 30_000;
|
|
112
112
|
const python = opts.python ?? "python3";
|
|
113
113
|
const workerArgs = [
|
|
114
|
+
// -X utf8=1: the scaffold states its own encoding explicitly (py/hostio.py), but MODEL
|
|
115
|
+
// code gets a real open() — guards.py exposes it deliberately — and on Windows that
|
|
116
|
+
// would default to cp1252 (issue #7). UTF-8 mode covers the whole interpreter; the
|
|
117
|
+
// scaffold's explicit encoding= still wins where PYTHONIOENCODING would override this.
|
|
118
|
+
"-X", "utf8=1",
|
|
114
119
|
"-u", WORKER_PATH,
|
|
115
120
|
"--depth", String(opts.depth ?? 1),
|
|
116
121
|
"--timeout", String(opts.execTimeoutS ?? 600),
|
|
@@ -124,7 +129,9 @@ export class PythonSandbox {
|
|
|
124
129
|
this.proc = spawn(
|
|
125
130
|
python,
|
|
126
131
|
workerArgs,
|
|
127
|
-
|
|
132
|
+
// windowsHide: without it each sandbox flashes a console window on Windows (pi sets
|
|
133
|
+
// this on every spawn — bash.ts / shell.ts). Same Windows surface as issue #7.
|
|
134
|
+
{ stdio: ["pipe", "pipe", "pipe"], env: sanitizedEnv(), windowsHide: true },
|
|
128
135
|
) as ChildProcessWithoutNullStreams;
|
|
129
136
|
|
|
130
137
|
this.proc.stdout.setEncoding("utf8");
|