@hicaru/pi-rlm 0.3.5 → 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.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/bridge/handlers/llm-query.ts +73 -17
- package/src/bridge/handlers/rlm-query.ts +130 -8
- package/src/bridge/handlers/types.ts +12 -0
- package/src/config/defaults.ts +17 -0
- package/src/config/settings.ts +45 -2
- package/src/core/answer.ts +7 -10
- package/src/core/budget.ts +182 -0
- package/src/core/compaction.ts +46 -0
- package/src/core/engine.ts +171 -5
- package/src/core/ledger.ts +451 -0
- package/src/core/memory.ts +577 -0
- package/src/core/model-registry.ts +88 -0
- package/src/core/types.ts +44 -3
- package/src/index.ts +60 -9
- package/src/mode/rlm-mode.ts +47 -9
- package/src/prompts/glossary.ts +139 -57
- package/src/prompts/native.ts +12 -7
- package/src/prompts/system.ts +22 -7
- package/src/prompts/user.ts +6 -3
- package/src/sandbox/interrupts.ts +24 -0
- package/src/sandbox/protocol.ts +69 -5
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +11 -6
- package/src/sandbox/py/scaffold.py +615 -0
- package/src/sandbox/py/worker.py +53 -506
- package/src/sandbox/sandbox.ts +21 -3
- package/src/text/repl-output.ts +15 -0
- package/src/tool/repl-result.ts +54 -10
- package/src/tool/repl-tool.ts +43 -9
- package/src/ui/status.ts +4 -1
- package/src/util/concurrency.ts +47 -0
package/src/sandbox/sandbox.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
|
|
|
14
14
|
import {
|
|
15
15
|
isInterrupt,
|
|
16
16
|
isWorkerMessage,
|
|
17
|
+
parsePendingTasks,
|
|
17
18
|
type ParentMessage,
|
|
18
19
|
type ReplResult,
|
|
19
20
|
type WorkerMessage,
|
|
@@ -29,6 +30,9 @@ export type { AddContextResult, SubcallOpts, SubLlmHandlers } from "./interrupts
|
|
|
29
30
|
export interface SandboxOptions {
|
|
30
31
|
/** Sandbox recursion depth label (passed to the worker, used in interrupt routing). */
|
|
31
32
|
readonly depth?: number;
|
|
33
|
+
/** v5 role separation: "child" sandboxes install the delegation-only scaffold (no
|
|
34
|
+
* search/grep_context/outline/add_context — retrieval belongs to the root). */
|
|
35
|
+
readonly surface?: "root" | "child";
|
|
32
36
|
/** Per-`repl`-block wall-clock timeout inside the worker (seconds). */
|
|
33
37
|
readonly execTimeoutS?: number;
|
|
34
38
|
/** Parent-side watchdog per request (ms); on breach the worker is SIGKILLed. */
|
|
@@ -52,6 +56,8 @@ export interface SandboxOptions {
|
|
|
52
56
|
|
|
53
57
|
const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), "py", "worker.py");
|
|
54
58
|
const STDERR_TAIL_CHARS = 8_192;
|
|
59
|
+
/** How often to refresh the parent request watchdog (and ping the worker) during silent host work. */
|
|
60
|
+
export const SANDBOX_WATCHDOG_HEARTBEAT_MS = 30_000;
|
|
55
61
|
/** How long dispose() waits for a clean worker exit before escalating to SIGKILL. */
|
|
56
62
|
const SHUTDOWN_GRACE_MS = 50;
|
|
57
63
|
|
|
@@ -118,6 +124,7 @@ export class PythonSandbox {
|
|
|
118
124
|
"-X", "utf8=1",
|
|
119
125
|
"-u", WORKER_PATH,
|
|
120
126
|
"--depth", String(opts.depth ?? 1),
|
|
127
|
+
"--surface", opts.surface === "child" ? "child" : "root",
|
|
121
128
|
"--timeout", String(opts.execTimeoutS ?? 600),
|
|
122
129
|
];
|
|
123
130
|
if (opts.maxPromptChars !== undefined) {
|
|
@@ -224,6 +231,7 @@ export class PythonSandbox {
|
|
|
224
231
|
raised: res.raised ?? false,
|
|
225
232
|
executionTimeMs: Math.round((res.execution_time ?? 0) * 1000),
|
|
226
233
|
varNames: res.var_names ?? [],
|
|
234
|
+
pendingTasks: parsePendingTasks(res.pending_tasks),
|
|
227
235
|
};
|
|
228
236
|
}
|
|
229
237
|
|
|
@@ -312,12 +320,22 @@ export class PythonSandbox {
|
|
|
312
320
|
}
|
|
313
321
|
|
|
314
322
|
/**
|
|
315
|
-
* Refresh the parent-side request watchdog
|
|
316
|
-
* Used during long mid-exec work that does not
|
|
317
|
-
*
|
|
323
|
+
* Refresh the parent-side request watchdog and ping the worker.
|
|
324
|
+
* Used during long mid-exec work that does not produce additional worker
|
|
325
|
+
* interrupts on this sandbox. The heartbeat rearms the worker's stall alarm
|
|
326
|
+
* so a healthy long sub-call is not reported as `_StallTimeout`.
|
|
318
327
|
*/
|
|
319
328
|
refreshWatchdog(): void {
|
|
320
329
|
this.touchPending();
|
|
330
|
+
if (this.hasPendingRequest()) this.send({ type: "heartbeat" });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** True when an exec/load_context/shutdown (not the init handshake) is in flight. */
|
|
334
|
+
private hasPendingRequest(): boolean {
|
|
335
|
+
for (const id of this.pending.keys()) {
|
|
336
|
+
if (id !== "_init") return true;
|
|
337
|
+
}
|
|
338
|
+
return false;
|
|
321
339
|
}
|
|
322
340
|
|
|
323
341
|
private send(msg: ParentMessage): void {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared REPL output formatting used by headless history and native repl() tool results.
|
|
3
|
+
* Lives in text/ so tool/ does not import from core/.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { truncateOutput } from "./parsing.ts";
|
|
7
|
+
|
|
8
|
+
/** Max stderr kept in model-visible REPL output (headless history and native tool_result). */
|
|
9
|
+
export const STDERR_LIMIT = 8_000;
|
|
10
|
+
|
|
11
|
+
/** Prefix stderr so the model can tell prints from exceptions. Empty when stderr is blank. */
|
|
12
|
+
export function formatReplStderr(stderr: string, limit = STDERR_LIMIT): string {
|
|
13
|
+
const err = stderr.trim();
|
|
14
|
+
return err ? `\n[stderr]\n${truncateOutput(err, limit)}` : "";
|
|
15
|
+
}
|
package/src/tool/repl-result.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { RlmSubcall } from "./rlm-details.ts";
|
|
8
|
+
import type { PendingTaskInfo } from "../sandbox/protocol.ts";
|
|
8
9
|
import { capReplResultText, replDelegationNudge } from "../mode/native-guards.ts";
|
|
10
|
+
import { formatReplStderr } from "../text/repl-output.ts";
|
|
9
11
|
|
|
10
12
|
/** Model-visible text assembled from a repl() result. */
|
|
11
13
|
export interface ReplResultText {
|
|
@@ -30,23 +32,20 @@ export function buildReplResultText(
|
|
|
30
32
|
subcalls: readonly RlmSubcall[],
|
|
31
33
|
backgroundPending = 0,
|
|
32
34
|
varNames: readonly string[] = [],
|
|
35
|
+
stderr = "",
|
|
36
|
+
raised = false,
|
|
37
|
+
pendingTasks: readonly PendingTaskInfo[] = [],
|
|
33
38
|
): ReplResultText {
|
|
34
39
|
const answerSubmitted = finalAnswer !== undefined;
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
? ` — the block ran fine and these REPL vars are defined: ${varNames.join(", ")}. `
|
|
38
|
-
+ "Do NOT re-run it; read them in the next block."
|
|
39
|
-
: "";
|
|
40
|
-
const rawText = answerSubmitted
|
|
41
|
-
? `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`
|
|
42
|
-
: stdout || `(no output)${varsHint}`;
|
|
40
|
+
const stderrBlock = formatReplStderr(stderr);
|
|
41
|
+
const rawText = assembleReplBody(stdout, finalAnswer, varNames, stderrBlock, raised);
|
|
43
42
|
// Model-visible text is capped; the caller keeps full stdout in `details` for the TUI.
|
|
44
43
|
const cappedText = capReplResultText(rawText) ?? rawText;
|
|
45
44
|
const delegated = subcalls.some((s) => s.kind === "llm" || s.kind === "batch" || s.kind === "rlm");
|
|
46
|
-
const nudge = answerSubmitted ? undefined : replDelegationNudge(rawText.length, delegated);
|
|
45
|
+
const nudge = answerSubmitted || raised ? undefined : replDelegationNudge(rawText.length, delegated);
|
|
47
46
|
const failedBg = subcalls.filter((s) => s.id.startsWith("bg") && s.status === "error").length;
|
|
48
47
|
const pendingLine = backgroundPending > 0
|
|
49
|
-
? `\n\n[rlm] ${backgroundPending} background task(s) still running —
|
|
48
|
+
? `\n\n[rlm] ${backgroundPending} background task(s) still running — ${pendingCollectHint(pendingTasks)}.`
|
|
50
49
|
: "";
|
|
51
50
|
const failedLine = failedBg > 0
|
|
52
51
|
? `\n[rlm] ${failedBg} background sub-call(s) FAILED — their await_task value is an "Error: …" string, not data.`
|
|
@@ -54,6 +53,51 @@ export function buildReplResultText(
|
|
|
54
53
|
return { text: cappedText + (nudge ?? "") + pendingLine + failedLine };
|
|
55
54
|
}
|
|
56
55
|
|
|
56
|
+
function assembleReplBody(
|
|
57
|
+
stdout: string,
|
|
58
|
+
finalAnswer: string | undefined,
|
|
59
|
+
varNames: readonly string[],
|
|
60
|
+
stderrBlock: string,
|
|
61
|
+
raised: boolean,
|
|
62
|
+
): string {
|
|
63
|
+
if (finalAnswer !== undefined) {
|
|
64
|
+
return `ANSWER_SUBMITTED (${finalAnswer.length} chars) — delivered to user. Do not restate it.`;
|
|
65
|
+
}
|
|
66
|
+
if (raised) {
|
|
67
|
+
const body = [stdout.trimEnd(), stderrBlock.trimStart()].filter((s) => s.length > 0).join("\n");
|
|
68
|
+
return body.length > 0 ? body : "(raised — see [stderr])";
|
|
69
|
+
}
|
|
70
|
+
const noOutput = !stdout;
|
|
71
|
+
const varsHint = noOutput && varNames.length > 0
|
|
72
|
+
? ` — the block ran fine and these REPL vars are defined: ${varNames.join(", ")}. `
|
|
73
|
+
+ "Do NOT re-run it; read them in the next block."
|
|
74
|
+
: "";
|
|
75
|
+
return stdout || `(no output)${varsHint}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Name the Python handle when we know it; otherwise point at await_task() / list_tasks(). */
|
|
79
|
+
function pendingCollectHint(pendingTasks: readonly PendingTaskInfo[]): string {
|
|
80
|
+
if (pendingTasks.length === 0) {
|
|
81
|
+
return "collect with await_task(<Task var>) or await_task(); answers[] is for results AFTER await_task";
|
|
82
|
+
}
|
|
83
|
+
const names = new Array<string>(pendingTasks.length);
|
|
84
|
+
for (let i = 0; i < pendingTasks.length; i++) {
|
|
85
|
+
const t = pendingTasks[i];
|
|
86
|
+
const kind = t?.kind ?? "task";
|
|
87
|
+
const label = t?.label ?? "";
|
|
88
|
+
names[i] = t?.var ?? `<${kind}${label.length > 0 ? ` ${label}` : ""}>`;
|
|
89
|
+
}
|
|
90
|
+
if (pendingTasks.length === 1) {
|
|
91
|
+
const only = names[0] ?? "";
|
|
92
|
+
const first = pendingTasks[0];
|
|
93
|
+
const detail = first !== undefined && first.label.length > 0 ? ` (${first.kind} ${first.label})` : "";
|
|
94
|
+
return only.startsWith("<")
|
|
95
|
+
? `collect with await_task()${detail}; answers[] is for results AFTER await_task`
|
|
96
|
+
: `collect with await_task(${only})${detail}; answers[] is for results AFTER await_task`;
|
|
97
|
+
}
|
|
98
|
+
return `collect with await_task() (${names.join(", ")}); answers[] is for results AFTER await_task`;
|
|
99
|
+
}
|
|
100
|
+
|
|
57
101
|
/** Advisory diagnostics derived from a completed invocation's sub-calls. */
|
|
58
102
|
export function collectReplWarnings(subcalls: readonly RlmSubcall[]): readonly string[] | undefined {
|
|
59
103
|
let failed = 0;
|
package/src/tool/repl-tool.ts
CHANGED
|
@@ -28,6 +28,8 @@ import type { RlmConfig, RlmInput, RlmResult } from "../core/types.ts";
|
|
|
28
28
|
import { SandboxManager } from "../sandbox/sandbox-manager.ts";
|
|
29
29
|
import type { SubcallOpts } from "../sandbox/sandbox.ts";
|
|
30
30
|
import { createSubcallHandlers, type Invocation } from "../bridge/handlers/index.ts";
|
|
31
|
+
import { TaskLedger } from "../core/ledger.ts";
|
|
32
|
+
import type { MemoryStore } from "../core/memory.ts";
|
|
31
33
|
import { BackgroundTasks } from "./background-tasks.ts";
|
|
32
34
|
import type { ReplResult } from "../sandbox/protocol.ts";
|
|
33
35
|
import { RlmEmitter } from "./rlm-events.ts";
|
|
@@ -110,8 +112,13 @@ export interface ReplToolDeps {
|
|
|
110
112
|
readonly getConfig: () => RlmConfig;
|
|
111
113
|
/** Session-wide sub-call admission, shared with every child engine this tool spawns. */
|
|
112
114
|
readonly gates: SubcallGates;
|
|
115
|
+
/** v5: live re-resolution of the session gates (provider caps change via /rlm-config without
|
|
116
|
+
* a restart). Falls back to `gates` when omitted. Read lazily per sub-call. */
|
|
117
|
+
readonly resolveGates?: () => SubcallGates;
|
|
113
118
|
/** Session-scoped home for detached spawn() work. */
|
|
114
119
|
readonly background: BackgroundTasks;
|
|
120
|
+
/** v5 durable memory (session-wide `.rlm` store); omitted → memory off for this tool. */
|
|
121
|
+
readonly memory?: MemoryStore;
|
|
115
122
|
readonly signal?: AbortSignal;
|
|
116
123
|
readonly onUsage?: (usage: Usage, role: "sub") => void;
|
|
117
124
|
readonly ensureContext?: () => Promise<void>;
|
|
@@ -127,12 +134,17 @@ export interface ReplToolDeps {
|
|
|
127
134
|
export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplToolParams, ReplDetails> {
|
|
128
135
|
const { sandboxManager, llmModel, registry, getConfig, signal, onUsage, background } = deps;
|
|
129
136
|
const bridgeState = new NativeBridgeState(background);
|
|
137
|
+
// v5: one session-wide blackboard for the native repl() path — the same claim/coalesce/
|
|
138
|
+
// demote logic the engine gets per run, shared by every turn and every child it spawns.
|
|
139
|
+
const sessionLedger = new TaskLedger();
|
|
130
140
|
|
|
131
141
|
// Late-bound cwd — getOrCreate installs handlers only at spawn; never rebuild the closure.
|
|
132
142
|
let sessionCwd = process.cwd();
|
|
133
143
|
|
|
134
144
|
const getLlmModel = (): Model<Api> => deps.getLlmModel?.() ?? llmModel;
|
|
135
145
|
const getModel = (): Model<Api> => deps.getModel?.() ?? deps.model;
|
|
146
|
+
// v5 (audit C6): resolve lazily per call so provider-cap edits via /rlm-config apply live.
|
|
147
|
+
const currentGates = (): SubcallGates => deps.resolveGates?.() ?? deps.gates;
|
|
136
148
|
|
|
137
149
|
// Each rlm_query spawns a child RLM with its own sandbox and turn loop, not a flat
|
|
138
150
|
// one-shot llm_query. The engine is created per call so the child's subcalls, turn
|
|
@@ -143,7 +155,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
143
155
|
registry,
|
|
144
156
|
config: getConfig(),
|
|
145
157
|
signal,
|
|
146
|
-
|
|
158
|
+
memory: deps.memory,
|
|
159
|
+
gates: currentGates(),
|
|
147
160
|
// Same emitter the parent subcall node lives on — see SubcallHandlerDeps.runChild.
|
|
148
161
|
emitter: inv.emitter,
|
|
149
162
|
// Everything a child engine spends is sub-work from this tool's perspective, including
|
|
@@ -156,7 +169,10 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
156
169
|
// per-invocation is reached through bridgeState.resolve, not captured here.
|
|
157
170
|
const subcallHandlers = createSubcallHandlers({
|
|
158
171
|
resolve: (opts) => bridgeState.resolve(opts),
|
|
159
|
-
|
|
172
|
+
// Getter, not a captured value: sub-call deps read gates lazily per call.
|
|
173
|
+
get gates(): SubcallGates {
|
|
174
|
+
return currentGates();
|
|
175
|
+
},
|
|
160
176
|
registry,
|
|
161
177
|
getLlmModel,
|
|
162
178
|
getModel,
|
|
@@ -168,6 +184,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
168
184
|
// earlier repl() reaches a child spawned in a later one. Populated before any interrupt can
|
|
169
185
|
// fire: execute() awaits ensureContext() before getOrCreate().
|
|
170
186
|
getChildContext: () => sandboxManager.contextPayload ?? undefined,
|
|
187
|
+
ledger: sessionLedger,
|
|
188
|
+
memory: deps.memory,
|
|
171
189
|
trackDetached: (task) => background.track(task),
|
|
172
190
|
});
|
|
173
191
|
|
|
@@ -213,7 +231,7 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
213
231
|
"repl: free search/outline; fire rlm_batch|map_files|llm_batch as Task (BG); await_task for results.",
|
|
214
232
|
promptGuidelines: [
|
|
215
233
|
"Multi-area analysis: rlm_batch([...]) or map_files(paths, q); free search; await_task — not serial native read.",
|
|
216
|
-
"Always-spawn tools return Task; only await_task has content. Fire-all
|
|
234
|
+
"Always-spawn tools return Task; only await_task has content. Fire-all then await; await_task() collects every still-running Task.",
|
|
217
235
|
"llm_query/llm_batch have no disk — never 'Read path/to/file.ts'; use map_files or rlm_* (see context).",
|
|
218
236
|
],
|
|
219
237
|
parameters: ReplToolParams,
|
|
@@ -283,6 +301,8 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
283
301
|
await sandboxManager.getOrCreate({
|
|
284
302
|
...subcallHandlers,
|
|
285
303
|
...(contextBundle?.handlers ?? {}),
|
|
304
|
+
ledgerClaims: () => Promise.resolve(sessionLedger.listClaims()),
|
|
305
|
+
memoryOp: (op, args) => Promise.resolve(deps.memory?.serviceOp(op, args) ?? "memory off"),
|
|
286
306
|
});
|
|
287
307
|
|
|
288
308
|
// Detect queue contention AFTER sandbox init (initPromise settled, isExecuting now accurate)
|
|
@@ -299,12 +319,23 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
299
319
|
}
|
|
300
320
|
|
|
301
321
|
const start = Date.now();
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
322
|
+
// v5 blackboard (audit C3 / R1): ancestors are the rlm_query/rlm_batch task
|
|
323
|
+
// strings inside this cell, not the Python soup (`print`, `await_task`). A
|
|
324
|
+
// child restating the user's goal then echoes. Popped in finally: detached
|
|
325
|
+
// work spawned by this cell already claimed at spawn time, inside this window.
|
|
326
|
+
const ledgerActive = getConfig().enableLedger;
|
|
327
|
+
const ancestorN = ledgerActive ? sessionLedger.beginNativeCell(params.code) : 0;
|
|
328
|
+
let result: ReplResult;
|
|
329
|
+
try {
|
|
330
|
+
result = await sandboxManager.execWithSetup(params.code, () => {
|
|
331
|
+
// Wire per-invocation mutable state only after the serialized exec slot
|
|
332
|
+
// is active. Swapping earlier would let queued repl() calls overwrite
|
|
333
|
+
// emitter/limits for the currently running REPL execution.
|
|
334
|
+
bridgeState.swap({ emitter, parentId: undefined, depth: 0, limits });
|
|
335
|
+
}, execSignal);
|
|
336
|
+
} finally {
|
|
337
|
+
if (ledgerActive) sessionLedger.endNativeCell(ancestorN);
|
|
338
|
+
}
|
|
308
339
|
const elapsed = Date.now() - start;
|
|
309
340
|
capturedStdout = result.stdout;
|
|
310
341
|
capturedStderr = result.stderr;
|
|
@@ -349,6 +380,9 @@ export function createReplTool(deps: ReplToolDeps): ToolDefinition<typeof ReplTo
|
|
|
349
380
|
subcalls,
|
|
350
381
|
background.pending,
|
|
351
382
|
result.varNames,
|
|
383
|
+
result.stderr,
|
|
384
|
+
result.raised,
|
|
385
|
+
result.pendingTasks,
|
|
352
386
|
);
|
|
353
387
|
|
|
354
388
|
const details: ReplDetails = {
|
package/src/ui/status.ts
CHANGED
|
@@ -17,7 +17,10 @@ export function formatRlmStateLine(controller: RlmController, contextUsage?: Con
|
|
|
17
17
|
// `percent` is null right after a compaction, before the next assistant response reports usage.
|
|
18
18
|
const percent = contextUsage?.percent;
|
|
19
19
|
const ctxSuffix = percent === null || percent === undefined ? "" : ` · ctx ${Math.round(percent)}%`;
|
|
20
|
-
|
|
20
|
+
// v5 provider caps (set via rlm.json): keep the effective admission visible.
|
|
21
|
+
const caps = controller.config.providerMaxConcurrent;
|
|
22
|
+
const capsSuffix = caps === undefined ? "" : ` · caps ${Object.entries(caps).map(([p, n]) => `${p}=${n}`).join(",")}`;
|
|
23
|
+
return `● RLM ON · llm=${llm}${llmSuffix}${ctxSuffix}${capsSuffix}`;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export function setRlmModeStatus(ui: ExtensionUIContext, controller: RlmController, contextUsage?: ContextUsage): void {
|
package/src/util/concurrency.ts
CHANGED
|
@@ -91,3 +91,50 @@ export interface SubcallGates {
|
|
|
91
91
|
export function createSubcallGates(leafLimit: number, childLimit: number = leafLimit): SubcallGates {
|
|
92
92
|
return Object.freeze({ leaf: new Semaphore(leafLimit), rlm: new DepthGates(childLimit) });
|
|
93
93
|
}
|
|
94
|
+
|
|
95
|
+
/** Provider-capped config slice (RlmConfig satisfies this structurally). */
|
|
96
|
+
export interface ProviderCapConfig {
|
|
97
|
+
readonly maxConcurrentSubcalls: number;
|
|
98
|
+
readonly maxConcurrentChildren: number;
|
|
99
|
+
/** v5: per-provider concurrent-request caps, e.g. { zai: 4 }. Caps only ever LOWER a limit. */
|
|
100
|
+
readonly providerMaxConcurrent?: Readonly<Record<string, number>>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** min(maxConcurrentSubcalls, every involved provider's cap) — unknown providers are ignored. */
|
|
104
|
+
export function effectiveSubcallLimit(
|
|
105
|
+
config: ProviderCapConfig,
|
|
106
|
+
providersInUse: readonly string[],
|
|
107
|
+
): number {
|
|
108
|
+
let limit = config.maxConcurrentSubcalls;
|
|
109
|
+
for (const p of providersInUse) {
|
|
110
|
+
const cap = config.providerMaxConcurrent?.[p];
|
|
111
|
+
if (cap !== undefined && cap > 0) limit = Math.min(limit, cap);
|
|
112
|
+
}
|
|
113
|
+
return limit;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Child engines are the heavy unit — cap them against their own model's provider. */
|
|
117
|
+
export function effectiveChildLimit(
|
|
118
|
+
config: ProviderCapConfig,
|
|
119
|
+
childModelProvider: string,
|
|
120
|
+
): number {
|
|
121
|
+
const cap = config.providerMaxConcurrent?.[childModelProvider];
|
|
122
|
+
return cap !== undefined && cap > 0
|
|
123
|
+
? Math.min(config.maxConcurrentChildren, cap)
|
|
124
|
+
: config.maxConcurrentChildren;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** v5 session gates, capping the RIGHT model per gate (audit C6): leaf completions run on the
|
|
128
|
+
* WORKER model, recursive child engines run on the SMART model — a shared-pool fold of both
|
|
129
|
+
* providers would clamp the wrong side (zai smart + openai worker must cap children, not leaves).
|
|
130
|
+
* Pure + testable; the composition roots memoize it keyed on (config, providers). */
|
|
131
|
+
export function buildSessionGates(
|
|
132
|
+
config: ProviderCapConfig,
|
|
133
|
+
smartProvider: string,
|
|
134
|
+
workerProvider: string,
|
|
135
|
+
): SubcallGates {
|
|
136
|
+
return createSubcallGates(
|
|
137
|
+
effectiveSubcallLimit(config, [workerProvider]),
|
|
138
|
+
effectiveChildLimit(config, smartProvider),
|
|
139
|
+
);
|
|
140
|
+
}
|