@hicaru/pi-rlm 0.3.6 → 0.3.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.
@@ -8,7 +8,7 @@ import type { ContextSizeStats } from "../text/tokens.ts";
8
8
  import {
9
9
  contextKindOf,
10
10
  DEFAULT_PROMPT_CAP,
11
- ENV_TIPS,
11
+ envTips,
12
12
  howToRunCode,
13
13
  LARGE_FILE_RULE_LINES,
14
14
  promptCapTokensK,
@@ -33,23 +33,29 @@ export interface SystemPromptOptions {
33
33
  readonly child?: boolean;
34
34
  /** The recursion depth of this run (0 = root, 1 = first child, etc.). */
35
35
  readonly depth?: number;
36
+ /** v5 doctrine: the child sandbox has the delegation-only surface (no retrieval tools). */
37
+ readonly delegation?: boolean;
36
38
  }
37
39
 
38
- function orchestratorAddendum(maxPromptChars: number): string {
40
+ function orchestratorAddendum(maxPromptChars: number, delegation: boolean): string {
39
41
  return [
40
42
  "As an RLM you are an **orchestrator, not a solver**. Probe `context`, plan decomposition, then",
41
43
  "fan out — do not solve multi-step module work yourself in a long chain of thought.",
42
44
  "",
43
45
  "<contract> llm_query / llm_batch / map_files / rlm_query / rlm_batch return Task (not the answer).",
44
- "Only await_task returns content. Fire independent Tasks first, free search, then await_task.",
46
+ delegation
47
+ ? "Only await_task returns content. Fire independent Tasks first, slice your `context` meanwhile, then await_task."
48
+ : "Only await_task returns content. Fire independent Tasks first, free search, then await_task.",
45
49
  "Do not await after every independent spawn.</contract>",
46
50
  "",
47
51
  "<routing> one-shot facts → llm_query/llm_batch/map_files; one multi-step study → rlm_query;",
48
52
  "≥2 independent multi-step areas → rlm_batch (prefer over serial rlm_query). NEVER print file",
49
53
  "bodies into your own stream when a Task tool can read them.</routing>",
50
54
  "",
51
- "Your own context window is small. Push long-context work into sub-calls. If free search/grep",
52
- "already pins a tiny fact, use that. Aggregate small results in Python / `answers`.",
55
+ "Your own context window is small. Push long-context work into sub-calls.",
56
+ delegation
57
+ ? "If a slice of your `context` already pins a tiny fact, quote it — do not re-ask."
58
+ : "If free search/grep already pins a tiny fact, use that. Aggregate small results in Python / `answers`.",
53
59
  "",
54
60
  `Sub-call budget: (1) per-prompt < ${maxPromptChars.toLocaleString()} chars (≈${promptCapTokensK(maxPromptChars)}K tok);`,
55
61
  "(2) ~20 prompts per llm_batch. Fat prompts in small batches beat thousands of tiny prompts.",
@@ -78,13 +84,22 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
78
84
  `**Recursion depth: ${opts.depth}.** You are a sub-RLM — focus narrowly on your assigned`,
79
85
  "task. Delegate (rlm_query/rlm_batch) only if the task itself must decompose further.",
80
86
  );
87
+ if (opts.delegation ?? false) {
88
+ parts.push(
89
+ "",
90
+ "**REPL API (ONLY these):** llm_query / llm_batch / llm_query_chunked / map_files /",
91
+ "llm_map_reduce / rlm_query / rlm_batch / spawn / await_task / list_tasks / memory.* /",
92
+ "list_claims. There is no search/grep_context/outline here — your task arrived WITH its",
93
+ "world in `context`; slice it into llm prompts. rlm_query only for a disjoint path set.",
94
+ );
95
+ }
81
96
  }
82
97
  parts.push(
83
98
  "",
84
99
  howToRunCode(),
85
100
  "",
86
101
  replGlossary(
87
- kind, recursion, opts.contextLoader ?? false, opts.child ?? false,
102
+ kind, recursion, opts.contextLoader ?? false, opts.child ?? false, opts.delegation ?? false,
88
103
  ),
89
104
  "",
90
105
  "REPL stdout over ~800 characters is truncated to a short excerpt — large results stay in your",
@@ -96,7 +111,7 @@ export function buildRlmSystemPrompt(meta: PromptMeta, opts: SystemPromptOptions
96
111
  if (opts.orchestrator ?? true) {
97
112
  // Two counterweights, both required (paper App. B): the addendum bounds OVER-recursion
98
113
  // (batching/cost), ENV_TIPS bounds UNDER-recursion (solving it yourself).
99
- parts.push("", orchestratorAddendum(maxPromptChars), "", ENV_TIPS);
114
+ parts.push("", orchestratorAddendum(maxPromptChars, opts.delegation ?? false), "", envTips(opts.delegation ?? false));
100
115
  }
101
116
  if (kind === "files") {
102
117
  parts.push("", LARGE_FILE_RULE_LINES.join("\n"));
@@ -19,7 +19,10 @@ export function buildTurnPrompt(
19
19
  return `${prefix}${body}`;
20
20
  }
21
21
 
22
- /** Asked once when the engine runs out of turns without a submitted answer. */
22
+ /** Asked once when the engine runs out of turns without a submitted answer. Same finalize
23
+ * dialect as the budget wrap-up note (audit M6): answer-ready first, plain text only as an
24
+ * explicit fallback the engine still accepts. */
23
25
  export const FINALIZE_PROMPT =
24
- "You are out of turns. Provide your best final answer now based on everything you have gathered, " +
25
- 'by setting `answer["content"]` and `answer["ready"] = True` (fenced ```repl```), or as plain text.';
26
+ "You are out of turns. Finalize NOW: set `answer[\"content\"]` and `answer[\"ready\"] = True` " +
27
+ "(fenced ```repl```) with your best final answer from everything you have gathered. " +
28
+ "Only if the REPL is unavailable, answer as plain text.";
@@ -51,6 +51,14 @@ export interface SubLlmHandlers {
51
51
  ): Promise<unknown>;
52
52
  finishTask(summary: string, depth: number, opts: SubcallOpts): Promise<unknown>;
53
53
  addContext(source: string, depth: number): Promise<AddContextResult>;
54
+ /** v5: the `[ledger]` claims table for the sandbox's `list_claims()` REPL call. */
55
+ ledgerClaims(): Promise<string>;
56
+ /** v5: durable memory surface for the sandbox's `memory.query/add/stats` object. */
57
+ memoryOp(
58
+ op: "query" | "add" | "stats",
59
+ args: { readonly query?: string; readonly k?: number; readonly content?: string; readonly paths?: readonly string[]; readonly tags?: readonly string[] },
60
+ depth: number,
61
+ ): Promise<string>;
54
62
  }
55
63
 
56
64
  function toStringArray(value: unknown): readonly string[] | undefined {
@@ -80,6 +88,8 @@ export const REJECT: SubLlmHandlers = Object.freeze({
80
88
  addContext: async () => {
81
89
  throw new Error("add_context not configured");
82
90
  },
91
+ ledgerClaims: async () => UNCONFIGURED,
92
+ memoryOp: async () => UNCONFIGURED,
83
93
  });
84
94
 
85
95
  export interface ReplyBody {
@@ -337,6 +347,20 @@ export async function serviceInterrupt(
337
347
  });
338
348
  return;
339
349
  }
350
+ case "ledger_claims": {
351
+ const table = await h.ledgerClaims();
352
+ reply(msg.rid, { response: table });
353
+ return;
354
+ }
355
+ case "memory": {
356
+ const out = await h.memoryOp(
357
+ msg.op,
358
+ { query: msg.query, k: msg.k, content: msg.content, paths: msg.paths, tags: msg.tags },
359
+ d,
360
+ );
361
+ reply(msg.rid, { response: out });
362
+ return;
363
+ }
340
364
  default: {
341
365
  const _exhaustive: never = msg;
342
366
  reply((_exhaustive as WorkerInterrupt).rid, {
@@ -2,7 +2,7 @@
2
2
  * Wire protocol for the RLM Python sandbox.
3
3
  *
4
4
  * Newline-delimited JSON over the worker's stdin/stdout — no sockets, no HTTP.
5
- * Parent -> worker: requests (exec/load_context/shutdown) and llm replies.
5
+ * Parent -> worker: requests (exec/load_context/shutdown), llm replies, and heartbeats.
6
6
  * Worker -> parent: request responses and mid-exec sub-LLM interrupts.
7
7
  *
8
8
  * Canonical api_v5 kinds only — no legacy `*_query_batched` wire names.
@@ -44,7 +44,12 @@ export interface LlmReply {
44
44
  readonly error?: string;
45
45
  }
46
46
 
47
- export type ParentMessage = WorkerRequest | LlmReply;
47
+ /** Keep-alive while the host is working and has nothing else to write. */
48
+ export interface Heartbeat {
49
+ readonly type: "heartbeat";
50
+ }
51
+
52
+ export type ParentMessage = WorkerRequest | LlmReply | Heartbeat;
48
53
 
49
54
  /** A normal response to a request (keyed by the request `id`). */
50
55
  export interface WorkerResponse {
@@ -60,11 +65,13 @@ export interface WorkerResponse {
60
65
  readonly execution_time?: number;
61
66
  // user-created variable names after this exec
62
67
  readonly var_names?: readonly string[];
68
+ /** Unsettled Task handles still in the worker (native pending-line / await-all). */
69
+ readonly pending_tasks?: readonly PendingTaskInfo[];
63
70
  // load_context:
64
71
  readonly index?: number;
65
72
  }
66
73
 
67
- /** Canonical interrupt kinds (api_v5). */
74
+ /** Canonical interrupt kinds (api_v5 + v5 ledger + memory). */
68
75
  export type InterruptKind =
69
76
  | "llm_query"
70
77
  | "rlm_query"
@@ -72,7 +79,9 @@ export type InterruptKind =
72
79
  | "rlm_batch"
73
80
  | "await"
74
81
  | "finish"
75
- | "add_context";
82
+ | "add_context"
83
+ | "ledger_claims"
84
+ | "memory";
76
85
 
77
86
  interface InterruptBase {
78
87
  readonly rid: string;
@@ -114,13 +123,31 @@ export interface AddContextInterrupt extends InterruptBase {
114
123
  readonly source?: string;
115
124
  }
116
125
 
126
+ /** v5: sandbox asks the host for the TaskLedger claims table (`list_claims()`). */
127
+ export interface LedgerClaimsInterrupt extends InterruptBase {
128
+ readonly type: "ledger_claims";
129
+ }
130
+
131
+ /** v5: sandbox reaches the durable MemoryStore (`memory.query/add/stats`). */
132
+ export interface MemoryInterrupt extends InterruptBase {
133
+ readonly type: "memory";
134
+ readonly op: "query" | "add" | "stats";
135
+ readonly query?: string;
136
+ readonly k?: number;
137
+ readonly content?: string;
138
+ readonly paths?: readonly string[];
139
+ readonly tags?: readonly string[];
140
+ }
141
+
117
142
  /** A mid-exec sub-LLM/tool request from the worker. */
118
143
  export type WorkerInterrupt =
119
144
  | PromptInterrupt
120
145
  | BatchInterrupt
121
146
  | AwaitInterrupt
122
147
  | FinishInterrupt
123
- | AddContextInterrupt;
148
+ | AddContextInterrupt
149
+ | LedgerClaimsInterrupt
150
+ | MemoryInterrupt;
124
151
 
125
152
  export type WorkerMessage = WorkerResponse | WorkerInterrupt;
126
153
 
@@ -133,6 +160,8 @@ export const INTERRUPT_KINDS = Object.freeze(
133
160
  "await",
134
161
  "finish",
135
162
  "add_context",
163
+ "ledger_claims",
164
+ "memory",
136
165
  ]),
137
166
  );
138
167
 
@@ -158,6 +187,39 @@ export function isWorkerMessage(msg: unknown): msg is WorkerMessage {
158
187
  return isWorkerResponse(msg) || isInterrupt(msg);
159
188
  }
160
189
 
190
+ /** Unsettled Task still in the worker, optionally bound to a REPL variable. */
191
+ export interface PendingTaskInfo {
192
+ readonly var: string | null;
193
+ readonly kind: string;
194
+ readonly label: string;
195
+ }
196
+
197
+ const EMPTY_PENDING: readonly PendingTaskInfo[] = Object.freeze([]);
198
+
199
+ function isPendingTaskInfo(value: unknown): value is PendingTaskInfo {
200
+ if (!isRecord(value)) return false;
201
+ const bound = value["var"];
202
+ return (typeof bound === "string" || bound === null)
203
+ && typeof value["kind"] === "string"
204
+ && typeof value["label"] === "string";
205
+ }
206
+
207
+ /** Narrow a worker `pending_tasks` payload; drop malformed entries. */
208
+ export function parsePendingTasks(value: unknown): readonly PendingTaskInfo[] {
209
+ if (!Array.isArray(value)) return EMPTY_PENDING;
210
+ const out = new Array<PendingTaskInfo>(value.length);
211
+ let n = 0;
212
+ for (let i = 0; i < value.length; i++) {
213
+ const item: unknown = value[i];
214
+ if (isPendingTaskInfo(item)) {
215
+ out[n] = item;
216
+ n += 1;
217
+ }
218
+ }
219
+ out.length = n;
220
+ return n === 0 ? EMPTY_PENDING : Object.freeze(out);
221
+ }
222
+
161
223
  /** Result of a single `repl` block execution, surfaced to the engine/tool. */
162
224
  export interface ReplResult {
163
225
  readonly stdout: string;
@@ -168,4 +230,6 @@ export interface ReplResult {
168
230
  readonly executionTimeMs: number;
169
231
  /** User-created variable names after this exec (builtins/context filtered out). */
170
232
  readonly varNames: readonly string[];
233
+ /** Unsettled Task handles still in the worker after this exec. */
234
+ readonly pendingTasks: readonly PendingTaskInfo[];
171
235
  }
@@ -91,12 +91,12 @@ RESERVED = frozenset(
91
91
  # Canonical api_v5
92
92
  "llm_query", "llm_batch",
93
93
  "rlm_query", "rlm_batch",
94
- "await_task", "finish",
94
+ "await_task", "list_tasks", "finish",
95
95
  "spawn",
96
96
  # Helpers (not the old *_query_batched API)
97
97
  "llm_query_chunked", "map_files", "llm_map_reduce",
98
98
  "search", "grep_context", "outline",
99
- "add_context",
99
+ "add_context", "list_claims", "memory",
100
100
  "SHOW_VARS", "answer", "context",
101
101
  }
102
102
  )
@@ -114,6 +114,14 @@ class _StallTimeout(Exception):
114
114
  """No frame from the host while a sub-call was pending."""
115
115
 
116
116
 
117
+ def _stall_message(stall_timeout_s: float) -> str:
118
+ """Contract text for a stall — raised internally, returned as Error: … to the model."""
119
+ return (
120
+ f"sub-call still running — no reply from the host for {stall_timeout_s:g}s; "
121
+ "await_task it again in a later block"
122
+ )
123
+
124
+
117
125
  @contextmanager
118
126
  def _stall_alarm(exec_timeout_s: float, stall_timeout_s: float):
119
127
  """Swap the per-cell alarm for a stall alarm while blocked on the parent.
@@ -127,10 +135,7 @@ def _stall_alarm(exec_timeout_s: float, stall_timeout_s: float):
127
135
  remaining = signal.getitimer(signal.ITIMER_REAL)[0] if (use and exec_timeout_s > 0) else 0.0
128
136
 
129
137
  def _fire(signum, frame): # noqa: ARG001
130
- raise _StallTimeout(
131
- f"sub-call stalled — no reply from the host for {stall_timeout_s:g}s "
132
- "(the task may still be running; await_task it again in a later block)"
133
- )
138
+ raise _StallTimeout(_stall_message(stall_timeout_s))
134
139
 
135
140
  old = signal.signal(signal.SIGALRM, _fire) if use else None
136
141