@hicaru/pi-rlm 0.3.14 → 0.3.16
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 +100 -54
- package/README.ru.md +69 -0
- package/README.zh-CN.md +66 -0
- package/package.json +1 -1
- package/src/bridge/handlers/emitting.ts +8 -0
- package/src/bridge/handlers/rlm-query.ts +2 -0
- package/src/bridge/model.ts +15 -1
- package/src/config/defaults.ts +3 -0
- package/src/config/settings.ts +6 -2
- package/src/core/answer.ts +5 -2
- package/src/core/budget.ts +22 -0
- package/src/core/engine.ts +91 -16
- package/src/core/types.ts +5 -0
- package/src/prompts/user.ts +25 -0
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/worker.cpython-314.pyc +0 -0
- package/src/sandbox/py/retrieval.py +31 -2
- package/src/sandbox/py/worker.py +30 -4
- package/src/sandbox/sandbox.ts +14 -1
- package/src/text/parsing.ts +28 -7
- package/src/tool/repl-tool.ts +2 -0
- package/src/tool/rlm-aggregator.ts +1 -1
- package/src/tool/rlm-details.ts +4 -1
- package/src/tool/rlm-events.ts +7 -2
- package/src/tool/rlm-tool.ts +3 -1
- package/src/tool/subcall-render.ts +10 -4
- package/src/tool/subcall-store.ts +32 -5
- package/src/ui/config-panel.ts +39 -0
- package/src/ui/panel/run-registry.ts +8 -0
- package/src/ui/theme.ts +6 -0
- package/src/ui/tree/tree-model.ts +19 -1
- package/src/ui/tree/tree-rows.ts +8 -8
- package/src/util/retry.ts +6 -3
package/src/core/engine.ts
CHANGED
|
@@ -20,12 +20,14 @@ import { TaskLedger, contextSig, taskKey } from "./ledger.ts";
|
|
|
20
20
|
import { type MemoryStore, rootContextPaths } from "./memory.ts";
|
|
21
21
|
import { type ChatMsg, modelComplete } from "../bridge/model.ts";
|
|
22
22
|
import { buildRlmSystemPrompt } from "../prompts/system.ts";
|
|
23
|
-
import { buildTurnPrompt, FINALIZE_PROMPT } from "../prompts/user.ts";
|
|
23
|
+
import { buildTurnPrompt, FINALIZE_PROMPT, RETRIEVAL_NUDGE, REASONING_BUDGET_HINT, VERIFICATION_NUDGE } from "../prompts/user.ts";
|
|
24
24
|
import type { RlmEmitter } from "../tool/rlm-events.ts";
|
|
25
25
|
import type { SubcallPhase } from "../tool/rlm-details.ts";
|
|
26
26
|
import { PythonSandbox, SANDBOX_WATCHDOG_HEARTBEAT_MS } from "../sandbox/sandbox.ts";
|
|
27
|
+
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
27
28
|
import { pinContext, type PinnedContext } from "../sandbox/context-file.ts";
|
|
28
29
|
import { previewStdout, previewText } from "../text/preview.ts";
|
|
30
|
+
import { findReplBlocks } from "../text/parsing.ts";
|
|
29
31
|
import { contextLength, contextSizeStats, contextTypeLabel } from "../text/tokens.ts";
|
|
30
32
|
import { finalAnswerOf, formatReplOutputs, latestAnswerContentOf, turnHadError } from "./answer.ts";
|
|
31
33
|
import { compactHistory, elideOldToolPayloads, shouldCompact } from "./compaction.ts";
|
|
@@ -44,6 +46,9 @@ import { createSubcallGates, type SubcallGates } from "../util/concurrency.ts";
|
|
|
44
46
|
* hold a finished run open on work whose result nobody can receive.
|
|
45
47
|
*/
|
|
46
48
|
const DETACHED_SETTLE_MS = 5_000;
|
|
49
|
+
/** Verification nudge (enableVerificationNudge): only an EARLY finalize is suspicious — from
|
|
50
|
+
* turn 4 on, a bare answer is just... an answer. "Before iteration ~4", per the bench data. */
|
|
51
|
+
const VERIFICATION_NUDGE_TURN_CAP = 4;
|
|
47
52
|
/** H6 (audit): root episodes snapshot at most this many real files — replay invalidation for
|
|
48
53
|
* the disk-backed slice of the context without hashing an unbounded repository. */
|
|
49
54
|
const ROOT_HASH_MAX = 64;
|
|
@@ -248,6 +253,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
248
253
|
// v5 budget cascade state: the wrap-up note fires for exactly ONE turn after crossing soft.
|
|
249
254
|
let softFired = false;
|
|
250
255
|
let softNoteTurn = -1;
|
|
256
|
+
// H3: retrieval-discipline coach — one-shot per run; children inherit it via the same loop.
|
|
257
|
+
let sawRetrieval = false;
|
|
258
|
+
let retrievalNudged = false;
|
|
259
|
+
// Verification-discipline coach (enableVerificationNudge, default OFF): one coached redo
|
|
260
|
+
// when an early finalize looks like the confident-wrong bench shape.
|
|
261
|
+
let verificationNudged = false;
|
|
262
|
+
let verificationNudgePending = false;
|
|
251
263
|
|
|
252
264
|
try {
|
|
253
265
|
const meta = {
|
|
@@ -315,6 +327,13 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
315
327
|
liveContext = input.context ?? [];
|
|
316
328
|
contextPin = await pinContext(liveContext);
|
|
317
329
|
await sandbox.loadContextPinned(contextPin);
|
|
330
|
+
|
|
331
|
+
// rootSampling fields win; smartReasoning is the default reasoning when not overridden.
|
|
332
|
+
// Loop-invariant — built once here; finalize() applies the same merge to its own turn.
|
|
333
|
+
const rootSampling: Sampling = {
|
|
334
|
+
reasoning: deps.config.smartReasoning,
|
|
335
|
+
...deps.config.rootSampling,
|
|
336
|
+
};
|
|
318
337
|
for (let i = 0; i < deps.config.maxIterations; i++) {
|
|
319
338
|
limits.checkTimeout();
|
|
320
339
|
if (selfReportId) emitter.emitSubcallUpdated({ id: selfReportId, detail: `turn ${i + 1}/${deps.config.maxIterations}` });
|
|
@@ -355,21 +374,27 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
355
374
|
// v5 [ledger] blackboard + [memory] notes — each silent ("") when it has nothing to say.
|
|
356
375
|
const ledgerBlock = deps.config.enableLedger ? runLedger.injectBlock() : "";
|
|
357
376
|
const memoryBlock = rootMemory !== undefined ? rootMemory.injectBlock(input.rootPrompt) : "";
|
|
377
|
+
// H3: after two retrieval-free turns, inject the coach nudge exactly once, for one turn.
|
|
378
|
+
const nudgeNow = i >= 2 && !sawRetrieval && !retrievalNudged;
|
|
379
|
+
if (nudgeNow) retrievalNudged = true;
|
|
358
380
|
const notes =
|
|
359
381
|
[
|
|
360
382
|
i === softNoteTurn ? WRAP_UP_BUDGET : undefined,
|
|
361
383
|
ledgerBlock === "" ? undefined : ledgerBlock,
|
|
362
384
|
memoryBlock === "" ? undefined : memoryBlock,
|
|
385
|
+
nudgeNow ? RETRIEVAL_NUDGE : undefined,
|
|
386
|
+
verificationNudgePending ? VERIFICATION_NUDGE : undefined,
|
|
387
|
+
// One-shot (turn 0 only): thinking tokens share the completion budget — mirror of
|
|
388
|
+
// the bench's doubling rule. Advisory; never fatal, never repeated.
|
|
389
|
+
i === 0 && rootSampling.reasoning !== undefined && (rootSampling.maxTokens ?? 16_384) < 8_192
|
|
390
|
+
? REASONING_BUDGET_HINT
|
|
391
|
+
: undefined,
|
|
363
392
|
]
|
|
364
393
|
.filter((s): s is string => s !== undefined)
|
|
365
394
|
.join("\n\n") || undefined;
|
|
395
|
+
verificationNudgePending = false;
|
|
366
396
|
appendUserMessage(history, buildTurnPrompt(i, deps.config.maxIterations, notes));
|
|
367
397
|
|
|
368
|
-
// rootSampling fields win; smartReasoning is the default reasoning when not overridden.
|
|
369
|
-
const rootSampling: Sampling = {
|
|
370
|
-
reasoning: deps.config.smartReasoning,
|
|
371
|
-
...deps.config.rootSampling,
|
|
372
|
-
};
|
|
373
398
|
const turn = await runTurn(history, sandbox, {
|
|
374
399
|
model: model,
|
|
375
400
|
registry: deps.registry,
|
|
@@ -379,6 +404,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
379
404
|
complete: deps.complete,
|
|
380
405
|
onPhase: reportPhase,
|
|
381
406
|
});
|
|
407
|
+
if (turn.blocks.some((b) => /\b(?:search|grep_context)\s*\(/.test(b))) sawRetrieval = true;
|
|
382
408
|
const allBlocks = turn.blocks.length > 0
|
|
383
409
|
? turn.blocks.map((b) => previewText(b, 400)).join("\n")
|
|
384
410
|
: previewText(turn.response, 400);
|
|
@@ -386,8 +412,17 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
386
412
|
emitter.emitSubcallUpdated({ id: selfReportId, args: `▶ ${allBlocks}`, resultPreview: previewStdout(turn.results) });
|
|
387
413
|
}
|
|
388
414
|
limits.addUsage(turn.usage);
|
|
389
|
-
if (selfReportId)
|
|
390
|
-
|
|
415
|
+
if (selfReportId) {
|
|
416
|
+
emitter.emitSubcallUpdated({
|
|
417
|
+
id: selfReportId,
|
|
418
|
+
costUsd: turn.usage.cost.total,
|
|
419
|
+
tokens: turn.usage.totalTokens,
|
|
420
|
+
tokensIn: turn.usage.input,
|
|
421
|
+
tokensOut: turn.usage.output,
|
|
422
|
+
});
|
|
423
|
+
} else {
|
|
424
|
+
emitter.emitRootUsage(turn.usage.cost.total, turn.usage.totalTokens, turn.usage.input, turn.usage.output);
|
|
425
|
+
}
|
|
391
426
|
deps.onUsage?.(turn.usage, "root");
|
|
392
427
|
const answerContent = latestAnswerContentOf(turn.results);
|
|
393
428
|
if (answerContent) best = answerContent;
|
|
@@ -395,10 +430,19 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
395
430
|
completedTurns = i + 1;
|
|
396
431
|
const final = finalAnswerOf(turn.results);
|
|
397
432
|
if (final != null) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
433
|
+
// Verification-discipline nudge (enableVerificationNudge, default OFF): an early
|
|
434
|
+
// finalize whose answer is a bare number / short label is the confident-wrong shape
|
|
435
|
+
// that dominated bench failures. ONE coached redo, then the answer is accepted.
|
|
436
|
+
if (deps.config.enableVerificationNudge === true && !verificationNudged
|
|
437
|
+
&& completedTurns < VERIFICATION_NUDGE_TURN_CAP && isBareAnswer(final)) {
|
|
438
|
+
verificationNudged = true;
|
|
439
|
+
verificationNudgePending = true;
|
|
440
|
+
} else {
|
|
441
|
+
const done = result(final, i + 1, limits);
|
|
442
|
+
persistRoot(done.answer);
|
|
443
|
+
lastAnswer = done.answer;
|
|
444
|
+
return done;
|
|
445
|
+
}
|
|
402
446
|
}
|
|
403
447
|
|
|
404
448
|
limits.observe(turnHadError(turn.results));
|
|
@@ -458,7 +502,7 @@ export function createEngine(deps: EngineDeps): RunRlm {
|
|
|
458
502
|
}
|
|
459
503
|
}
|
|
460
504
|
if (pendingReplOutputs) appendUserMessage(history, pendingReplOutputs);
|
|
461
|
-
const finalized = result(await finalize(history, model, deps, limits), deps.config.maxIterations, limits);
|
|
505
|
+
const finalized = result(await finalize(history, model, deps, limits, sandbox), deps.config.maxIterations, limits);
|
|
462
506
|
persistRoot(finalized.answer);
|
|
463
507
|
lastAnswer = finalized.answer;
|
|
464
508
|
return finalized;
|
|
@@ -516,17 +560,48 @@ function contextWindowOrFallback(model: Model<Api>, registry: ModelContextRegist
|
|
|
516
560
|
return registry.limitFor(`${model.provider}/${model.id}`);
|
|
517
561
|
}
|
|
518
562
|
|
|
519
|
-
/**
|
|
520
|
-
|
|
563
|
+
/** Bare number / short label — the early-confident answer shape the verification nudge
|
|
564
|
+
* targets (28/33 bench failures were early confident wrong answers). */
|
|
565
|
+
function isBareAnswer(answer: string): boolean {
|
|
566
|
+
const t = answer.trim();
|
|
567
|
+
return t.length <= 12 || /^[-+$(€£¥]?\d+(?:[.,]\d+)*\s*%?$/.test(t);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Out of turns: ask the model for its best final answer. FINALIZE_PROMPT asks for a fenced
|
|
571
|
+
* ```repl``` block, so execute it like any turn and prefer the captured answer (H2) — a raw
|
|
572
|
+
* fence echoed verbatim must never become the run answer. Plain text stays the fallback. */
|
|
573
|
+
async function finalize(
|
|
574
|
+
history: ChatMsg[],
|
|
575
|
+
model: Model<Api>,
|
|
576
|
+
deps: EngineDeps,
|
|
577
|
+
limits: LimitGuard,
|
|
578
|
+
sandbox: PythonSandbox,
|
|
579
|
+
): Promise<string> {
|
|
521
580
|
const finalHistory = [...history];
|
|
522
581
|
appendUserMessage(finalHistory, FINALIZE_PROMPT);
|
|
523
582
|
const complete = deps.complete ?? modelComplete;
|
|
583
|
+
// Same merge rule as the main loop (the `rootSampling` construction in run()): rootSampling
|
|
584
|
+
// wins, smartReasoning is the reasoning default. Finalize is a root turn — it must obey the
|
|
585
|
+
// user's sampling too, or the last turn of every run silently reverts to provider defaults.
|
|
586
|
+
const rootSampling: Sampling = {
|
|
587
|
+
reasoning: deps.config.smartReasoning,
|
|
588
|
+
...deps.config.rootSampling,
|
|
589
|
+
};
|
|
524
590
|
const { text, usage } = await complete(finalHistory, {
|
|
525
591
|
model,
|
|
526
592
|
registry: deps.registry,
|
|
527
|
-
|
|
593
|
+
maxTokens: rootSampling.maxTokens,
|
|
594
|
+
temperature: rootSampling.temperature,
|
|
595
|
+
reasoning: rootSampling.reasoning,
|
|
528
596
|
signal: deps.signal,
|
|
529
597
|
});
|
|
530
598
|
limits.addUsage(usage);
|
|
599
|
+
const blocks = findReplBlocks(text);
|
|
600
|
+
const results = new Array<ReplResult>(blocks.length);
|
|
601
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
602
|
+
results[i] = await sandbox.exec(blocks[i]);
|
|
603
|
+
}
|
|
604
|
+
const final = finalAnswerOf(results) ?? latestAnswerContentOf(results);
|
|
605
|
+
if (final !== null && final.trim() !== "") return final.trim();
|
|
531
606
|
return text.trim();
|
|
532
607
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -110,6 +110,11 @@ export interface RlmConfig {
|
|
|
110
110
|
/** v5 doctrine: "delegation" = child engines get llm/memory/ledger only (no repo retrieval);
|
|
111
111
|
* "legacy" keeps today's full child surface as a one-flip rollback. */
|
|
112
112
|
readonly childSurface: "delegation" | "legacy";
|
|
113
|
+
/** Verification-discipline nudge (default OFF — it changes interactive behavior): when the
|
|
114
|
+
* root finalizes before turn 4 with a bare number / short label, it gets ONE coached redo
|
|
115
|
+
* ("recompute and sanity-check in Python") instead of accepting the answer. Opt-in via
|
|
116
|
+
* rlm.json; evidence: 28/33 bench failures were early confident wrong answers. */
|
|
117
|
+
readonly enableVerificationNudge?: boolean;
|
|
113
118
|
}
|
|
114
119
|
|
|
115
120
|
/** Input to a (headless) RLM run. */
|
package/src/prompts/user.ts
CHANGED
|
@@ -26,3 +26,28 @@ export const FINALIZE_PROMPT =
|
|
|
26
26
|
"You are out of turns. Finalize NOW: set `answer[\"content\"]` and `answer[\"ready\"] = True` " +
|
|
27
27
|
"(fenced ```repl```) with your best final answer from everything you have gathered. " +
|
|
28
28
|
"Only if the REPL is unavailable, answer as plain text.";
|
|
29
|
+
|
|
30
|
+
/** One-shot retrieval-discipline nudge (the engine owns the when — see core/engine.ts). The
|
|
31
|
+
* context is external by design, so a model that never calls search()/grep_context() is
|
|
32
|
+
* guessing from padding vocabulary; after two retrieval-free turns it gets this once. */
|
|
33
|
+
export const RETRIEVAL_NUDGE =
|
|
34
|
+
"[coach] You have not inspected the external context yet — it is NOT included in this " +
|
|
35
|
+
"chat, and guessing is useless: the text is padding. On THIS turn, call search(\"...\") " +
|
|
36
|
+
"or grep_context(\"...\") inside a ```repl block before answering.";
|
|
37
|
+
|
|
38
|
+
/** One-shot budget hint (engine-owned, turn 0 only): reasoning tokens share the completion
|
|
39
|
+
* budget with the answer, mirroring the bench's doubling rule — a reasoning root with a
|
|
40
|
+
* small output cap risks truncated thought. Advisory only; never fatal. */
|
|
41
|
+
export const REASONING_BUDGET_HINT =
|
|
42
|
+
"[budget] Reasoning is on while the root output cap is below 8192 tokens: thinking shares " +
|
|
43
|
+
"the completion budget with the answer, so long thought may be cut off mid-reasoning. " +
|
|
44
|
+
"Keep thought concise, or raise rootSampling.maxTokens.";
|
|
45
|
+
|
|
46
|
+
/** One-shot verification-discipline nudge (default OFF — enableVerificationNudge): fired when
|
|
47
|
+
* the root finalizes suspiciously early with a bare number / short label. The model gets ONE
|
|
48
|
+
* coached redo instead of having the answer accepted. */
|
|
49
|
+
export const VERIFICATION_NUDGE =
|
|
50
|
+
"[coach] That answer was submitted suspiciously early and looks under-verified. Before " +
|
|
51
|
+
"finalizing: recompute the key quantity inside a ```repl block (show the actual computation, " +
|
|
52
|
+
"not a restatement), sanity-check it against the source material, and only then set " +
|
|
53
|
+
"answer[\"content\"] again.";
|
|
Binary file
|
|
Binary file
|
|
@@ -36,6 +36,30 @@ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
|
|
|
36
36
|
return chunks
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def _snippet_window(text: str, terms: set[str]) -> str:
|
|
40
|
+
"""Slice `text` around the earliest occurrence of any query term, capped at _SNIPPET_CHARS.
|
|
41
|
+
|
|
42
|
+
BM25 finds the right window chunk; the snippet must show the match, not the chunk head.
|
|
43
|
+
Clipped edges get "..." markers, which count toward the cap (the body is trimmed to fit).
|
|
44
|
+
Falls back to the chunk head when no term occurs (tokenize/camelCase mismatches).
|
|
45
|
+
"""
|
|
46
|
+
lowered = text.lower()
|
|
47
|
+
hits = [p for p in (lowered.find(t) for t in terms) if p >= 0]
|
|
48
|
+
if not hits:
|
|
49
|
+
return text[:_SNIPPET_CHARS]
|
|
50
|
+
start = max(0, min(hits) - _SNIPPET_LEAD)
|
|
51
|
+
end = min(len(text), start + _SNIPPET_CHARS)
|
|
52
|
+
lead = "..." if start > 0 else ""
|
|
53
|
+
trail = "..." if end < len(text) else ""
|
|
54
|
+
body = text[start:end]
|
|
55
|
+
if len(body) > _SNIPPET_CHARS - len(lead) - len(trail):
|
|
56
|
+
body = body[:_SNIPPET_CHARS - len(lead) - len(trail)]
|
|
57
|
+
if start + len(body) < len(text):
|
|
58
|
+
trail = "..." # trimming pulled the window edge back inside the chunk
|
|
59
|
+
body = body[:_SNIPPET_CHARS - len(lead) - len(trail)]
|
|
60
|
+
return lead + body + trail
|
|
61
|
+
|
|
62
|
+
|
|
39
63
|
# ---- deterministic retrieval over `context` -----------------------------------------------
|
|
40
64
|
#
|
|
41
65
|
# The RLM paper's trajectories retrieve by having the root model hand-write regex over the
|
|
@@ -48,6 +72,7 @@ def _chunk_text(text: str, chunk_chars: int) -> list[str]:
|
|
|
48
72
|
_INDEX_WINDOW_LINES = 40 # a window is the retrieval unit: big enough to carry meaning
|
|
49
73
|
_INDEX_MAX_WINDOWS = 20_000 # ceiling so a huge add_context() cannot exhaust worker memory
|
|
50
74
|
_SNIPPET_CHARS = 400
|
|
75
|
+
_SNIPPET_LEAD = 100 # chars of lead-in kept before the earliest matched term
|
|
51
76
|
_GREP_HARD_CAP = 200 # absolute ceiling on returned grep hits, whatever k asks for
|
|
52
77
|
_BM25_K1 = 1.2
|
|
53
78
|
_BM25_B = 0.75
|
|
@@ -165,9 +190,10 @@ class _Bm25Index:
|
|
|
165
190
|
return []
|
|
166
191
|
top = heapq.nlargest(k, scores.items(), key=lambda kv: kv[1])
|
|
167
192
|
out: list[dict[str, Any]] = [None] * len(top) # type: ignore[list-item]
|
|
193
|
+
term_set = set(terms)
|
|
168
194
|
for i, (idx, score) in enumerate(top):
|
|
169
195
|
text = self.texts[idx]
|
|
170
|
-
snip = text
|
|
196
|
+
snip = _snippet_window(text, term_set)
|
|
171
197
|
# Both `snippet` and `text` so agents never KeyError mixing search vs grep shapes.
|
|
172
198
|
out[i] = {
|
|
173
199
|
"path": self.paths[idx],
|
|
@@ -209,7 +235,10 @@ def grep_context(
|
|
|
209
235
|
`counts` is complete even when `hits` is capped.
|
|
210
236
|
"""
|
|
211
237
|
try:
|
|
212
|
-
|
|
238
|
+
# MULTILINE: the doc-level gate below must not veto line-anchored patterns (^/$) —
|
|
239
|
+
# without it, `^foo` on a multi-line doc never matches outside position 0 and every
|
|
240
|
+
# line hit is silently filtered out (grep is line-oriented; match that).
|
|
241
|
+
rx = re.compile(pattern, re.MULTILINE)
|
|
213
242
|
except re.error as e:
|
|
214
243
|
return {"hits": [], "counts": {}, "total": 0, "truncated": False, "error": f"bad regex: {e}"}
|
|
215
244
|
try:
|
package/src/sandbox/py/worker.py
CHANGED
|
@@ -22,6 +22,7 @@ awaited in a LATER exec than the one that started it.
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
24
|
import argparse
|
|
25
|
+
import ast
|
|
25
26
|
import io
|
|
26
27
|
import json
|
|
27
28
|
import os
|
|
@@ -61,7 +62,15 @@ class _AnswerDict(dict):
|
|
|
61
62
|
def __setitem__(self, key, value):
|
|
62
63
|
super().__setitem__(key, value)
|
|
63
64
|
if key == "ready" and value:
|
|
64
|
-
self.
|
|
65
|
+
content = self.get("content", "")
|
|
66
|
+
# An empty ready-flip must not capture "": defer it — a later non-blank content
|
|
67
|
+
# assignment while ready stays True (branch below) is the real submission.
|
|
68
|
+
if str(content).strip():
|
|
69
|
+
self._on_ready(content)
|
|
70
|
+
elif key == "content" and self.get("ready"):
|
|
71
|
+
content = str(value)
|
|
72
|
+
if content.strip():
|
|
73
|
+
self._on_ready(content)
|
|
65
74
|
|
|
66
75
|
|
|
67
76
|
|
|
@@ -151,7 +160,7 @@ class Worker(WorkerScaffold):
|
|
|
151
160
|
if isinstance(cur, dict):
|
|
152
161
|
for k, v in cur.items():
|
|
153
162
|
dict.__setitem__(ans, k, v)
|
|
154
|
-
if cur.get("ready") and self._final_answer is None:
|
|
163
|
+
if cur.get("ready") and self._final_answer is None and str(cur.get("content", "")).strip():
|
|
155
164
|
self._final_answer = str(cur.get("content", ""))
|
|
156
165
|
ns["answer"] = ans
|
|
157
166
|
# Single context variable (RLM paper: the context lives in the environment and
|
|
@@ -295,8 +304,25 @@ class Worker(WorkerScaffold):
|
|
|
295
304
|
|
|
296
305
|
def _exec(self, code: str, ns: dict[str, Any]) -> None:
|
|
297
306
|
t = self.exec_timeout_s
|
|
307
|
+
# Jupyter-style auto-echo: plain exec() discards a trailing bare expression's value
|
|
308
|
+
# (models write `search("...")` and never see the hits), so split the block: exec the
|
|
309
|
+
# head, eval the tail, and repr() the value when it is not None (None stays silent).
|
|
310
|
+
tree = ast.parse(code)
|
|
311
|
+
tail = tree.body[-1] if tree.body else None
|
|
312
|
+
tail_is_expr = isinstance(tail, ast.Expr)
|
|
313
|
+
|
|
314
|
+
def _run() -> None:
|
|
315
|
+
if tail_is_expr and tail is not None:
|
|
316
|
+
head = ast.Module(body=tree.body[:-1], type_ignores=[])
|
|
317
|
+
exec(compile(head, "<repl>", "exec"), ns, ns) # noqa: S102
|
|
318
|
+
value = eval(compile(ast.Expression(tail.value), "<repl>", "eval"), ns, ns) # noqa: S102
|
|
319
|
+
if value is not None:
|
|
320
|
+
print(repr(value))
|
|
321
|
+
else:
|
|
322
|
+
exec(compile(tree, "<repl>", "exec"), ns, ns) # noqa: S102
|
|
323
|
+
|
|
298
324
|
if t <= 0 or not hasattr(signal, "SIGALRM"):
|
|
299
|
-
|
|
325
|
+
_run()
|
|
300
326
|
return
|
|
301
327
|
|
|
302
328
|
def _alarm(signum, frame): # noqa: ARG001
|
|
@@ -305,7 +331,7 @@ class Worker(WorkerScaffold):
|
|
|
305
331
|
old = signal.signal(signal.SIGALRM, _alarm)
|
|
306
332
|
signal.setitimer(signal.ITIMER_REAL, t)
|
|
307
333
|
try:
|
|
308
|
-
|
|
334
|
+
_run()
|
|
309
335
|
finally:
|
|
310
336
|
signal.setitimer(signal.ITIMER_REAL, 0)
|
|
311
337
|
signal.signal(signal.SIGALRM, old)
|
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -344,6 +344,8 @@ export class PythonSandbox {
|
|
|
344
344
|
frame: msg.type,
|
|
345
345
|
id: "id" in msg ? msg.id : undefined,
|
|
346
346
|
rid: "rid" in msg ? msg.rid : undefined,
|
|
347
|
+
// Post-mortem needs the payload, not just the shape (mirrors repl-tool's 400-char cap).
|
|
348
|
+
...(msg.type === "exec" ? { chars: msg.code.length, code: msg.code.slice(0, 400) } : {}),
|
|
347
349
|
});
|
|
348
350
|
}
|
|
349
351
|
// Never write to a corpse. The write would fail asynchronously and, historically, take the
|
|
@@ -423,7 +425,18 @@ export class PythonSandbox {
|
|
|
423
425
|
prompts: "prompts" in msg ? msg.prompts?.length : 1,
|
|
424
426
|
});
|
|
425
427
|
} else {
|
|
426
|
-
trace("frame.in", {
|
|
428
|
+
trace("frame.in", {
|
|
429
|
+
frame: "response",
|
|
430
|
+
id: msg.id,
|
|
431
|
+
ok: msg.ok,
|
|
432
|
+
...(msg.ok
|
|
433
|
+
? {
|
|
434
|
+
stdout: msg.stdout?.slice(0, 300),
|
|
435
|
+
finalAnswer: msg.final_answer ?? undefined,
|
|
436
|
+
vars: msg.var_names?.length,
|
|
437
|
+
}
|
|
438
|
+
: { error: msg.error?.slice(0, 200) }),
|
|
439
|
+
});
|
|
427
440
|
}
|
|
428
441
|
}
|
|
429
442
|
if (isInterrupt(msg)) {
|
package/src/text/parsing.ts
CHANGED
|
@@ -2,23 +2,44 @@
|
|
|
2
2
|
* Parsing helpers: extract ```repl``` code blocks from a model response.
|
|
3
3
|
*
|
|
4
4
|
* The RLM root model emits Python wrapped in fenced blocks tagged `repl`. We extract those
|
|
5
|
-
* blocks in order; everything else is prose the model uses to think out loud.
|
|
5
|
+
* blocks in order; everything else is prose the model uses to think out loud. Small instruct
|
|
6
|
+
* models often finalize inside ```python / ```py fences (or bare ones) instead; when a response
|
|
7
|
+
* contains no `repl` block at all, those code-ish fences are used as a fallback so the engine
|
|
8
|
+
* still executes their code. Other language tags (```text, ```json, ...) are never executed —
|
|
9
|
+
* the sandbox only runs Python.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
const FENCE = /(`{3,})[ \t]*repl[ \t]*\r?\n([\s\S]*?)\1/g;
|
|
13
|
+
// The info string is captured so a rejected tag (e.g. ```text) still consumes its whole fence —
|
|
14
|
+
// otherwise that fence's own closing ``` could later match as a bare opener and swallow code.
|
|
15
|
+
const FALLBACK_FENCE = /(`{3,})[ \t]*([^`\r\n]*)[ \t]*\r?\n([\s\S]*?)\1/g;
|
|
16
|
+
const PYTHON_TAG = /^py(thon)?$/i;
|
|
9
17
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
18
|
+
/** Shared fence scan: run `re` over `text`, keep bodies the selector accepts (same trimming). */
|
|
19
|
+
function collectFences(text: string, re: RegExp, select: (m: RegExpExecArray) => string | null): string[] {
|
|
12
20
|
const blocks: string[] = [];
|
|
13
21
|
let m: RegExpExecArray | null;
|
|
14
|
-
|
|
15
|
-
while ((m =
|
|
16
|
-
const code = m
|
|
17
|
-
if (code.trim()) blocks.push(code.replace(/\s+$/, ""));
|
|
22
|
+
re.lastIndex = 0;
|
|
23
|
+
while ((m = re.exec(text)) !== null) {
|
|
24
|
+
const code = select(m);
|
|
25
|
+
if (code !== null && code.trim()) blocks.push(code.replace(/\s+$/, ""));
|
|
18
26
|
}
|
|
19
27
|
return blocks;
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Return every ```repl``` block body, in document order. If the response has none, fall back to
|
|
32
|
+
* ```python / ```py / untagged fences — never other language tags, and never a mix of both kinds.
|
|
33
|
+
*/
|
|
34
|
+
export function findReplBlocks(text: string): string[] {
|
|
35
|
+
const repl = collectFences(text, FENCE, (m) => m[2] ?? "");
|
|
36
|
+
if (repl.length > 0) return repl;
|
|
37
|
+
return collectFences(text, FALLBACK_FENCE, (m) => {
|
|
38
|
+
const tag = m[2] ?? "";
|
|
39
|
+
return tag === "" || PYTHON_TAG.test(tag) ? (m[3] ?? "") : null;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
/** Truncate REPL stdout for the model's context window (head + tail, with an elision note). */
|
|
23
44
|
export function truncateOutput(text: string, limit = 20_000): string {
|
|
24
45
|
if (text.length <= limit) return text;
|
package/src/tool/repl-tool.ts
CHANGED
|
@@ -267,6 +267,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
267
267
|
return modelRef(m) ?? m.id;
|
|
268
268
|
},
|
|
269
269
|
rootTokens: () => store.getRootUsage().tokens,
|
|
270
|
+
rootTokensIn: () => store.getRootUsage().tokensIn,
|
|
271
|
+
rootTokensOut: () => store.getRootUsage().tokensOut,
|
|
270
272
|
});
|
|
271
273
|
let capturedStdout = "";
|
|
272
274
|
let capturedStderr = "";
|
|
@@ -55,7 +55,7 @@ export class RlmEventAggregator extends EmitterListener {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
private handleRootUsage(event: RootUsageEvent): void {
|
|
58
|
-
this.store.addRootUsage(event.costUsd, event.tokens);
|
|
58
|
+
this.store.addRootUsage(event.costUsd, event.tokens, event.tokensIn, event.tokensOut);
|
|
59
59
|
this.notify();
|
|
60
60
|
}
|
|
61
61
|
|
package/src/tool/rlm-details.ts
CHANGED
|
@@ -33,6 +33,9 @@ export interface RlmSubcall {
|
|
|
33
33
|
readonly endedAt?: number;
|
|
34
34
|
readonly costUsd: number;
|
|
35
35
|
readonly tokens: number;
|
|
36
|
+
/** In/out split (input / output) — mirrors tokens. */
|
|
37
|
+
readonly tokensIn: number;
|
|
38
|
+
readonly tokensOut: number;
|
|
36
39
|
/** For batch subcalls: failed prompt count (partial failure). */
|
|
37
40
|
readonly failedCount?: number;
|
|
38
41
|
/** For batch subcalls: total prompt count. */
|
|
@@ -46,7 +49,7 @@ export interface RlmDetails {
|
|
|
46
49
|
readonly rootPrompt: string;
|
|
47
50
|
readonly turns: { readonly current: number; readonly max: number };
|
|
48
51
|
readonly subcalls: readonly RlmSubcall[];
|
|
49
|
-
readonly totals: { readonly costUsd: number; readonly tokens: number };
|
|
52
|
+
readonly totals: { readonly costUsd: number; readonly tokens: number; readonly tokensIn: number; readonly tokensOut: number };
|
|
50
53
|
readonly answer?: string;
|
|
51
54
|
}
|
|
52
55
|
|
package/src/tool/rlm-events.ts
CHANGED
|
@@ -41,6 +41,9 @@ export interface SubcallUpdatedEvent {
|
|
|
41
41
|
readonly costUsd?: number;
|
|
42
42
|
/** Delta — additive on both the subcall and running totals. */
|
|
43
43
|
readonly tokens?: number;
|
|
44
|
+
/** Deltas for the in/out split shown in the tree (input / output). Additive like tokens. */
|
|
45
|
+
readonly tokensIn?: number;
|
|
46
|
+
readonly tokensOut?: number;
|
|
44
47
|
/** For batch subcalls: failed prompt count. */
|
|
45
48
|
readonly failedCount?: number;
|
|
46
49
|
/** For batch subcalls: total prompt count. */
|
|
@@ -55,6 +58,8 @@ export interface TurnEvent {
|
|
|
55
58
|
export interface RootUsageEvent {
|
|
56
59
|
readonly costUsd: number;
|
|
57
60
|
readonly tokens: number;
|
|
61
|
+
readonly tokensIn?: number;
|
|
62
|
+
readonly tokensOut?: number;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
export interface AnswerEvent {
|
|
@@ -114,8 +119,8 @@ export class RlmEmitter {
|
|
|
114
119
|
}
|
|
115
120
|
|
|
116
121
|
/** Accumulate usage directly to root-level totals. */
|
|
117
|
-
emitRootUsage(costUsd: number, tokens: number): void {
|
|
118
|
-
this.ee.emit("root-usage", { costUsd, tokens } satisfies RootUsageEvent);
|
|
122
|
+
emitRootUsage(costUsd: number, tokens: number, tokensIn?: number, tokensOut?: number): void {
|
|
123
|
+
this.ee.emit("root-usage", { costUsd, tokens, tokensIn, tokensOut } satisfies RootUsageEvent);
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
/** Set the final answer text (root-only). */
|
package/src/tool/rlm-tool.ts
CHANGED
|
@@ -53,7 +53,7 @@ export function createRlmTool(controller: RlmController, runRegistry?: RunRegist
|
|
|
53
53
|
rootPrompt: "",
|
|
54
54
|
turns: { current: 0, max: 0 },
|
|
55
55
|
subcalls: [],
|
|
56
|
-
totals: { costUsd: 0, tokens: 0 },
|
|
56
|
+
totals: { costUsd: 0, tokens: 0, tokensIn: 0, tokensOut: 0 },
|
|
57
57
|
}));
|
|
58
58
|
if (!validation.ok) return validation.error;
|
|
59
59
|
const params = validation.value;
|
|
@@ -79,6 +79,8 @@ export function createRlmTool(controller: RlmController, runRegistry?: RunRegist
|
|
|
79
79
|
return m === undefined ? undefined : modelRef(m) ?? m.id;
|
|
80
80
|
},
|
|
81
81
|
rootTokens: () => aggregator.getRootUsage().tokens,
|
|
82
|
+
rootTokensIn: () => aggregator.getRootUsage().tokensIn,
|
|
83
|
+
rootTokensOut: () => aggregator.getRootUsage().tokensOut,
|
|
82
84
|
});
|
|
83
85
|
|
|
84
86
|
// Wire abort signal to controller
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { Text } from "@earendil-works/pi-tui";
|
|
10
10
|
import { keyText } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import type { SubcallStatus } from "./rlm-details.ts";
|
|
12
|
-
import { formatTokens, spinnerFrame } from "../ui/theme.ts";
|
|
12
|
+
import { formatTokens, formatTokensSplit, spinnerFrame } from "../ui/theme.ts";
|
|
13
13
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
|
|
15
15
|
// ── Glyphs ──
|
|
@@ -25,15 +25,21 @@ export function headlineStatusGlyph(status: SubcallStatus | "aborted" | "done",
|
|
|
25
25
|
|
|
26
26
|
// ── Stats formatting ──
|
|
27
27
|
|
|
28
|
-
/** The `4.2k tok · 812ms` run of a card header.
|
|
28
|
+
/** The `4.2k tok · 812ms` run of a card header. In/out split when known; omits zero parts. */
|
|
29
29
|
export function cardStatsLine(
|
|
30
|
-
totals: { readonly tokens: number },
|
|
30
|
+
totals: { readonly tokens: number; readonly tokensIn?: number; readonly tokensOut?: number },
|
|
31
31
|
theme: Theme,
|
|
32
32
|
extra?: string,
|
|
33
33
|
backgroundPending?: number,
|
|
34
34
|
): string {
|
|
35
35
|
const parts: string[] = [];
|
|
36
|
-
if (totals.tokens > 0)
|
|
36
|
+
if (totals.tokens > 0) {
|
|
37
|
+
parts.push(
|
|
38
|
+
totals.tokensOut !== undefined && totals.tokensOut > 0
|
|
39
|
+
? formatTokensSplit(totals.tokensIn ?? totals.tokens, totals.tokensOut)
|
|
40
|
+
: `${formatTokens(totals.tokens)} tok`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
37
43
|
if (extra) parts.push(extra);
|
|
38
44
|
const line = theme.fg("dim", parts.join(" · "));
|
|
39
45
|
// The one thing no single line can show: spawned work that may outlive this block.
|