@hicaru/pi-rlm 0.2.1 → 0.3.0

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 (79) hide show
  1. package/README.md +28 -47
  2. package/README.ru.md +18 -23
  3. package/README.zh-CN.md +17 -28
  4. package/package.json +22 -19
  5. package/src/bridge/add-context.ts +322 -0
  6. package/src/bridge/subcall-handlers.ts +63 -17
  7. package/src/commands/rlm-config.ts +47 -18
  8. package/src/commands/rlm.ts +3 -152
  9. package/src/config/defaults.ts +8 -18
  10. package/src/config/settings.ts +13 -34
  11. package/src/context/anydoc.ts +67 -0
  12. package/src/context/listing.ts +70 -0
  13. package/src/context/md-cache.ts +112 -0
  14. package/src/context/merge.ts +97 -0
  15. package/src/context/namespace.ts +180 -0
  16. package/src/context/resolve.ts +122 -0
  17. package/src/context/source-dir.ts +166 -0
  18. package/src/context/source-doc.ts +71 -0
  19. package/src/context/source-git.ts +51 -0
  20. package/src/context/source-text.ts +45 -0
  21. package/src/context/types.ts +88 -0
  22. package/src/context/walk.ts +250 -0
  23. package/src/core/engine.ts +61 -345
  24. package/src/core/history.ts +1 -1
  25. package/src/core/limits.ts +5 -12
  26. package/src/core/resource-limits.ts +0 -2
  27. package/src/core/types.ts +10 -38
  28. package/src/index.ts +92 -54
  29. package/src/mode/llm-model.ts +54 -0
  30. package/src/mode/rlm-mode.ts +28 -58
  31. package/src/prompts/glossary.ts +290 -0
  32. package/src/prompts/native.ts +127 -0
  33. package/src/prompts/system.ts +15 -408
  34. package/src/sandbox/context-file.ts +154 -0
  35. package/src/sandbox/interrupts.ts +160 -0
  36. package/src/sandbox/protocol.ts +20 -75
  37. package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
  38. package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
  39. package/src/sandbox/py/__pycache__/tasks.cpython-314.pyc +0 -0
  40. package/src/sandbox/py/guards.py +150 -0
  41. package/src/sandbox/py/retrieval.py +265 -0
  42. package/src/sandbox/py/tasks.py +129 -0
  43. package/src/sandbox/py/worker.py +856 -0
  44. package/src/sandbox/sandbox-manager.ts +24 -9
  45. package/src/sandbox/sandbox.ts +99 -193
  46. package/src/text/tokens.ts +31 -5
  47. package/src/tool/repl-details.ts +2 -2
  48. package/src/tool/repl-render.ts +58 -0
  49. package/src/tool/repl-result.ts +70 -0
  50. package/src/tool/repl-tool.ts +60 -170
  51. package/src/tool/rlm-aggregator.ts +2 -10
  52. package/src/tool/rlm-details.ts +0 -2
  53. package/src/tool/rlm-events.ts +0 -14
  54. package/src/tool/rlm-tool.ts +2 -13
  55. package/src/ui/config-panel.ts +12 -20
  56. package/src/ui/intro.ts +1 -2
  57. package/src/ui/model-picker.ts +34 -10
  58. package/src/ui/status.ts +3 -7
  59. package/src/util/concurrency.ts +9 -5
  60. package/src/bridge/fallback-todo.ts +0 -148
  61. package/src/bridge/interactive.ts +0 -65
  62. package/src/bridge/library.ts +0 -155
  63. package/src/bridge/pi-interactive.ts +0 -41
  64. package/src/context/library-context.ts +0 -266
  65. package/src/context/repomix-context.ts +0 -204
  66. package/src/core/artifacts.ts +0 -89
  67. package/src/core/critique.ts +0 -92
  68. package/src/core/gates.ts +0 -301
  69. package/src/core/pipeline-handlers.ts +0 -319
  70. package/src/core/pipeline.ts +0 -268
  71. package/src/prompts/phases.ts +0 -104
  72. package/src/sandbox/worker.py +0 -1456
  73. package/src/state/index.ts +0 -24
  74. package/src/state/internal.ts +0 -46
  75. package/src/state/paths.ts +0 -44
  76. package/src/state/reads.ts +0 -133
  77. package/src/state/resume.ts +0 -173
  78. package/src/state/rows.ts +0 -123
  79. package/src/state/writes.ts +0 -58
