@draht/rlm 2026.7.12-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -0
- package/dist/cost-comparison.d.ts +81 -0
- package/dist/cost-comparison.d.ts.map +1 -0
- package/dist/cost-comparison.js +93 -0
- package/dist/cost-comparison.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/loaders.d.ts +44 -0
- package/dist/loaders.d.ts.map +1 -0
- package/dist/loaders.js +156 -0
- package/dist/loaders.js.map +1 -0
- package/dist/prompts.d.ts +49 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +71 -0
- package/dist/prompts.js.map +1 -0
- package/dist/router-session.d.ts +50 -0
- package/dist/router-session.d.ts.map +1 -0
- package/dist/router-session.js +251 -0
- package/dist/router-session.js.map +1 -0
- package/dist/sandbox.d.ts +95 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +273 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/session.d.ts +162 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +803 -0
- package/dist/session.js.map +1 -0
- package/dist/trajectory.d.ts +71 -0
- package/dist/trajectory.d.ts.map +1 -0
- package/dist/trajectory.js +65 -0
- package/dist/trajectory.js.map +1 -0
- package/dist/types.d.ts +140 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +35 -0
- package/python/repl_driver.py +744 -0
- package/sandbox/macos.sb +89 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt tier selection and templating for `@draht/rlm`'s root loop.
|
|
3
|
+
*
|
|
4
|
+
* Three tuned system-prompt templates (`packages/rlm/prompts/*.md`) mirror
|
|
5
|
+
* the technique's documented mechanics (root LM writes Python that peeks,
|
|
6
|
+
* chunks, and searches `context`, calls `llm_query` for recursive sub-calls,
|
|
7
|
+
* terminates via `FINAL`/`FINAL_VAR` — see R26-RLM.4-7) at three levels of
|
|
8
|
+
* verbosity, auto-selected from the resolved root model's context window per
|
|
9
|
+
* R27-SLM.1/.4. See .planning/phases/27-sub-llm-integration/27-01-PLAN.md,
|
|
10
|
+
* Architecture sections 3-4.
|
|
11
|
+
*/
|
|
12
|
+
export type PromptTier = "frontier" | "coder-mid" | "small-context";
|
|
13
|
+
/** Substitution variables for {@link renderPrompt}. */
|
|
14
|
+
export interface PromptVars {
|
|
15
|
+
/** What kind of content `context` holds (e.g. "text", "code", "logs", "json"). */
|
|
16
|
+
contextType: string;
|
|
17
|
+
/** Total character length of the full `context` string. */
|
|
18
|
+
contextTotalLength: number;
|
|
19
|
+
/** Suggested chunk boundary sizes (characters), in order. */
|
|
20
|
+
chunkLengths: number[];
|
|
21
|
+
/** Max number of `llm_query` sub-calls allowed this session. */
|
|
22
|
+
maxSubCallBudget: number;
|
|
23
|
+
/** Max characters per `llm_query` sub-call prompt. */
|
|
24
|
+
subCallCharBudget: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Picks a prompt tier from the resolved root model's `contextWindow`
|
|
28
|
+
* (per @draht/ai's `Model.contextWindow`).
|
|
29
|
+
*
|
|
30
|
+
* Thresholds (chosen to track real-world context-window tiers rather than
|
|
31
|
+
* arbitrary round numbers):
|
|
32
|
+
* - `>= 500_000`: "frontier" — models with genuinely huge (500k-2M token)
|
|
33
|
+
* windows (e.g. 1M-context Claude/Gemini variants) that can afford a
|
|
34
|
+
* terser prompt because they have ample headroom even after the
|
|
35
|
+
* constant-size history metadata (R26-RLM.7) accumulates over many
|
|
36
|
+
* steps.
|
|
37
|
+
* - `>= 128_000`: "coder-mid" — 128k-500k is the most common modern
|
|
38
|
+
* context ceiling (GPT-4-class, Claude default, DeepSeek-V3, etc.);
|
|
39
|
+
* plenty of room for a step-by-step prompt but not so much that
|
|
40
|
+
* verbosity is free.
|
|
41
|
+
* - below `128_000`: "small-context" — constrained-window models need the
|
|
42
|
+
* most explicit scaffolding and the smallest suggested chunk/peek sizes,
|
|
43
|
+
* since the prompt itself is a proportionally larger share of their
|
|
44
|
+
* budget.
|
|
45
|
+
*/
|
|
46
|
+
export declare function selectTier(contextWindow: number): PromptTier;
|
|
47
|
+
/** Simple `{{token}}` substitution -- no templating engine dependency needed. */
|
|
48
|
+
export declare function renderPrompt(tier: PromptTier, vars: PromptVars): string;
|
|
49
|
+
//# sourceMappingURL=prompts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prompts.d.ts","sourceRoot":"","sources":["../src/prompts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AASH,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,WAAW,GAAG,eAAe,CAAC;AAEpE,uDAAuD;AACvD,MAAM,WAAW,UAAU;IAC1B,kFAAkF;IAClF,WAAW,EAAE,MAAM,CAAC;IACpB,2DAA2D;IAC3D,kBAAkB,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,gEAAgE;IAChE,gBAAgB,EAAE,MAAM,CAAC;IACzB,sDAAsD;IACtD,iBAAiB,EAAE,MAAM,CAAC;CAC1B;AAqBD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,UAAU,CAAC,aAAa,EAAE,MAAM,GAAG,UAAU,CAI5D;AAED,iFAAiF;AACjF,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CAQvE"}
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt tier selection and templating for `@draht/rlm`'s root loop.
|
|
3
|
+
*
|
|
4
|
+
* Three tuned system-prompt templates (`packages/rlm/prompts/*.md`) mirror
|
|
5
|
+
* the technique's documented mechanics (root LM writes Python that peeks,
|
|
6
|
+
* chunks, and searches `context`, calls `llm_query` for recursive sub-calls,
|
|
7
|
+
* terminates via `FINAL`/`FINAL_VAR` — see R26-RLM.4-7) at three levels of
|
|
8
|
+
* verbosity, auto-selected from the resolved root model's context window per
|
|
9
|
+
* R27-SLM.1/.4. See .planning/phases/27-sub-llm-integration/27-01-PLAN.md,
|
|
10
|
+
* Architecture sections 3-4.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const PROMPTS_DIR = join(__dirname, "..", "prompts");
|
|
17
|
+
const TIER_FILENAMES = {
|
|
18
|
+
frontier: "frontier.md",
|
|
19
|
+
"coder-mid": "coder-mid.md",
|
|
20
|
+
"small-context": "small-context.md",
|
|
21
|
+
};
|
|
22
|
+
// Templates are static files on disk; read once per tier and cache rather
|
|
23
|
+
// than re-reading on every renderPrompt() call.
|
|
24
|
+
const templateCache = new Map();
|
|
25
|
+
function loadTemplate(tier) {
|
|
26
|
+
const cached = templateCache.get(tier);
|
|
27
|
+
if (cached !== undefined)
|
|
28
|
+
return cached;
|
|
29
|
+
const path = join(PROMPTS_DIR, TIER_FILENAMES[tier]);
|
|
30
|
+
const template = readFileSync(path, "utf8");
|
|
31
|
+
templateCache.set(tier, template);
|
|
32
|
+
return template;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Picks a prompt tier from the resolved root model's `contextWindow`
|
|
36
|
+
* (per @draht/ai's `Model.contextWindow`).
|
|
37
|
+
*
|
|
38
|
+
* Thresholds (chosen to track real-world context-window tiers rather than
|
|
39
|
+
* arbitrary round numbers):
|
|
40
|
+
* - `>= 500_000`: "frontier" — models with genuinely huge (500k-2M token)
|
|
41
|
+
* windows (e.g. 1M-context Claude/Gemini variants) that can afford a
|
|
42
|
+
* terser prompt because they have ample headroom even after the
|
|
43
|
+
* constant-size history metadata (R26-RLM.7) accumulates over many
|
|
44
|
+
* steps.
|
|
45
|
+
* - `>= 128_000`: "coder-mid" — 128k-500k is the most common modern
|
|
46
|
+
* context ceiling (GPT-4-class, Claude default, DeepSeek-V3, etc.);
|
|
47
|
+
* plenty of room for a step-by-step prompt but not so much that
|
|
48
|
+
* verbosity is free.
|
|
49
|
+
* - below `128_000`: "small-context" — constrained-window models need the
|
|
50
|
+
* most explicit scaffolding and the smallest suggested chunk/peek sizes,
|
|
51
|
+
* since the prompt itself is a proportionally larger share of their
|
|
52
|
+
* budget.
|
|
53
|
+
*/
|
|
54
|
+
export function selectTier(contextWindow) {
|
|
55
|
+
if (contextWindow >= 500_000)
|
|
56
|
+
return "frontier";
|
|
57
|
+
if (contextWindow >= 128_000)
|
|
58
|
+
return "coder-mid";
|
|
59
|
+
return "small-context";
|
|
60
|
+
}
|
|
61
|
+
/** Simple `{{token}}` substitution -- no templating engine dependency needed. */
|
|
62
|
+
export function renderPrompt(tier, vars) {
|
|
63
|
+
const template = loadTemplate(tier);
|
|
64
|
+
return template
|
|
65
|
+
.replaceAll("{{context_type}}", vars.contextType)
|
|
66
|
+
.replaceAll("{{context_total_length}}", String(vars.contextTotalLength))
|
|
67
|
+
.replaceAll("{{chunk_lengths}}", JSON.stringify(vars.chunkLengths))
|
|
68
|
+
.replaceAll("{{max_sub_call_budget}}", String(vars.maxSubCallBudget))
|
|
69
|
+
.replaceAll("{{sub_call_char_budget}}", String(vars.subCallCharBudget));
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=prompts.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prompts.js","sourceRoot":"","sources":["../src/prompts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAC1D,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC;AAkBrD,MAAM,cAAc,GAA+B;IAClD,QAAQ,EAAE,aAAa;IACvB,WAAW,EAAE,cAAc;IAC3B,eAAe,EAAE,kBAAkB;CACnC,CAAC;AAEF,0EAA0E;AAC1E,gDAAgD;AAChD,MAAM,aAAa,GAAG,IAAI,GAAG,EAAsB,CAAC;AAEpD,SAAS,YAAY,CAAC,IAAgB;IACrC,MAAM,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACvC,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC;IACxC,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,EAAE,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC;IACrD,MAAM,QAAQ,GAAG,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC5C,aAAa,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAClC,OAAO,QAAQ,CAAC;AACjB,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,UAAU,CAAC,aAAqB;IAC/C,IAAI,aAAa,IAAI,OAAO;QAAE,OAAO,UAAU,CAAC;IAChD,IAAI,aAAa,IAAI,OAAO;QAAE,OAAO,WAAW,CAAC;IACjD,OAAO,eAAe,CAAC;AACxB,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,YAAY,CAAC,IAAgB,EAAE,IAAgB;IAC9D,MAAM,QAAQ,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC;IACpC,OAAO,QAAQ;SACb,UAAU,CAAC,kBAAkB,EAAE,IAAI,CAAC,WAAW,CAAC;SAChD,UAAU,CAAC,0BAA0B,EAAE,MAAM,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAC;SACvE,UAAU,CAAC,mBAAmB,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;SAClE,UAAU,CAAC,yBAAyB,EAAE,MAAM,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC;SACpE,UAAU,CAAC,0BAA0B,EAAE,MAAM,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC,CAAC;AAC1E,CAAC","sourcesContent":["/**\n * Prompt tier selection and templating for `@draht/rlm`'s root loop.\n *\n * Three tuned system-prompt templates (`packages/rlm/prompts/*.md`) mirror\n * the technique's documented mechanics (root LM writes Python that peeks,\n * chunks, and searches `context`, calls `llm_query` for recursive sub-calls,\n * terminates via `FINAL`/`FINAL_VAR` — see R26-RLM.4-7) at three levels of\n * verbosity, auto-selected from the resolved root model's context window per\n * R27-SLM.1/.4. See .planning/phases/27-sub-llm-integration/27-01-PLAN.md,\n * Architecture sections 3-4.\n */\n\nimport { readFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst PROMPTS_DIR = join(__dirname, \"..\", \"prompts\");\n\nexport type PromptTier = \"frontier\" | \"coder-mid\" | \"small-context\";\n\n/** Substitution variables for {@link renderPrompt}. */\nexport interface PromptVars {\n\t/** What kind of content `context` holds (e.g. \"text\", \"code\", \"logs\", \"json\"). */\n\tcontextType: string;\n\t/** Total character length of the full `context` string. */\n\tcontextTotalLength: number;\n\t/** Suggested chunk boundary sizes (characters), in order. */\n\tchunkLengths: number[];\n\t/** Max number of `llm_query` sub-calls allowed this session. */\n\tmaxSubCallBudget: number;\n\t/** Max characters per `llm_query` sub-call prompt. */\n\tsubCallCharBudget: number;\n}\n\nconst TIER_FILENAMES: Record<PromptTier, string> = {\n\tfrontier: \"frontier.md\",\n\t\"coder-mid\": \"coder-mid.md\",\n\t\"small-context\": \"small-context.md\",\n};\n\n// Templates are static files on disk; read once per tier and cache rather\n// than re-reading on every renderPrompt() call.\nconst templateCache = new Map<PromptTier, string>();\n\nfunction loadTemplate(tier: PromptTier): string {\n\tconst cached = templateCache.get(tier);\n\tif (cached !== undefined) return cached;\n\tconst path = join(PROMPTS_DIR, TIER_FILENAMES[tier]);\n\tconst template = readFileSync(path, \"utf8\");\n\ttemplateCache.set(tier, template);\n\treturn template;\n}\n\n/**\n * Picks a prompt tier from the resolved root model's `contextWindow`\n * (per @draht/ai's `Model.contextWindow`).\n *\n * Thresholds (chosen to track real-world context-window tiers rather than\n * arbitrary round numbers):\n * - `>= 500_000`: \"frontier\" — models with genuinely huge (500k-2M token)\n * windows (e.g. 1M-context Claude/Gemini variants) that can afford a\n * terser prompt because they have ample headroom even after the\n * constant-size history metadata (R26-RLM.7) accumulates over many\n * steps.\n * - `>= 128_000`: \"coder-mid\" — 128k-500k is the most common modern\n * context ceiling (GPT-4-class, Claude default, DeepSeek-V3, etc.);\n * plenty of room for a step-by-step prompt but not so much that\n * verbosity is free.\n * - below `128_000`: \"small-context\" — constrained-window models need the\n * most explicit scaffolding and the smallest suggested chunk/peek sizes,\n * since the prompt itself is a proportionally larger share of their\n * budget.\n */\nexport function selectTier(contextWindow: number): PromptTier {\n\tif (contextWindow >= 500_000) return \"frontier\";\n\tif (contextWindow >= 128_000) return \"coder-mid\";\n\treturn \"small-context\";\n}\n\n/** Simple `{{token}}` substitution -- no templating engine dependency needed. */\nexport function renderPrompt(tier: PromptTier, vars: PromptVars): string {\n\tconst template = loadTemplate(tier);\n\treturn template\n\t\t.replaceAll(\"{{context_type}}\", vars.contextType)\n\t\t.replaceAll(\"{{context_total_length}}\", String(vars.contextTotalLength))\n\t\t.replaceAll(\"{{chunk_lengths}}\", JSON.stringify(vars.chunkLengths))\n\t\t.replaceAll(\"{{max_sub_call_budget}}\", String(vars.maxSubCallBudget))\n\t\t.replaceAll(\"{{sub_call_char_budget}}\", String(vars.subCallCharBudget));\n}\n"]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router-backed `RlmSession` factory.
|
|
3
|
+
*
|
|
4
|
+
* `RlmSession` (session.ts) takes `rootLlm`/`llmQuery` as injected callbacks
|
|
5
|
+
* -- this module is the production wiring that builds those callbacks on top
|
|
6
|
+
* of `@draht/router`'s `ModelRouter`, selects the right system-prompt tier
|
|
7
|
+
* from the resolved `rlm-root` model's context window (prompts.ts), and logs
|
|
8
|
+
* a cost entry (via `@draht/router`'s `estimateCost`/`logCost`) for every
|
|
9
|
+
* root and sub call, tagged with a single `trajectoryId` shared across the
|
|
10
|
+
* whole session. See .planning/phases/27-sub-llm-integration/27-01-PLAN.md,
|
|
11
|
+
* Architecture section 5.
|
|
12
|
+
*/
|
|
13
|
+
import type { ModelRouter } from "@draht/router";
|
|
14
|
+
import { RlmSession } from "./session.js";
|
|
15
|
+
export interface CreateRouterBackedSessionOptions {
|
|
16
|
+
/** Becomes the `context` variable inside the REPL (see `RlmSessionOptions.prompt`). */
|
|
17
|
+
prompt: string;
|
|
18
|
+
/**
|
|
19
|
+
* Router used to resolve models and stream completions for both the
|
|
20
|
+
* "rlm-root" and "rlm-sub" roles. Caller-supplied so production code
|
|
21
|
+
* passes a real `ModelRouter` and tests can inject a fake one shaped
|
|
22
|
+
* like its public API, with no real network calls.
|
|
23
|
+
*/
|
|
24
|
+
router: ModelRouter;
|
|
25
|
+
/** What kind of content `prompt` holds (e.g. "text", "code", "logs", "json"). Default "text". */
|
|
26
|
+
contextType?: string;
|
|
27
|
+
/** Max number of `llm_query` sub-calls advertised to the root LM this session. Default 50. */
|
|
28
|
+
maxSubCallBudget?: number;
|
|
29
|
+
/** Max characters per `llm_query` sub-call prompt advertised to the root LM. Default 12_000. */
|
|
30
|
+
subCallCharBudget?: number;
|
|
31
|
+
/** Passthrough to `logCost`'s `logPath` param; defaults to `.draht/cost-log.jsonl`. */
|
|
32
|
+
costLogPath?: string;
|
|
33
|
+
/** `CostEntry.sessionId` for every cost entry this session logs. Defaults to the generated `trajectoryId`. */
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Directory the trajectory JSONL log (`.draht/rlm/<trajectoryId>.jsonl`)
|
|
37
|
+
* is written under (Phase 30, `trajectory.ts`). Passed straight through to
|
|
38
|
+
* `appendTrajectoryEntry`'s `logDir` param; defaults to `.draht/rlm/`.
|
|
39
|
+
*/
|
|
40
|
+
trajectoryLogDir?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Builds a real `@draht/router`-backed `RlmSession`: resolves the "rlm-root"
|
|
44
|
+
* model to pick a prompt tier, renders that tier's system prompt with the
|
|
45
|
+
* real prompt's length/chunk info, and wires `rootLlm`/`llmQuery` to call
|
|
46
|
+
* `router.streamSimple("rlm-root" | "rlm-sub", ...)`, logging a cost entry
|
|
47
|
+
* (tagged with one `trajectoryId` per session) after every call.
|
|
48
|
+
*/
|
|
49
|
+
export declare function createRouterBackedSession(opts: CreateRouterBackedSessionOptions): RlmSession;
|
|
50
|
+
//# sourceMappingURL=router-session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router-session.d.ts","sourceRoot":"","sources":["../src/router-session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAIjD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAO1C,MAAM,WAAW,gCAAgC;IAChD,uFAAuF;IACvF,MAAM,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,MAAM,EAAE,WAAW,CAAC;IACpB,iGAAiG;IACjG,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8FAA8F;IAC9F,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,gGAAgG;IAChG,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,uFAAuF;IACvF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8GAA8G;IAC9G,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B;AAoID;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,gCAAgC,GAAG,UAAU,CA2I5F"}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router-backed `RlmSession` factory.
|
|
3
|
+
*
|
|
4
|
+
* `RlmSession` (session.ts) takes `rootLlm`/`llmQuery` as injected callbacks
|
|
5
|
+
* -- this module is the production wiring that builds those callbacks on top
|
|
6
|
+
* of `@draht/router`'s `ModelRouter`, selects the right system-prompt tier
|
|
7
|
+
* from the resolved `rlm-root` model's context window (prompts.ts), and logs
|
|
8
|
+
* a cost entry (via `@draht/router`'s `estimateCost`/`logCost`) for every
|
|
9
|
+
* root and sub call, tagged with a single `trajectoryId` shared across the
|
|
10
|
+
* whole session. See .planning/phases/27-sub-llm-integration/27-01-PLAN.md,
|
|
11
|
+
* Architecture section 5.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { estimateCost, logCost } from "@draht/router";
|
|
15
|
+
import { renderPrompt, selectTier } from "./prompts.js";
|
|
16
|
+
import { RlmSession } from "./session.js";
|
|
17
|
+
import { appendTrajectoryEntry } from "./trajectory.js";
|
|
18
|
+
const DEFAULT_CONTEXT_TYPE = "text";
|
|
19
|
+
const DEFAULT_MAX_SUB_CALL_BUDGET = 50;
|
|
20
|
+
const DEFAULT_SUB_CALL_CHAR_BUDGET = 12_000;
|
|
21
|
+
/**
|
|
22
|
+
* Fallback context window used only when the "rlm-root" role's primary
|
|
23
|
+
* model can't be resolved against the `@draht/ai` registry (should not
|
|
24
|
+
* happen with a correctly configured router, but `selectTier` still needs
|
|
25
|
+
* *some* number). 128_000 lands squarely in "coder-mid" -- the safest
|
|
26
|
+
* middle ground between over-terse (frontier) and over-verbose
|
|
27
|
+
* (small-context) prompting when we genuinely don't know the model.
|
|
28
|
+
*/
|
|
29
|
+
const FALLBACK_ROOT_CONTEXT_WINDOW = 128_000;
|
|
30
|
+
/**
|
|
31
|
+
* Splits `totalLength` into near-equal chunks no larger than
|
|
32
|
+
* `chunkCharBudget`, used only to populate the `chunk_lengths` prompt
|
|
33
|
+
* variable with concrete, addable suggestions (not enforced anywhere).
|
|
34
|
+
*/
|
|
35
|
+
function computeChunkLengths(totalLength, chunkCharBudget) {
|
|
36
|
+
if (totalLength <= 0 || chunkCharBudget <= 0)
|
|
37
|
+
return [];
|
|
38
|
+
const numChunks = Math.max(1, Math.ceil(totalLength / chunkCharBudget));
|
|
39
|
+
const base = Math.floor(totalLength / numChunks);
|
|
40
|
+
const remainder = totalLength % numChunks;
|
|
41
|
+
const lengths = [];
|
|
42
|
+
for (let i = 0; i < numChunks; i++) {
|
|
43
|
+
lengths.push(base + (i < remainder ? 1 : 0));
|
|
44
|
+
}
|
|
45
|
+
return lengths;
|
|
46
|
+
}
|
|
47
|
+
/** Renders the single user turn sent to the root LM: prior history + what's expected next. */
|
|
48
|
+
function buildRootUserTurn(contextTotalLength, contextType, history) {
|
|
49
|
+
const header = `A Python REPL is running. The variable \`context\` already holds ${contextTotalLength} ` +
|
|
50
|
+
`characters of ${contextType} content -- follow the system instructions for how to inspect, ` +
|
|
51
|
+
"chunk, and search it, and call FINAL(...)/FINAL_VAR(...) once you have the answer.";
|
|
52
|
+
if (history.length === 0) {
|
|
53
|
+
return `${header}\n\nWrite your first \`\`\`repl fenced code block now.`;
|
|
54
|
+
}
|
|
55
|
+
const steps = history.map((entry) => {
|
|
56
|
+
const lines = [
|
|
57
|
+
`--- step ${entry.step} ---`,
|
|
58
|
+
"```repl",
|
|
59
|
+
entry.code,
|
|
60
|
+
"```",
|
|
61
|
+
`stdout: ${entry.truncatedStdout || "(empty)"}`,
|
|
62
|
+
];
|
|
63
|
+
if (entry.error)
|
|
64
|
+
lines.push(`error: ${entry.error}`);
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
});
|
|
67
|
+
return [
|
|
68
|
+
header,
|
|
69
|
+
"",
|
|
70
|
+
"History so far:",
|
|
71
|
+
"",
|
|
72
|
+
...steps,
|
|
73
|
+
"",
|
|
74
|
+
"Continue with the next ```repl fenced code block, or call FINAL/FINAL_VAR if you already have the answer.",
|
|
75
|
+
].join("\n");
|
|
76
|
+
}
|
|
77
|
+
/** Drains a `streamSimple`-shaped event stream down to its terminal `done`/`error` message. */
|
|
78
|
+
async function collectAssistantMessage(stream) {
|
|
79
|
+
let final;
|
|
80
|
+
for await (const event of stream) {
|
|
81
|
+
if (event.type === "done") {
|
|
82
|
+
final = event.message;
|
|
83
|
+
}
|
|
84
|
+
else if (event.type === "error") {
|
|
85
|
+
final = event.error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!final) {
|
|
89
|
+
throw new Error("createRouterBackedSession: router stream ended without a done/error event");
|
|
90
|
+
}
|
|
91
|
+
return final;
|
|
92
|
+
}
|
|
93
|
+
/** Concatenates every `text` content block of an assistant message (ignores thinking/tool-call blocks). */
|
|
94
|
+
function messageText(message) {
|
|
95
|
+
let text = "";
|
|
96
|
+
for (const block of message.content) {
|
|
97
|
+
if (block.type === "text")
|
|
98
|
+
text += block.text;
|
|
99
|
+
}
|
|
100
|
+
return text;
|
|
101
|
+
}
|
|
102
|
+
/** Computes and appends one `CostEntry` for a completed router call, returning its `estimatedCostUsd`. */
|
|
103
|
+
function logRouterCall(role, message, trajectoryId, sessionId, costLogPath) {
|
|
104
|
+
const reasoningTokens = message.usage.reasoning ?? 0;
|
|
105
|
+
const estimatedCostUsd = estimateCost(message.provider, message.model, message.usage.input, message.usage.output, reasoningTokens);
|
|
106
|
+
logCost({
|
|
107
|
+
timestamp: new Date().toISOString(),
|
|
108
|
+
role,
|
|
109
|
+
provider: message.provider,
|
|
110
|
+
model: message.model,
|
|
111
|
+
inputTokens: message.usage.input,
|
|
112
|
+
outputTokens: message.usage.output,
|
|
113
|
+
...(reasoningTokens > 0 && { reasoningTokens }),
|
|
114
|
+
estimatedCostUsd,
|
|
115
|
+
sessionId,
|
|
116
|
+
trajectoryId,
|
|
117
|
+
}, costLogPath);
|
|
118
|
+
return estimatedCostUsd;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Builds a real `@draht/router`-backed `RlmSession`: resolves the "rlm-root"
|
|
122
|
+
* model to pick a prompt tier, renders that tier's system prompt with the
|
|
123
|
+
* real prompt's length/chunk info, and wires `rootLlm`/`llmQuery` to call
|
|
124
|
+
* `router.streamSimple("rlm-root" | "rlm-sub", ...)`, logging a cost entry
|
|
125
|
+
* (tagged with one `trajectoryId` per session) after every call.
|
|
126
|
+
*/
|
|
127
|
+
export function createRouterBackedSession(opts) {
|
|
128
|
+
const trajectoryId = randomUUID();
|
|
129
|
+
const sessionId = opts.sessionId ?? trajectoryId;
|
|
130
|
+
const contextType = opts.contextType ?? DEFAULT_CONTEXT_TYPE;
|
|
131
|
+
const maxSubCallBudget = opts.maxSubCallBudget ?? DEFAULT_MAX_SUB_CALL_BUDGET;
|
|
132
|
+
const subCallCharBudget = opts.subCallCharBudget ?? DEFAULT_SUB_CALL_CHAR_BUDGET;
|
|
133
|
+
const costLogPath = opts.costLogPath;
|
|
134
|
+
const trajectoryLogDir = opts.trajectoryLogDir;
|
|
135
|
+
// Trajectory JSONL logging (Phase 30, trajectory.ts): `rootLlm` is called
|
|
136
|
+
// once per step, always *before* that step's own `llm_query` sub-calls
|
|
137
|
+
// happen (they occur later, inside the driver's `runExec`, which
|
|
138
|
+
// `session.ts` only invokes after `rootLlm` resolves -- see session.ts's
|
|
139
|
+
// `step()`). So a step's sub-calls always land between one `rootLlm` call
|
|
140
|
+
// and the next, and the simplest correct way to correlate them without
|
|
141
|
+
// touching session.ts's core loop is: accumulate sub-call costs into
|
|
142
|
+
// `pendingStep` as they happen, and finalize+append the *previous* step's
|
|
143
|
+
// `TrajectoryStepEntry` the moment the *next* `rootLlm` call (or the final
|
|
144
|
+
// `run()` resolution, for the very last step) tells us -- via the
|
|
145
|
+
// `history` array it's handed -- that the previous step actually
|
|
146
|
+
// completed.
|
|
147
|
+
let pendingStep = null;
|
|
148
|
+
let finalizedThroughStep = 0;
|
|
149
|
+
let totalCostUsd = 0;
|
|
150
|
+
function finalizeStepIfPending(history) {
|
|
151
|
+
if (!pendingStep)
|
|
152
|
+
return;
|
|
153
|
+
const entry = history[history.length - 1];
|
|
154
|
+
// No new completed entry since the last finalize (e.g. the step that
|
|
155
|
+
// started this accumulator never finished -- a hard-killed/errored
|
|
156
|
+
// step never gets pushed to `history`): nothing to log, and don't
|
|
157
|
+
// double-log the previous step either.
|
|
158
|
+
if (!entry || entry.step <= finalizedThroughStep)
|
|
159
|
+
return;
|
|
160
|
+
const stepCostUsd = pendingStep.rootCostUsd + pendingStep.subCalls.reduce((sum, sub) => sum + sub.costUsd, 0);
|
|
161
|
+
totalCostUsd += stepCostUsd;
|
|
162
|
+
appendTrajectoryEntry(trajectoryId, {
|
|
163
|
+
type: "step",
|
|
164
|
+
trajectoryId,
|
|
165
|
+
step: entry.step,
|
|
166
|
+
code: entry.code,
|
|
167
|
+
truncatedStdout: entry.truncatedStdout,
|
|
168
|
+
error: entry.error,
|
|
169
|
+
subCalls: pendingStep.subCalls,
|
|
170
|
+
costUsd: stepCostUsd,
|
|
171
|
+
timestamp: entry.timestamp,
|
|
172
|
+
}, trajectoryLogDir);
|
|
173
|
+
finalizedThroughStep = entry.step;
|
|
174
|
+
pendingStep = null;
|
|
175
|
+
}
|
|
176
|
+
const rootRef = opts.router.resolve("rlm-root");
|
|
177
|
+
const rootModel = opts.router.resolveModel(rootRef);
|
|
178
|
+
const contextWindow = rootModel?.contextWindow ?? FALLBACK_ROOT_CONTEXT_WINDOW;
|
|
179
|
+
const tier = selectTier(contextWindow);
|
|
180
|
+
const contextTotalLength = opts.prompt.length;
|
|
181
|
+
const chunkLengths = computeChunkLengths(contextTotalLength, subCallCharBudget);
|
|
182
|
+
const vars = {
|
|
183
|
+
contextType,
|
|
184
|
+
contextTotalLength,
|
|
185
|
+
chunkLengths,
|
|
186
|
+
maxSubCallBudget,
|
|
187
|
+
subCallCharBudget,
|
|
188
|
+
};
|
|
189
|
+
const systemPrompt = renderPrompt(tier, vars);
|
|
190
|
+
const rootLlm = async (history) => {
|
|
191
|
+
// `history` reflects every step completed *before* this one -- so if a
|
|
192
|
+
// previous step's accumulator is still pending, this call is exactly
|
|
193
|
+
// the signal that it (and only it) has now fully completed (its own
|
|
194
|
+
// root call plus every sub-call it triggered).
|
|
195
|
+
finalizeStepIfPending(history);
|
|
196
|
+
pendingStep = { rootCostUsd: 0, subCalls: [] };
|
|
197
|
+
const context = {
|
|
198
|
+
systemPrompt,
|
|
199
|
+
messages: [
|
|
200
|
+
{
|
|
201
|
+
role: "user",
|
|
202
|
+
content: buildRootUserTurn(contextTotalLength, contextType, history),
|
|
203
|
+
timestamp: Date.now(),
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
};
|
|
207
|
+
const message = await collectAssistantMessage(opts.router.streamSimple("rlm-root", context));
|
|
208
|
+
const costUsd = logRouterCall("rlm-root", message, trajectoryId, sessionId, costLogPath);
|
|
209
|
+
pendingStep.rootCostUsd = costUsd;
|
|
210
|
+
return messageText(message);
|
|
211
|
+
};
|
|
212
|
+
const llmQuery = async (prompt) => {
|
|
213
|
+
const context = {
|
|
214
|
+
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
|
|
215
|
+
};
|
|
216
|
+
const message = await collectAssistantMessage(opts.router.streamSimple("rlm-sub", context));
|
|
217
|
+
const costUsd = logRouterCall("rlm-sub", message, trajectoryId, sessionId, costLogPath);
|
|
218
|
+
// A sub-call always happens after `rootLlm` resolved for the step that
|
|
219
|
+
// triggered it (and before the next `rootLlm` call) -- see session.ts's
|
|
220
|
+
// `step()`/`runExec` -- so `pendingStep` is always the right step to
|
|
221
|
+
// attribute this cost to. `pendingStep` is only ever null before the
|
|
222
|
+
// very first `rootLlm` call, at which point no `llm_query` could have
|
|
223
|
+
// been dispatched yet either.
|
|
224
|
+
pendingStep?.subCalls.push({ costUsd, provider: message.provider, model: message.model });
|
|
225
|
+
return messageText(message);
|
|
226
|
+
};
|
|
227
|
+
const session = new RlmSession({ prompt: opts.prompt, rootLlm, llmQuery });
|
|
228
|
+
// Wrap (not replace the class's core loop -- session.ts is untouched)
|
|
229
|
+
// `run()` so the trajectory's last step and its terminal
|
|
230
|
+
// `TrajectoryFinalEntry` get logged once the whole session resolves.
|
|
231
|
+
// There's no other hook to observe "the session is done" from outside
|
|
232
|
+
// session.ts: `rootLlm` is never called again after the last step, so
|
|
233
|
+
// nothing else would ever finalize that step's accumulator.
|
|
234
|
+
const originalRun = session.run.bind(session);
|
|
235
|
+
session.run = async () => {
|
|
236
|
+
const result = await originalRun();
|
|
237
|
+
finalizeStepIfPending(result.history);
|
|
238
|
+
appendTrajectoryEntry(trajectoryId, {
|
|
239
|
+
type: "final",
|
|
240
|
+
trajectoryId,
|
|
241
|
+
kind: result.kind,
|
|
242
|
+
value: result.value,
|
|
243
|
+
totalCostUsd,
|
|
244
|
+
totalSteps: result.steps,
|
|
245
|
+
timestamp: new Date().toISOString(),
|
|
246
|
+
}, trajectoryLogDir);
|
|
247
|
+
return result;
|
|
248
|
+
};
|
|
249
|
+
return session;
|
|
250
|
+
}
|
|
251
|
+
//# sourceMappingURL=router-session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router-session.js","sourceRoot":"","sources":["../src/router-session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAGzC,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAEtD,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AACxD,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAkCxD,MAAM,oBAAoB,GAAG,MAAM,CAAC;AACpC,MAAM,2BAA2B,GAAG,EAAE,CAAC;AACvC,MAAM,4BAA4B,GAAG,MAAM,CAAC;AAE5C;;;;;;;GAOG;AACH,MAAM,4BAA4B,GAAG,OAAO,CAAC;AAE7C;;;;GAIG;AACH,SAAS,mBAAmB,CAAC,WAAmB,EAAE,eAAuB;IACxE,IAAI,WAAW,IAAI,CAAC,IAAI,eAAe,IAAI,CAAC;QAAE,OAAO,EAAE,CAAC;IACxD,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,WAAW,GAAG,eAAe,CAAC,CAAC,CAAC;IACxE,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,GAAG,SAAS,CAAC,CAAC;IACjD,MAAM,SAAS,GAAG,WAAW,GAAG,SAAS,CAAC;IAC1C,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,SAAS,EAAE,CAAC,EAAE,EAAE,CAAC;QACpC,OAAO,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9C,CAAC;IACD,OAAO,OAAO,CAAC;AAChB,CAAC;AAED,8FAA8F;AAC9F,SAAS,iBAAiB,CAAC,kBAA0B,EAAE,WAAmB,EAAE,OAA0B;IACrG,MAAM,MAAM,GACX,oEAAoE,kBAAkB,GAAG;QACzF,iBAAiB,WAAW,iEAAiE;QAC7F,oFAAoF,CAAC;IAEtF,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC1B,OAAO,GAAG,MAAM,wDAAwD,CAAC;IAC1E,CAAC;IAED,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE;QACnC,MAAM,KAAK,GAAG;YACb,YAAY,KAAK,CAAC,IAAI,MAAM;YAC5B,SAAS;YACT,KAAK,CAAC,IAAI;YACV,KAAK;YACL,WAAW,KAAK,CAAC,eAAe,IAAI,SAAS,EAAE;SAC/C,CAAC;QACF,IAAI,KAAK,CAAC,KAAK;YAAE,KAAK,CAAC,IAAI,CAAC,UAAU,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC;QACrD,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACzB,CAAC,CAAC,CAAC;IAEH,OAAO;QACN,MAAM;QACN,EAAE;QACF,iBAAiB;QACjB,EAAE;QACF,GAAG,KAAK;QACR,EAAE;QACF,2GAA2G;KAC3G,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACd,CAAC;AAED,+FAA+F;AAC/F,KAAK,UAAU,uBAAuB,CAAC,MAA4C;IAClF,IAAI,KAAmC,CAAC;IACxC,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAClC,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;YAC3B,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC;QACvB,CAAC;aAAM,IAAI,KAAK,CAAC,IAAI,KAAK,OAAO,EAAE,CAAC;YACnC,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC;QACrB,CAAC;IACF,CAAC;IACD,IAAI,CAAC,KAAK,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CAAC,2EAA2E,CAAC,CAAC;IAC9F,CAAC;IACD,OAAO,KAAK,CAAC;AACd,CAAC;AAED,2GAA2G;AAC3G,SAAS,WAAW,CAAC,OAAyB;IAC7C,IAAI,IAAI,GAAG,EAAE,CAAC;IACd,KAAK,MAAM,KAAK,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;QACrC,IAAI,KAAK,CAAC,IAAI,KAAK,MAAM;YAAE,IAAI,IAAI,KAAK,CAAC,IAAI,CAAC;IAC/C,CAAC;IACD,OAAO,IAAI,CAAC;AACb,CAAC;AAED,0GAA0G;AAC1G,SAAS,aAAa,CACrB,IAAa,EACb,OAAyB,EACzB,YAAoB,EACpB,SAAiB,EACjB,WAA+B;IAE/B,MAAM,eAAe,GAAG,OAAO,CAAC,KAAK,CAAC,SAAS,IAAI,CAAC,CAAC;IACrD,MAAM,gBAAgB,GAAG,YAAY,CACpC,OAAO,CAAC,QAAQ,EAChB,OAAO,CAAC,KAAK,EACb,OAAO,CAAC,KAAK,CAAC,KAAK,EACnB,OAAO,CAAC,KAAK,CAAC,MAAM,EACpB,eAAe,CACf,CAAC;IACF,OAAO,CACN;QACC,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;QACnC,IAAI;QACJ,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,KAAK,EAAE,OAAO,CAAC,KAAK;QACpB,WAAW,EAAE,OAAO,CAAC,KAAK,CAAC,KAAK;QAChC,YAAY,EAAE,OAAO,CAAC,KAAK,CAAC,MAAM;QAClC,GAAG,CAAC,eAAe,GAAG,CAAC,IAAI,EAAE,eAAe,EAAE,CAAC;QAC/C,gBAAgB;QAChB,SAAS;QACT,YAAY;KACZ,EACD,WAAW,CACX,CAAC;IACF,OAAO,gBAAgB,CAAC;AACzB,CAAC;AAQD;;;;;;GAMG;AACH,MAAM,UAAU,yBAAyB,CAAC,IAAsC;IAC/E,MAAM,YAAY,GAAG,UAAU,EAAE,CAAC;IAClC,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,YAAY,CAAC;IACjD,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,IAAI,oBAAoB,CAAC;IAC7D,MAAM,gBAAgB,GAAG,IAAI,CAAC,gBAAgB,IAAI,2BAA2B,CAAC;IAC9E,MAAM,iBAAiB,GAAG,IAAI,CAAC,iBAAiB,IAAI,4BAA4B,CAAC;IACjF,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,CAAC;IACrC,MAAM,gBAAgB,GAAG,IAAI,CAAC,gBAAgB,CAAC;IAE/C,0EAA0E;IAC1E,uEAAuE;IACvE,iEAAiE;IACjE,yEAAyE;IACzE,0EAA0E;IAC1E,uEAAuE;IACvE,qEAAqE;IACrE,0EAA0E;IAC1E,2EAA2E;IAC3E,kEAAkE;IAClE,iEAAiE;IACjE,aAAa;IACb,IAAI,WAAW,GAA+B,IAAI,CAAC;IACnD,IAAI,oBAAoB,GAAG,CAAC,CAAC;IAC7B,IAAI,YAAY,GAAG,CAAC,CAAC;IAErB,SAAS,qBAAqB,CAAC,OAA0B;QACxD,IAAI,CAAC,WAAW;YAAE,OAAO;QACzB,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QAC1C,qEAAqE;QACrE,mEAAmE;QACnE,kEAAkE;QAClE,uCAAuC;QACvC,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,IAAI,IAAI,oBAAoB;YAAE,OAAO;QACzD,MAAM,WAAW,GAAG,WAAW,CAAC,WAAW,GAAG,WAAW,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,GAAG,GAAG,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QAC9G,YAAY,IAAI,WAAW,CAAC;QAC5B,qBAAqB,CACpB,YAAY,EACZ;YACC,IAAI,EAAE,MAAM;YACZ,YAAY;YACZ,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,eAAe,EAAE,KAAK,CAAC,eAAe;YACtC,KAAK,EAAE,KAAK,CAAC,KAAK;YAClB,QAAQ,EAAE,WAAW,CAAC,QAAQ;YAC9B,OAAO,EAAE,WAAW;YACpB,SAAS,EAAE,KAAK,CAAC,SAAS;SAC1B,EACD,gBAAgB,CAChB,CAAC;QACF,oBAAoB,GAAG,KAAK,CAAC,IAAI,CAAC;QAClC,WAAW,GAAG,IAAI,CAAC;IACpB,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;IAChD,MAAM,SAAS,GAAG,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC;IACpD,MAAM,aAAa,GAAG,SAAS,EAAE,aAAa,IAAI,4BAA4B,CAAC;IAE/E,MAAM,IAAI,GAAG,UAAU,CAAC,aAAa,CAAC,CAAC;IACvC,MAAM,kBAAkB,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC;IAC9C,MAAM,YAAY,GAAG,mBAAmB,CAAC,kBAAkB,EAAE,iBAAiB,CAAC,CAAC;IAEhF,MAAM,IAAI,GAAe;QACxB,WAAW;QACX,kBAAkB;QAClB,YAAY;QACZ,gBAAgB;QAChB,iBAAiB;KACjB,CAAC;IACF,MAAM,YAAY,GAAG,YAAY,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAE9C,MAAM,OAAO,GAAG,KAAK,EAAE,OAA0B,EAAmB,EAAE;QACrE,uEAAuE;QACvE,qEAAqE;QACrE,oEAAoE;QACpE,+CAA+C;QAC/C,qBAAqB,CAAC,OAAO,CAAC,CAAC;QAC/B,WAAW,GAAG,EAAE,WAAW,EAAE,CAAC,EAAE,QAAQ,EAAE,EAAE,EAAE,CAAC;QAC/C,MAAM,OAAO,GAAY;YACxB,YAAY;YACZ,QAAQ,EAAE;gBACT;oBACC,IAAI,EAAE,MAAM;oBACZ,OAAO,EAAE,iBAAiB,CAAC,kBAAkB,EAAE,WAAW,EAAE,OAAO,CAAC;oBACpE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE;iBACrB;aACD;SACD,CAAC;QACF,MAAM,OAAO,GAAG,MAAM,uBAAuB,CAAC,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,CAAC;QAC7F,MAAM,OAAO,GAAG,aAAa,CAAC,UAAU,EAAE,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,WAAW,CAAC,CAAC;QACzF,WAAW,CAAC,WAAW,GAAG,OAAO,CAAC;QAClC,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC;IAC7B,CAAC,CAAC;IAEF,MAAM,QAAQ,GAAG,KAAK,EAAE,MAAc,EAAmB,EAAE;QAC1D,MAAM,OAAO,GAAY;YACxB,QAAQ,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;SACpE,CAAC;QACF,MAAM,OAAO,GAAG,MAAM,uBAAuB,CAAC,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC,CAAC;QAC5F,MAAM,OAAO,GAAG,aAAa,CAAC,SAAS,EAAE,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,WAAW,CAAC,CAAC;QACxF,uEAAuE;QACvE,wEAAwE;QACxE,qEAAqE;QACrE,qEAAqE;QACrE,sEAAsE;QACtE,8BAA8B;QAC9B,WAAW,EAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;QAC1F,OAAO,WAAW,CAAC,OAAO,CAAC,CAAC;IAC7B,CAAC,CAAC;IAEF,MAAM,OAAO,GAAG,IAAI,UAAU,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,CAAC;IAE3E,sEAAsE;IACtE,yDAAyD;IACzD,qEAAqE;IACrE,sEAAsE;IACtE,sEAAsE;IACtE,4DAA4D;IAC5D,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IAC9C,OAAO,CAAC,GAAG,GAAG,KAAK,IAAI,EAAE;QACxB,MAAM,MAAM,GAAG,MAAM,WAAW,EAAE,CAAC;QACnC,qBAAqB,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QACtC,qBAAqB,CACpB,YAAY,EACZ;YACC,IAAI,EAAE,OAAO;YACb,YAAY;YACZ,IAAI,EAAE,MAAM,CAAC,IAAI;YACjB,KAAK,EAAE,MAAM,CAAC,KAAK;YACnB,YAAY;YACZ,UAAU,EAAE,MAAM,CAAC,KAAK;YACxB,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;SACnC,EACD,gBAAgB,CAChB,CAAC;QACF,OAAO,MAAM,CAAC;IACf,CAAC,CAAC;IAEF,OAAO,OAAO,CAAC;AAChB,CAAC","sourcesContent":["/**\n * Router-backed `RlmSession` factory.\n *\n * `RlmSession` (session.ts) takes `rootLlm`/`llmQuery` as injected callbacks\n * -- this module is the production wiring that builds those callbacks on top\n * of `@draht/router`'s `ModelRouter`, selects the right system-prompt tier\n * from the resolved `rlm-root` model's context window (prompts.ts), and logs\n * a cost entry (via `@draht/router`'s `estimateCost`/`logCost`) for every\n * root and sub call, tagged with a single `trajectoryId` shared across the\n * whole session. See .planning/phases/27-sub-llm-integration/27-01-PLAN.md,\n * Architecture section 5.\n */\n\nimport { randomUUID } from \"node:crypto\";\nimport type { AssistantMessage, AssistantMessageEvent, Context } from \"@draht/ai/compat\";\nimport type { ModelRouter } from \"@draht/router\";\nimport { estimateCost, logCost } from \"@draht/router\";\nimport type { PromptVars } from \"./prompts.js\";\nimport { renderPrompt, selectTier } from \"./prompts.js\";\nimport { RlmSession } from \"./session.js\";\nimport { appendTrajectoryEntry } from \"./trajectory.js\";\nimport type { RlmHistoryEntry } from \"./types.js\";\n\n/** RLM-specific router roles (see `@draht/router`'s `DEFAULT_CONFIG`). */\ntype RlmRole = \"rlm-root\" | \"rlm-sub\";\n\nexport interface CreateRouterBackedSessionOptions {\n\t/** Becomes the `context` variable inside the REPL (see `RlmSessionOptions.prompt`). */\n\tprompt: string;\n\t/**\n\t * Router used to resolve models and stream completions for both the\n\t * \"rlm-root\" and \"rlm-sub\" roles. Caller-supplied so production code\n\t * passes a real `ModelRouter` and tests can inject a fake one shaped\n\t * like its public API, with no real network calls.\n\t */\n\trouter: ModelRouter;\n\t/** What kind of content `prompt` holds (e.g. \"text\", \"code\", \"logs\", \"json\"). Default \"text\". */\n\tcontextType?: string;\n\t/** Max number of `llm_query` sub-calls advertised to the root LM this session. Default 50. */\n\tmaxSubCallBudget?: number;\n\t/** Max characters per `llm_query` sub-call prompt advertised to the root LM. Default 12_000. */\n\tsubCallCharBudget?: number;\n\t/** Passthrough to `logCost`'s `logPath` param; defaults to `.draht/cost-log.jsonl`. */\n\tcostLogPath?: string;\n\t/** `CostEntry.sessionId` for every cost entry this session logs. Defaults to the generated `trajectoryId`. */\n\tsessionId?: string;\n\t/**\n\t * Directory the trajectory JSONL log (`.draht/rlm/<trajectoryId>.jsonl`)\n\t * is written under (Phase 30, `trajectory.ts`). Passed straight through to\n\t * `appendTrajectoryEntry`'s `logDir` param; defaults to `.draht/rlm/`.\n\t */\n\ttrajectoryLogDir?: string;\n}\n\nconst DEFAULT_CONTEXT_TYPE = \"text\";\nconst DEFAULT_MAX_SUB_CALL_BUDGET = 50;\nconst DEFAULT_SUB_CALL_CHAR_BUDGET = 12_000;\n\n/**\n * Fallback context window used only when the \"rlm-root\" role's primary\n * model can't be resolved against the `@draht/ai` registry (should not\n * happen with a correctly configured router, but `selectTier` still needs\n * *some* number). 128_000 lands squarely in \"coder-mid\" -- the safest\n * middle ground between over-terse (frontier) and over-verbose\n * (small-context) prompting when we genuinely don't know the model.\n */\nconst FALLBACK_ROOT_CONTEXT_WINDOW = 128_000;\n\n/**\n * Splits `totalLength` into near-equal chunks no larger than\n * `chunkCharBudget`, used only to populate the `chunk_lengths` prompt\n * variable with concrete, addable suggestions (not enforced anywhere).\n */\nfunction computeChunkLengths(totalLength: number, chunkCharBudget: number): number[] {\n\tif (totalLength <= 0 || chunkCharBudget <= 0) return [];\n\tconst numChunks = Math.max(1, Math.ceil(totalLength / chunkCharBudget));\n\tconst base = Math.floor(totalLength / numChunks);\n\tconst remainder = totalLength % numChunks;\n\tconst lengths: number[] = [];\n\tfor (let i = 0; i < numChunks; i++) {\n\t\tlengths.push(base + (i < remainder ? 1 : 0));\n\t}\n\treturn lengths;\n}\n\n/** Renders the single user turn sent to the root LM: prior history + what's expected next. */\nfunction buildRootUserTurn(contextTotalLength: number, contextType: string, history: RlmHistoryEntry[]): string {\n\tconst header =\n\t\t`A Python REPL is running. The variable \\`context\\` already holds ${contextTotalLength} ` +\n\t\t`characters of ${contextType} content -- follow the system instructions for how to inspect, ` +\n\t\t\"chunk, and search it, and call FINAL(...)/FINAL_VAR(...) once you have the answer.\";\n\n\tif (history.length === 0) {\n\t\treturn `${header}\\n\\nWrite your first \\`\\`\\`repl fenced code block now.`;\n\t}\n\n\tconst steps = history.map((entry) => {\n\t\tconst lines = [\n\t\t\t`--- step ${entry.step} ---`,\n\t\t\t\"```repl\",\n\t\t\tentry.code,\n\t\t\t\"```\",\n\t\t\t`stdout: ${entry.truncatedStdout || \"(empty)\"}`,\n\t\t];\n\t\tif (entry.error) lines.push(`error: ${entry.error}`);\n\t\treturn lines.join(\"\\n\");\n\t});\n\n\treturn [\n\t\theader,\n\t\t\"\",\n\t\t\"History so far:\",\n\t\t\"\",\n\t\t...steps,\n\t\t\"\",\n\t\t\"Continue with the next ```repl fenced code block, or call FINAL/FINAL_VAR if you already have the answer.\",\n\t].join(\"\\n\");\n}\n\n/** Drains a `streamSimple`-shaped event stream down to its terminal `done`/`error` message. */\nasync function collectAssistantMessage(stream: AsyncIterable<AssistantMessageEvent>): Promise<AssistantMessage> {\n\tlet final: AssistantMessage | undefined;\n\tfor await (const event of stream) {\n\t\tif (event.type === \"done\") {\n\t\t\tfinal = event.message;\n\t\t} else if (event.type === \"error\") {\n\t\t\tfinal = event.error;\n\t\t}\n\t}\n\tif (!final) {\n\t\tthrow new Error(\"createRouterBackedSession: router stream ended without a done/error event\");\n\t}\n\treturn final;\n}\n\n/** Concatenates every `text` content block of an assistant message (ignores thinking/tool-call blocks). */\nfunction messageText(message: AssistantMessage): string {\n\tlet text = \"\";\n\tfor (const block of message.content) {\n\t\tif (block.type === \"text\") text += block.text;\n\t}\n\treturn text;\n}\n\n/** Computes and appends one `CostEntry` for a completed router call, returning its `estimatedCostUsd`. */\nfunction logRouterCall(\n\trole: RlmRole,\n\tmessage: AssistantMessage,\n\ttrajectoryId: string,\n\tsessionId: string,\n\tcostLogPath: string | undefined,\n): number {\n\tconst reasoningTokens = message.usage.reasoning ?? 0;\n\tconst estimatedCostUsd = estimateCost(\n\t\tmessage.provider,\n\t\tmessage.model,\n\t\tmessage.usage.input,\n\t\tmessage.usage.output,\n\t\treasoningTokens,\n\t);\n\tlogCost(\n\t\t{\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\trole,\n\t\t\tprovider: message.provider,\n\t\t\tmodel: message.model,\n\t\t\tinputTokens: message.usage.input,\n\t\t\toutputTokens: message.usage.output,\n\t\t\t...(reasoningTokens > 0 && { reasoningTokens }),\n\t\t\testimatedCostUsd,\n\t\t\tsessionId,\n\t\t\ttrajectoryId,\n\t\t},\n\t\tcostLogPath,\n\t);\n\treturn estimatedCostUsd;\n}\n\n/** A trajectory step's cost tally, accumulated between one `rootLlm` call and the next. */\ninterface StepCostAccumulator {\n\trootCostUsd: number;\n\tsubCalls: Array<{ costUsd: number; provider: string; model: string }>;\n}\n\n/**\n * Builds a real `@draht/router`-backed `RlmSession`: resolves the \"rlm-root\"\n * model to pick a prompt tier, renders that tier's system prompt with the\n * real prompt's length/chunk info, and wires `rootLlm`/`llmQuery` to call\n * `router.streamSimple(\"rlm-root\" | \"rlm-sub\", ...)`, logging a cost entry\n * (tagged with one `trajectoryId` per session) after every call.\n */\nexport function createRouterBackedSession(opts: CreateRouterBackedSessionOptions): RlmSession {\n\tconst trajectoryId = randomUUID();\n\tconst sessionId = opts.sessionId ?? trajectoryId;\n\tconst contextType = opts.contextType ?? DEFAULT_CONTEXT_TYPE;\n\tconst maxSubCallBudget = opts.maxSubCallBudget ?? DEFAULT_MAX_SUB_CALL_BUDGET;\n\tconst subCallCharBudget = opts.subCallCharBudget ?? DEFAULT_SUB_CALL_CHAR_BUDGET;\n\tconst costLogPath = opts.costLogPath;\n\tconst trajectoryLogDir = opts.trajectoryLogDir;\n\n\t// Trajectory JSONL logging (Phase 30, trajectory.ts): `rootLlm` is called\n\t// once per step, always *before* that step's own `llm_query` sub-calls\n\t// happen (they occur later, inside the driver's `runExec`, which\n\t// `session.ts` only invokes after `rootLlm` resolves -- see session.ts's\n\t// `step()`). So a step's sub-calls always land between one `rootLlm` call\n\t// and the next, and the simplest correct way to correlate them without\n\t// touching session.ts's core loop is: accumulate sub-call costs into\n\t// `pendingStep` as they happen, and finalize+append the *previous* step's\n\t// `TrajectoryStepEntry` the moment the *next* `rootLlm` call (or the final\n\t// `run()` resolution, for the very last step) tells us -- via the\n\t// `history` array it's handed -- that the previous step actually\n\t// completed.\n\tlet pendingStep: StepCostAccumulator | null = null;\n\tlet finalizedThroughStep = 0;\n\tlet totalCostUsd = 0;\n\n\tfunction finalizeStepIfPending(history: RlmHistoryEntry[]): void {\n\t\tif (!pendingStep) return;\n\t\tconst entry = history[history.length - 1];\n\t\t// No new completed entry since the last finalize (e.g. the step that\n\t\t// started this accumulator never finished -- a hard-killed/errored\n\t\t// step never gets pushed to `history`): nothing to log, and don't\n\t\t// double-log the previous step either.\n\t\tif (!entry || entry.step <= finalizedThroughStep) return;\n\t\tconst stepCostUsd = pendingStep.rootCostUsd + pendingStep.subCalls.reduce((sum, sub) => sum + sub.costUsd, 0);\n\t\ttotalCostUsd += stepCostUsd;\n\t\tappendTrajectoryEntry(\n\t\t\ttrajectoryId,\n\t\t\t{\n\t\t\t\ttype: \"step\",\n\t\t\t\ttrajectoryId,\n\t\t\t\tstep: entry.step,\n\t\t\t\tcode: entry.code,\n\t\t\t\ttruncatedStdout: entry.truncatedStdout,\n\t\t\t\terror: entry.error,\n\t\t\t\tsubCalls: pendingStep.subCalls,\n\t\t\t\tcostUsd: stepCostUsd,\n\t\t\t\ttimestamp: entry.timestamp,\n\t\t\t},\n\t\t\ttrajectoryLogDir,\n\t\t);\n\t\tfinalizedThroughStep = entry.step;\n\t\tpendingStep = null;\n\t}\n\n\tconst rootRef = opts.router.resolve(\"rlm-root\");\n\tconst rootModel = opts.router.resolveModel(rootRef);\n\tconst contextWindow = rootModel?.contextWindow ?? FALLBACK_ROOT_CONTEXT_WINDOW;\n\n\tconst tier = selectTier(contextWindow);\n\tconst contextTotalLength = opts.prompt.length;\n\tconst chunkLengths = computeChunkLengths(contextTotalLength, subCallCharBudget);\n\n\tconst vars: PromptVars = {\n\t\tcontextType,\n\t\tcontextTotalLength,\n\t\tchunkLengths,\n\t\tmaxSubCallBudget,\n\t\tsubCallCharBudget,\n\t};\n\tconst systemPrompt = renderPrompt(tier, vars);\n\n\tconst rootLlm = async (history: RlmHistoryEntry[]): Promise<string> => {\n\t\t// `history` reflects every step completed *before* this one -- so if a\n\t\t// previous step's accumulator is still pending, this call is exactly\n\t\t// the signal that it (and only it) has now fully completed (its own\n\t\t// root call plus every sub-call it triggered).\n\t\tfinalizeStepIfPending(history);\n\t\tpendingStep = { rootCostUsd: 0, subCalls: [] };\n\t\tconst context: Context = {\n\t\t\tsystemPrompt,\n\t\t\tmessages: [\n\t\t\t\t{\n\t\t\t\t\trole: \"user\",\n\t\t\t\t\tcontent: buildRootUserTurn(contextTotalLength, contextType, history),\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t},\n\t\t\t],\n\t\t};\n\t\tconst message = await collectAssistantMessage(opts.router.streamSimple(\"rlm-root\", context));\n\t\tconst costUsd = logRouterCall(\"rlm-root\", message, trajectoryId, sessionId, costLogPath);\n\t\tpendingStep.rootCostUsd = costUsd;\n\t\treturn messageText(message);\n\t};\n\n\tconst llmQuery = async (prompt: string): Promise<string> => {\n\t\tconst context: Context = {\n\t\t\tmessages: [{ role: \"user\", content: prompt, timestamp: Date.now() }],\n\t\t};\n\t\tconst message = await collectAssistantMessage(opts.router.streamSimple(\"rlm-sub\", context));\n\t\tconst costUsd = logRouterCall(\"rlm-sub\", message, trajectoryId, sessionId, costLogPath);\n\t\t// A sub-call always happens after `rootLlm` resolved for the step that\n\t\t// triggered it (and before the next `rootLlm` call) -- see session.ts's\n\t\t// `step()`/`runExec` -- so `pendingStep` is always the right step to\n\t\t// attribute this cost to. `pendingStep` is only ever null before the\n\t\t// very first `rootLlm` call, at which point no `llm_query` could have\n\t\t// been dispatched yet either.\n\t\tpendingStep?.subCalls.push({ costUsd, provider: message.provider, model: message.model });\n\t\treturn messageText(message);\n\t};\n\n\tconst session = new RlmSession({ prompt: opts.prompt, rootLlm, llmQuery });\n\n\t// Wrap (not replace the class's core loop -- session.ts is untouched)\n\t// `run()` so the trajectory's last step and its terminal\n\t// `TrajectoryFinalEntry` get logged once the whole session resolves.\n\t// There's no other hook to observe \"the session is done\" from outside\n\t// session.ts: `rootLlm` is never called again after the last step, so\n\t// nothing else would ever finalize that step's accumulator.\n\tconst originalRun = session.run.bind(session);\n\tsession.run = async () => {\n\t\tconst result = await originalRun();\n\t\tfinalizeStepIfPending(result.history);\n\t\tappendTrajectoryEntry(\n\t\t\ttrajectoryId,\n\t\t\t{\n\t\t\t\ttype: \"final\",\n\t\t\t\ttrajectoryId,\n\t\t\t\tkind: result.kind,\n\t\t\t\tvalue: result.value,\n\t\t\t\ttotalCostUsd,\n\t\t\t\ttotalSteps: result.steps,\n\t\t\t\ttimestamp: new Date().toISOString(),\n\t\t\t},\n\t\t\ttrajectoryLogDir,\n\t\t);\n\t\treturn result;\n\t};\n\n\treturn session;\n}\n"]}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS-level sandbox spawn wrapper for the RLM Python REPL driver.
|
|
3
|
+
*
|
|
4
|
+
* This is the REAL security boundary described in
|
|
5
|
+
* .planning/phases/28-repl-sandbox-safety/28-01-PLAN.md ("IMPORTANT" section
|
|
6
|
+
* and Architecture section 1) -- NOT `repl_driver.py`'s Python-level
|
|
7
|
+
* builtins/import allowlist (that's defense-in-depth for a
|
|
8
|
+
* confused/prompt-injected LLM, not something that stops a deliberately
|
|
9
|
+
* adversarial payload; the Python object graph makes in-process
|
|
10
|
+
* confinement of arbitrary Python fundamentally unachievable -- see the
|
|
11
|
+
* plan for the `__subclasses__`/`gi_frame.f_builtins` escapes that defeat
|
|
12
|
+
* any purely in-process approach).
|
|
13
|
+
*
|
|
14
|
+
* **macOS**: wraps the interpreter with `sandbox-exec -f macos.sb ...`. The
|
|
15
|
+
* SBPL profile (`../sandbox/macos.sb`) denies network and process-exec
|
|
16
|
+
* entirely (which alone defeats `os.system`/`subprocess` -- they fork+exec
|
|
17
|
+
* a shell -- independent of any Python-level import block) and scopes file
|
|
18
|
+
* access to exactly the Python installation needed to boot plus the
|
|
19
|
+
* session's workdir. Machine-specific paths (where python3 actually lives)
|
|
20
|
+
* are discovered here at spawn time via a real subprocess call, never
|
|
21
|
+
* hardcoded/guessed -- see `resolvePythonInstallation`.
|
|
22
|
+
*
|
|
23
|
+
* **Linux**: wraps with `unshare --user --map-root-user --net --mount --`
|
|
24
|
+
* (empty net namespace = network unreachable at the kernel; mount
|
|
25
|
+
* namespace for FS confinement), falling back to `bwrap` with equivalent
|
|
26
|
+
* flags if `unshare` isn't available. Availability is detected at spawn
|
|
27
|
+
* time, never assumed.
|
|
28
|
+
*
|
|
29
|
+
* **Fail closed everywhere**: if the sandboxing mechanism for the current
|
|
30
|
+
* platform can't be established (wrapper binary missing, profile missing,
|
|
31
|
+
* Python installation can't be discovered, unsupported platform), every
|
|
32
|
+
* function here throws `SandboxUnavailableError` synchronously rather than
|
|
33
|
+
* returning a command that would run the driver unwrapped. There is no
|
|
34
|
+
* fallback path to an unsandboxed spawn anywhere in this module.
|
|
35
|
+
*/
|
|
36
|
+
import { type ChildProcess } from "node:child_process";
|
|
37
|
+
/**
|
|
38
|
+
* Thrown whenever the OS-level sandbox can't be established for the current
|
|
39
|
+
* platform/environment. Callers (see `session.ts`) must let this propagate
|
|
40
|
+
* and refuse to run rather than catching it and falling back to an
|
|
41
|
+
* unwrapped spawn -- that fallback is exactly the failure mode Architecture
|
|
42
|
+
* section 1's "fail closed" requirement exists to prevent.
|
|
43
|
+
*/
|
|
44
|
+
export declare class SandboxUnavailableError extends Error {
|
|
45
|
+
constructor(message: string);
|
|
46
|
+
}
|
|
47
|
+
export interface SandboxSpawnOptions {
|
|
48
|
+
/** Absolute path to `repl_driver.py`. */
|
|
49
|
+
driverPath: string;
|
|
50
|
+
/**
|
|
51
|
+
* Absolute path to the session's scratch workdir. The only place the
|
|
52
|
+
* sandboxed process may read *and* write freely (besides the read-only
|
|
53
|
+
* Python installation itself).
|
|
54
|
+
*/
|
|
55
|
+
workdir: string;
|
|
56
|
+
/**
|
|
57
|
+
* Which `python3` to sandbox. Defaults to whatever `python3` resolves to
|
|
58
|
+
* on `PATH`. Overridable mainly so tests can point at a specific
|
|
59
|
+
* interpreter without mutating `PATH`.
|
|
60
|
+
*/
|
|
61
|
+
pythonBin?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Override for the macOS `sandbox-exec` binary path. Mainly a test
|
|
64
|
+
* injection point for proving fail-closed behavior (point it at a
|
|
65
|
+
* nonexistent path and assert setup refuses rather than falling back to
|
|
66
|
+
* an unwrapped spawn) -- see Architecture section 1's fail-closed
|
|
67
|
+
* requirement and this plan's task 1 test 7.
|
|
68
|
+
*/
|
|
69
|
+
sandboxExecBin?: string;
|
|
70
|
+
/** Override for the Linux `unshare` binary path. Mainly for tests. */
|
|
71
|
+
unshareBin?: string;
|
|
72
|
+
/** Override for the Linux `bwrap` binary path (used if `unshare` is unavailable). Mainly for tests. */
|
|
73
|
+
bwrapBin?: string;
|
|
74
|
+
}
|
|
75
|
+
/** The concrete command + args a `child_process.spawn` call should use. */
|
|
76
|
+
export interface SandboxedCommand {
|
|
77
|
+
command: string;
|
|
78
|
+
args: string[];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Computes the sandboxed command/args for the current platform without
|
|
82
|
+
* spawning anything. Exposed separately from `spawnSandboxed` so tests can
|
|
83
|
+
* assert on the resolved invocation and so fail-closed behavior (throwing
|
|
84
|
+
* `SandboxUnavailableError`) is observable synchronously, before any
|
|
85
|
+
* process exists.
|
|
86
|
+
*/
|
|
87
|
+
export declare function resolveSandboxedCommand(opts: SandboxSpawnOptions): SandboxedCommand;
|
|
88
|
+
/**
|
|
89
|
+
* Spawns `repl_driver.py` wrapped in the platform's OS-level sandbox.
|
|
90
|
+
* Throws `SandboxUnavailableError` synchronously (before any process is
|
|
91
|
+
* created) if the sandbox can't be established -- there is no unwrapped
|
|
92
|
+
* fallback path.
|
|
93
|
+
*/
|
|
94
|
+
export declare function spawnSandboxed(opts: SandboxSpawnOptions): ChildProcess;
|
|
95
|
+
//# sourceMappingURL=sandbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../src/sandbox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEH,OAAO,EAAE,KAAK,YAAY,EAAuB,MAAM,oBAAoB,CAAC;AAS5E;;;;;;GAMG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;IACjD,YAAY,OAAO,EAAE,MAAM,EAG1B;CACD;AAED,MAAM,WAAW,mBAAmB;IACnC,yCAAyC;IACzC,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,sEAAsE;IACtE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uGAAuG;IACvG,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,2EAA2E;AAC3E,MAAM,WAAW,gBAAgB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,EAAE,CAAC;CACf;AA8MD;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,mBAAmB,GAAG,gBAAgB,CAMnF;AA2CD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,mBAAmB,GAAG,YAAY,CAGtE"}
|