@@ -0,0 +1,160 @@
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 pack requested by `add_context`. */
14
+ export interface AddContextResult {
15
+ readonly payload: unknown; // always ContextFile[] under ctx/<id>/ (or un-prefixed for cwd)
16
+ readonly files?: number;
17
+ readonly chars: number;
18
+ readonly sourceId: string;
19
+ readonly pathPrefix: string;
20
+ /** Host already has this source — no pack, empty payload. */
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 }[];
28
+ }
29
+
30
+ /**
31
+ * Per-interrupt routing context for the sub-LLM handlers.
32
+ *
33
+ * Only the four sub-call kinds can be spawned, so only they carry it; add_context is
34
+ * always synchronous within one exec.
35
+ */
36
+ export interface SubcallOpts {
37
+ /** Started via `spawn()` — route to session-scoped state, not the current invocation. */
38
+ readonly detached: boolean;
39
+ /**
40
+ * `rlm_query(paths=[…])` — path prefixes narrowing the child's inherited context.
41
+ * Absent on every other path; `llm_query` never carries it.
42
+ */
43
+ readonly paths?: readonly string[];
44
+ }
45
+
46
+ /** Handlers the bridge installs to service sub-LLM interrupts. Return the reply payload. */
47
+ export interface SubLlmHandlers {
48
+ llmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
49
+ llmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
50
+ rlmQuery(prompt: string, model: string | null, depth: number, opts: SubcallOpts): Promise<string>;
51
+ rlmQueryBatched(prompts: readonly string[], model: string | null, depth: number, opts: SubcallOpts): Promise<string[]>;
52
+ addContext(source: string, depth: number): Promise<AddContextResult>;
53
+ }
54
+
55
+ /** Narrow an unknown JSON value to a frozen string array. Non-strings and blanks are dropped. */
56
+ function toStringArray(value: unknown): readonly string[] | undefined {
57
+ if (!Array.isArray(value)) return undefined;
58
+ const out = new Array<string>(value.length);
59
+ let n = 0;
60
+ for (let i = 0; i < value.length; i++) {
61
+ const item: unknown = value[i];
62
+ if (typeof item === "string" && item.trim() !== "") out[n++] = item;
63
+ }
64
+ out.length = n;
65
+ return n > 0 ? Object.freeze(out) : undefined;
66
+ }
67
+
68
+ /** Default handlers — every sandbox function refuses until a bridge installs a real one. */
69
+ export const REJECT: SubLlmHandlers = {
70
+ llmQuery: async () => formatError("sub-LLM bridge not configured"),
71
+ llmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
72
+ rlmQuery: async () => formatError("sub-LLM bridge not configured"),
73
+ rlmQueryBatched: async (p) => p.map(() => formatError("sub-LLM bridge not configured")),
74
+ addContext: async () => { throw new Error("add_context not configured"); },
75
+ };
76
+
77
+ /** Body of a reply frame — the union of every handler's payload shape. */
78
+ export interface ReplyBody {
79
+ response?: string;
80
+ responses?: string[];
81
+ path?: string;
82
+ json?: boolean;
83
+ files?: number;
84
+ chars?: number;
85
+ source_id?: string;
86
+ path_prefix?: string;
87
+ already_loaded?: boolean;
88
+ documents?: number;
89
+ converted?: number;
90
+ skipped?: readonly { readonly path: string; readonly reason: string }[];
91
+ error?: string;
92
+ }
93
+
94
+ /**
95
+ * Service one interrupt and hand the reply body to `reply`.
96
+ *
97
+ * Errors are replied, never thrown: the caller invokes this from the stdio pump, where a
98
+ * rejection would surface as an unhandled promise and leave the worker parked forever.
99
+ */
100
+ export async function serviceInterrupt(
101
+ msg: WorkerInterrupt,
102
+ h: SubLlmHandlers,
103
+ reply: (rid: string, body: ReplyBody) => void,
104
+ ): Promise<void> {
105
+ const d = msg.depth;
106
+ const opts: SubcallOpts = Object.freeze({
107
+ detached: msg.detached === true,
108
+ // Only the recursive kinds carry a context slice; the value crossed JSON, so guard it.
109
+ paths: msg.type === "rlm_query" || msg.type === "rlm_query_batched"
110
+ ? toStringArray(msg.paths)
111
+ : undefined,
112
+ });
113
+ try {
114
+ if (msg.type === "llm_query") {
115
+ const response = await h.llmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
116
+ reply(msg.rid, { response });
117
+ } else if (msg.type === "rlm_query") {
118
+ const response = await h.rlmQuery(msg.prompt ?? "", msg.model ?? null, d, opts);
119
+ reply(msg.rid, { response });
120
+ } else if (msg.type === "llm_query_batched") {
121
+ const responses = await h.llmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
122
+ reply(msg.rid, { responses });
123
+ } else if (msg.type === "rlm_query_batched") {
124
+ const responses = await h.rlmQueryBatched(msg.prompts ?? [], msg.model ?? null, d, opts);
125
+ reply(msg.rid, { responses });
126
+ } else if (msg.type === "add_context") {
127
+ const lib = await h.addContext(msg.source ?? "", d);
128
+ if (lib.alreadyLoaded) {
129
+ // No temp file — worker short-circuits on already_loaded.
130
+ reply(msg.rid, {
131
+ already_loaded: true,
132
+ files: 0,
133
+ chars: lib.chars,
134
+ source_id: lib.sourceId,
135
+ path_prefix: lib.pathPrefix,
136
+ documents: lib.documents ?? 0,
137
+ converted: lib.converted ?? 0,
138
+ skipped: lib.skipped,
139
+ });
140
+ } else {
141
+ const { path, json: isJson } = await writeContextTempFile(lib.payload);
142
+ // Worker reads then unlinks (worker._add_context). Host must not unlink here —
143
+ // if the worker is SIGKILLed before os.remove, the temp file leaks in tmpdir (acceptable).
144
+ reply(msg.rid, {
145
+ path,
146
+ json: isJson,
147
+ files: lib.files,
148
+ chars: lib.chars,
149
+ source_id: lib.sourceId,
150
+ path_prefix: lib.pathPrefix,
151
+ documents: lib.documents ?? 0,
152
+ converted: lib.converted ?? 0,
153
+ skipped: lib.skipped,
154
+ });
155
+ }
156
+ }
157
+ } catch (err) {
158
+ reply(msg.rid, { error: errorMessage(err) });
159
+ }
160
+ }
@@ -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,17 +18,21 @@ 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
+ /** add_context 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;
31
27
  readonly path_prefix?: string;
32
- /** Host-side idempotency: library already loaded — no path payload. */
28
+ /** Host-side idempotency: source already loaded — no path payload. */
33
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 }[];
34
36
  readonly error?: string;
35
37
  }
36
38
 
@@ -60,30 +62,7 @@ export type InterruptKind =
60
62
  | "llm_query_batched"
61
63
  | "rlm_query"
62
64
  | "rlm_query_batched"
63
- | "advance_phase"
64
- | "save_artifact"
65
- | "ask_user_question"
66
- | "todo"
67
- | "load_library";
68
-
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
- }
65
+ | "add_context";
87
66
 
88
67
  interface InterruptBase {
89
68
  readonly rid: string;
@@ -100,49 +79,23 @@ interface PromptInterrupt extends InterruptBase {
100
79
  readonly type: "llm_query" | "rlm_query";
101
80
  readonly prompt?: string;
102
81
  readonly model?: string | null;
82
+ /**
83
+ * `rlm_query` only — path prefixes narrowing the child's inherited context. Never sent for
84
+ * `llm_query`, whose frame stays byte-identical to before.
85
+ */
86
+ readonly paths?: readonly string[];
103
87
  }
104
88
 
105
89
  interface BatchedPromptInterrupt extends InterruptBase {
106
90
  readonly type: "llm_query_batched" | "rlm_query_batched";
107
91
  readonly prompts?: readonly string[];
108
92
  readonly model?: string | null;
93
+ /** `rlm_query_batched` only — one prefix set shared by every prompt in the batch. */
94
+ readonly paths?: readonly string[];
109
95
  }
110
96
 
111
- interface AdvancePhaseInterrupt extends InterruptBase {
112
- readonly type: "advance_phase";
113
- readonly phase?: string;
114
- readonly summary?: string;
115
- }
116
-
117
- interface SaveArtifactInterrupt extends InterruptBase {
118
- readonly type: "save_artifact";
119
- readonly artifactKind?: string;
120
- readonly content?: string;
121
- }
122
-
123
- export interface AskUserQuestionInterrupt extends InterruptBase {
124
- readonly type: "ask_user_question";
125
- readonly questions: readonly AskQuestion[];
126
- }
127
-
128
- export interface TodoInterrupt extends InterruptBase {
129
- readonly type: "todo";
130
- readonly action: "create" | "update" | "list" | "get" | "delete" | "clear";
131
- readonly id?: number;
132
- readonly subject?: string;
133
- readonly description?: string;
134
- readonly status?: "pending" | "in_progress" | "completed" | "deleted";
135
- readonly activeForm?: string;
136
- readonly blockedBy?: readonly number[];
137
- readonly addBlockedBy?: readonly number[];
138
- readonly removeBlockedBy?: readonly number[];
139
- readonly owner?: string;
140
- readonly filterStatus?: string;
141
- readonly includeDeleted?: boolean;
142
- }
143
-
144
- export interface LoadLibraryInterrupt extends InterruptBase {
145
- readonly type: "load_library";
97
+ export interface AddContextInterrupt extends InterruptBase {
98
+ readonly type: "add_context";
146
99
  readonly source?: string;
147
100
  }
148
101
 
@@ -150,11 +103,7 @@ export interface LoadLibraryInterrupt extends InterruptBase {
150
103
  export type WorkerInterrupt =
151
104
  | PromptInterrupt
152
105
  | BatchedPromptInterrupt
153
- | AdvancePhaseInterrupt
154
- | SaveArtifactInterrupt
155
- | AskUserQuestionInterrupt
156
- | TodoInterrupt
157
- | LoadLibraryInterrupt;
106
+ | AddContextInterrupt;
158
107
 
159
108
  export type WorkerMessage = WorkerResponse | WorkerInterrupt;
160
109
 
@@ -163,11 +112,7 @@ export const INTERRUPT_KINDS = Object.freeze(new Set<InterruptKind>([
163
112
  "llm_query_batched",
164
113
  "rlm_query",
165
114
  "rlm_query_batched",
166
- "advance_phase",
167
- "save_artifact",
168
- "ask_user_question",
169
- "todo",
170
- "load_library",
115
+ "add_context",
171
116
  ]));
172
117
 
173
118
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -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
+ "add_context",
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}"