modelmix 5.0.2 → 5.0.4
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 +121 -7
- package/RLM_PLUGIN_SPEC.md +465 -0
- package/demo/gemini.js +3 -4
- package/demo/moderation.js +17 -0
- package/demo/short.js +1 -1
- package/effort.js +2 -0
- package/index.d.ts +107 -1
- package/index.js +443 -46
- package/package.json +4 -2
- package/plugins/rlm/index.d.ts +194 -0
- package/plugins/rlm/index.js +25 -0
- package/plugins/rlm/lib/budget.js +153 -0
- package/plugins/rlm/lib/isolated-vm-sandbox.js +90 -0
- package/plugins/rlm/lib/markdown.js +156 -0
- package/plugins/rlm/lib/planner-prompt.js +137 -0
- package/plugins/rlm/lib/plugin.js +203 -0
- package/plugins/rlm/lib/runtime.js +146 -0
- package/plugins/rlm/lib/variable-descriptors.js +228 -0
- package/plugins/rlm/lib/worker-catalog.js +70 -0
- package/plugins/rlm/package.json +32 -0
- package/plugins/rlm/prompts/partials/processing-rules.md +8 -0
- package/plugins/rlm/prompts/planner.md +53 -0
- package/plugins/rlm/test/budget.test.js +86 -0
- package/plugins/rlm/test/fixtures/book.md +24 -0
- package/plugins/rlm/test/isolated-vm-sandbox.test.js +114 -0
- package/plugins/rlm/test/markdown.test.js +64 -0
- package/plugins/rlm/test/planner-template.test.js +140 -0
- package/plugins/rlm/test/plugin-contract.test.js +182 -0
- package/plugins/rlm/test/rlm-e2e.test.js +338 -0
- package/plugins/rlm/test/variable-descriptors.test.js +170 -0
- package/plugins/rlm/test/worker-catalog.test.js +104 -0
- package/pnpm-workspace.yaml +6 -0
- package/skills/modelmix/SKILL.md +26 -4
- package/test/effort.test.js +14 -1
- package/test/live.mcp.js +6 -6
- package/test/live.test.js +2 -2
- package/test/moderation.test.js +135 -0
- package/test/plugins.test.js +356 -0
- package/test/tokens.test.js +37 -5
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
3
|
+
const prototype = Object.getPrototypeOf(value);
|
|
4
|
+
return prototype === Object.prototype || prototype === null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function roundAverage(total, count) {
|
|
8
|
+
return count === 0 ? 0 : Number((total / count).toFixed(2));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function numericStats(values) {
|
|
12
|
+
if (values.length === 0) {
|
|
13
|
+
return { min: 0, max: 0, average: 0, total: 0 };
|
|
14
|
+
}
|
|
15
|
+
let min = values[0];
|
|
16
|
+
let max = values[0];
|
|
17
|
+
let total = 0;
|
|
18
|
+
for (const value of values) {
|
|
19
|
+
min = Math.min(min, value);
|
|
20
|
+
max = Math.max(max, value);
|
|
21
|
+
total += value;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
min,
|
|
25
|
+
max,
|
|
26
|
+
average: roundAverage(total, values.length),
|
|
27
|
+
total
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function valueType(value) {
|
|
32
|
+
if (value === null) return 'null';
|
|
33
|
+
if (Array.isArray(value)) return 'array';
|
|
34
|
+
return typeof value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function primitiveSerializedBytes(value, path) {
|
|
38
|
+
let serialized;
|
|
39
|
+
try {
|
|
40
|
+
serialized = JSON.stringify(value);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw new TypeError(`External variable ${path} is not JSON-serializable: ${error.message}`);
|
|
43
|
+
}
|
|
44
|
+
if (serialized === undefined) {
|
|
45
|
+
throw new TypeError(`External variable ${path} is not JSON-serializable.`);
|
|
46
|
+
}
|
|
47
|
+
return Buffer.byteLength(serialized, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function objectSerializedBytes(value, childDescriptors) {
|
|
51
|
+
const keys = Object.keys(value);
|
|
52
|
+
let bytes = 2;
|
|
53
|
+
for (let index = 0; index < keys.length; index += 1) {
|
|
54
|
+
const key = keys[index];
|
|
55
|
+
bytes += primitiveSerializedBytes(key, key) + 1 + childDescriptors[key].estimatedBytes;
|
|
56
|
+
if (index > 0) bytes += 1;
|
|
57
|
+
}
|
|
58
|
+
return bytes;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function arraySerializedBytes(itemBytes) {
|
|
62
|
+
if (itemBytes.length === 0) return 2;
|
|
63
|
+
return 2
|
|
64
|
+
+ itemBytes.reduce((sum, bytes) => sum + bytes, 0)
|
|
65
|
+
+ itemBytes.length - 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function countLines(value) {
|
|
69
|
+
return value.length === 0 ? 0 : value.split(/\r\n|\n|\r/).length;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function countParagraphs(value) {
|
|
73
|
+
const trimmed = value.trim();
|
|
74
|
+
return trimmed.length === 0
|
|
75
|
+
? 0
|
|
76
|
+
: trimmed.split(/(?:\r\n|\n|\r)[\t ]*(?:\r\n|\n|\r)+/).length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function stringMetrics(value) {
|
|
80
|
+
return {
|
|
81
|
+
characters: Array.from(value).length,
|
|
82
|
+
utf16CodeUnits: value.length,
|
|
83
|
+
utf8Bytes: Buffer.byteLength(value, 'utf8'),
|
|
84
|
+
lines: countLines(value),
|
|
85
|
+
paragraphs: countParagraphs(value)
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function incrementType(types, value) {
|
|
90
|
+
const type = valueType(value);
|
|
91
|
+
types[type] = (types[type] || 0) + 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function summarizeStringValues(values) {
|
|
95
|
+
const metrics = values.map(stringMetrics);
|
|
96
|
+
return {
|
|
97
|
+
characters: numericStats(metrics.map(metric => metric.characters)),
|
|
98
|
+
utf8Bytes: numericStats(metrics.map(metric => metric.utf8Bytes)),
|
|
99
|
+
lines: numericStats(metrics.map(metric => metric.lines)),
|
|
100
|
+
paragraphs: numericStats(metrics.map(metric => metric.paragraphs))
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function summarizeObjectArray(items) {
|
|
105
|
+
const keys = [...new Set(items.flatMap(item => Object.keys(item)))].sort();
|
|
106
|
+
const properties = {};
|
|
107
|
+
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
const values = items
|
|
110
|
+
.filter(item => Object.prototype.hasOwnProperty.call(item, key))
|
|
111
|
+
.map(item => item[key]);
|
|
112
|
+
const types = {};
|
|
113
|
+
for (const value of values) incrementType(types, value);
|
|
114
|
+
const property = {
|
|
115
|
+
present: values.length,
|
|
116
|
+
missing: items.length - values.length,
|
|
117
|
+
types
|
|
118
|
+
};
|
|
119
|
+
if (values.length > 0 && values.every(value => typeof value === 'string')) {
|
|
120
|
+
property.stringSize = summarizeStringValues(values);
|
|
121
|
+
}
|
|
122
|
+
properties[key] = property;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function summarizeArrayItems(items) {
|
|
132
|
+
const types = {};
|
|
133
|
+
for (const item of items) incrementType(types, item);
|
|
134
|
+
const summary = { types };
|
|
135
|
+
|
|
136
|
+
if (items.length > 0 && items.every(item => typeof item === 'string')) {
|
|
137
|
+
summary.type = 'string';
|
|
138
|
+
summary.stringSize = summarizeStringValues(items);
|
|
139
|
+
} else if (items.length > 0 && items.every(isPlainObject)) {
|
|
140
|
+
Object.assign(summary, summarizeObjectArray(items));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return summary;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function describeValue(value, path, ancestors) {
|
|
147
|
+
const type = valueType(value);
|
|
148
|
+
if (type === 'undefined' || type === 'function' || type === 'symbol' || type === 'bigint') {
|
|
149
|
+
throw new TypeError(`External variable ${path} has unsupported type ${type}.`);
|
|
150
|
+
}
|
|
151
|
+
if (type === 'number' && !Number.isFinite(value)) {
|
|
152
|
+
throw new TypeError(`External variable ${path} must contain only finite numbers.`);
|
|
153
|
+
}
|
|
154
|
+
if (type === 'object' && value !== null && !isPlainObject(value)) {
|
|
155
|
+
throw new TypeError(`External variable ${path} must contain only plain objects.`);
|
|
156
|
+
}
|
|
157
|
+
if (value && typeof value === 'object') {
|
|
158
|
+
if (ancestors.has(value)) {
|
|
159
|
+
throw new TypeError(`External variable ${path} contains a circular reference.`);
|
|
160
|
+
}
|
|
161
|
+
ancestors.add(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let descriptor;
|
|
165
|
+
if (type === 'string') {
|
|
166
|
+
descriptor = {
|
|
167
|
+
path,
|
|
168
|
+
type,
|
|
169
|
+
estimatedBytes: primitiveSerializedBytes(value, path),
|
|
170
|
+
...stringMetrics(value)
|
|
171
|
+
};
|
|
172
|
+
} else if (type === 'array') {
|
|
173
|
+
const itemBytes = [];
|
|
174
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
175
|
+
const itemDescriptor = describeValue(value[index], `${path}[${index}]`, ancestors);
|
|
176
|
+
itemBytes.push(itemDescriptor.estimatedBytes);
|
|
177
|
+
}
|
|
178
|
+
descriptor = {
|
|
179
|
+
path,
|
|
180
|
+
type,
|
|
181
|
+
items: value.length,
|
|
182
|
+
estimatedBytes: arraySerializedBytes(itemBytes),
|
|
183
|
+
itemSize: numericStats(itemBytes),
|
|
184
|
+
itemShape: summarizeArrayItems(value)
|
|
185
|
+
};
|
|
186
|
+
} else if (type === 'object') {
|
|
187
|
+
const properties = {};
|
|
188
|
+
for (const key of Object.keys(value).sort()) {
|
|
189
|
+
properties[key] = describeValue(value[key], `${path}.${key}`, ancestors);
|
|
190
|
+
}
|
|
191
|
+
descriptor = {
|
|
192
|
+
path,
|
|
193
|
+
type,
|
|
194
|
+
properties: Object.keys(properties).length,
|
|
195
|
+
estimatedBytes: objectSerializedBytes(value, properties),
|
|
196
|
+
children: properties
|
|
197
|
+
};
|
|
198
|
+
} else {
|
|
199
|
+
descriptor = {
|
|
200
|
+
path,
|
|
201
|
+
type,
|
|
202
|
+
estimatedBytes: primitiveSerializedBytes(value, path)
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (value && typeof value === 'object') ancestors.delete(value);
|
|
207
|
+
return descriptor;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function describeVariables(variables) {
|
|
211
|
+
if (!isPlainObject(variables)) {
|
|
212
|
+
throw new TypeError('External variables must be a plain object.');
|
|
213
|
+
}
|
|
214
|
+
const descriptors = {};
|
|
215
|
+
for (const name of Object.keys(variables).sort()) {
|
|
216
|
+
descriptors[name] = describeValue(variables[name], name, new WeakSet());
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
sizeBasis: 'serialized-json-utf8',
|
|
220
|
+
variables: Object.keys(descriptors).length,
|
|
221
|
+
estimatedBytes: objectSerializedBytes(variables, descriptors),
|
|
222
|
+
descriptors
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = {
|
|
227
|
+
describeVariables
|
|
228
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
3
|
+
const prototype = Object.getPrototypeOf(value);
|
|
4
|
+
return prototype === Object.prototype || prototype === null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function finiteRating(value, path) {
|
|
8
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
9
|
+
throw new TypeError(`${path} must be a non-negative finite number.`);
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function validateModel(value, path) {
|
|
15
|
+
if (!value || typeof value !== 'object' || !Array.isArray(value.models)) {
|
|
16
|
+
throw new TypeError(`${path} must be a ModelMix instance.`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createWorkerCatalog(workers) {
|
|
22
|
+
if (!isPlainObject(workers) || Object.keys(workers).length === 0) {
|
|
23
|
+
throw new TypeError('workers must be a non-empty plain object.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const models = new Map();
|
|
27
|
+
const manifest = {};
|
|
28
|
+
for (const name of Object.keys(workers).sort()) {
|
|
29
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) {
|
|
30
|
+
throw new TypeError(`Worker name "${name}" must use letters, numbers, underscores, or hyphens.`);
|
|
31
|
+
}
|
|
32
|
+
const worker = workers[name];
|
|
33
|
+
if (!isPlainObject(worker)) {
|
|
34
|
+
throw new TypeError(`Worker "${name}" must be a plain object.`);
|
|
35
|
+
}
|
|
36
|
+
if (typeof worker.description !== 'string' || worker.description.trim().length === 0) {
|
|
37
|
+
throw new TypeError(`Worker "${name}" description must be a non-empty string.`);
|
|
38
|
+
}
|
|
39
|
+
const usesParent = worker.useParent === true;
|
|
40
|
+
if (usesParent === (worker.model !== undefined)) {
|
|
41
|
+
throw new TypeError(
|
|
42
|
+
`Worker "${name}" must define exactly one of model or useParent: true.`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
models.set(
|
|
46
|
+
name,
|
|
47
|
+
usesParent ? undefined : validateModel(worker.model, `Worker "${name}" model`)
|
|
48
|
+
);
|
|
49
|
+
manifest[name] = {
|
|
50
|
+
intelligence: finiteRating(worker.intelligence, `Worker "${name}" intelligence`),
|
|
51
|
+
cost: finiteRating(worker.cost, `Worker "${name}" cost`),
|
|
52
|
+
speed: finiteRating(worker.speed, `Worker "${name}" speed`),
|
|
53
|
+
description: worker.description
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
get(name) {
|
|
59
|
+
if (typeof name !== 'string' || !models.has(name)) {
|
|
60
|
+
throw new Error(`Unknown RLM worker "${name}".`);
|
|
61
|
+
}
|
|
62
|
+
return models.get(name);
|
|
63
|
+
},
|
|
64
|
+
manifest
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
createWorkerCatalog
|
|
70
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@modelmix/rlm",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Recursive Language Model plugin for ModelMix.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./index.d.ts",
|
|
10
|
+
"require": "./index.js",
|
|
11
|
+
"default": "./index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"index.d.ts",
|
|
17
|
+
"lib/",
|
|
18
|
+
"prompts/"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"type": "commonjs",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"isolated-vm": "6.0.2",
|
|
30
|
+
"mdast-util-from-markdown": "2.0.3"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
## Required processing rules
|
|
2
|
+
|
|
3
|
+
- Never send more than <%- maxQueryBytes %> UTF-8 bytes in one `query()` payload.
|
|
4
|
+
- Split oversized strings and string fields at paragraph or other semantic boundaries.
|
|
5
|
+
- Batch compatible array items when each item fits within the payload limit.
|
|
6
|
+
- Process independent batches concurrently with at most <%- maxConcurrentQueries %> active queries.
|
|
7
|
+
- Preserve source order and complete coverage when reassembling the final output.
|
|
8
|
+
- Inspect external variables in the sandbox; never ask the planner to reproduce hidden content.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Recursive Language Model Planner
|
|
2
|
+
|
|
3
|
+
Generate an executable JavaScript orchestration program for the user's task. The source data remains in external sandbox variables and is not present in this prompt.
|
|
4
|
+
|
|
5
|
+
## External variable manifest
|
|
6
|
+
|
|
7
|
+
The following JSON contains metadata only. Variable values must be inspected inside the sandbox.
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
<%- variableManifest %>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Processing limits
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
<%- processingLimits %>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Planning hints
|
|
20
|
+
|
|
21
|
+
These hints are derived from serialized variable sizes and query limits. Apply them to the actual task rather than treating them as mandatory task-specific operations.
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
<%- planningHints %>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Worker catalog
|
|
28
|
+
|
|
29
|
+
Choose a worker by its registered name for every `query()` call. The catalog contains developer-supplied decision metadata only; model objects, provider configuration, and credentials are not exposed.
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
<%- workerManifest %>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Sandbox API
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
const result = await query({
|
|
39
|
+
worker: 'registered-worker-name',
|
|
40
|
+
system: 'Focused instructions for this subtask.',
|
|
41
|
+
message: externalVariableOrFragment
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
<%- include('partials/processing-rules.md') %>
|
|
46
|
+
|
|
47
|
+
## Output requirements
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
<%- outputRequirements %>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Return only an async JavaScript IIFE. The returned value must be the final result requested by the user. Do not return Markdown fences, explanations, or source data samples.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const { expect } = require('chai');
|
|
2
|
+
const { RlmLimitError } = require('..');
|
|
3
|
+
const { createRuntimeBudget } = require('../lib/budget');
|
|
4
|
+
|
|
5
|
+
function limits(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
maxQueryBytes: 100,
|
|
8
|
+
sandboxMemoryBytes: 1024 * 1024,
|
|
9
|
+
maxConcurrentQueries: 2,
|
|
10
|
+
maxCalls: 4,
|
|
11
|
+
maxOutputBytes: 100,
|
|
12
|
+
maxGeneratedTokens: 20,
|
|
13
|
+
maxWallTimeMs: 1000,
|
|
14
|
+
...overrides
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('RLM runtime budget', () => {
|
|
19
|
+
it('limits active queries while allowing queued work to finish', async () => {
|
|
20
|
+
const budget = createRuntimeBudget(limits());
|
|
21
|
+
let active = 0;
|
|
22
|
+
let peak = 0;
|
|
23
|
+
const operation = async value => budget.runQuery({ payloadBytes: 10 }, async () => {
|
|
24
|
+
active += 1;
|
|
25
|
+
peak = Math.max(peak, active);
|
|
26
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
27
|
+
active -= 1;
|
|
28
|
+
return { message: value, tokens: { output: 1 } };
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const results = await Promise.all([
|
|
32
|
+
operation('a'),
|
|
33
|
+
operation('b'),
|
|
34
|
+
operation('c'),
|
|
35
|
+
operation('d')
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
expect(results.map(result => result.message)).to.deep.equal(['a', 'b', 'c', 'd']);
|
|
39
|
+
expect(peak).to.equal(2);
|
|
40
|
+
expect(budget.snapshot()).to.include({
|
|
41
|
+
calls: 4,
|
|
42
|
+
active: 0,
|
|
43
|
+
peakConcurrency: 2,
|
|
44
|
+
outputBytes: 4,
|
|
45
|
+
generatedTokens: 4
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('fails clearly on payload, call, output, and token limits', async () => {
|
|
50
|
+
const payloadBudget = createRuntimeBudget(limits());
|
|
51
|
+
expect(() => payloadBudget.assertQueryPayload(101))
|
|
52
|
+
.to.throw(RlmLimitError).with.property('limit', 'maxQueryBytes');
|
|
53
|
+
|
|
54
|
+
const callBudget = createRuntimeBudget(limits({ maxCalls: 1 }));
|
|
55
|
+
await callBudget.runPlanner(async () => ({ message: 'planner' }));
|
|
56
|
+
let callFailure;
|
|
57
|
+
try {
|
|
58
|
+
await callBudget.runPlanner(async () => ({ message: 'again' }));
|
|
59
|
+
} catch (error) {
|
|
60
|
+
callFailure = error;
|
|
61
|
+
}
|
|
62
|
+
expect(callFailure).to.be.instanceOf(RlmLimitError);
|
|
63
|
+
expect(callFailure.limit).to.equal('maxCalls');
|
|
64
|
+
|
|
65
|
+
const outputBudget = createRuntimeBudget(limits({ maxOutputBytes: 2 }));
|
|
66
|
+
let outputFailure;
|
|
67
|
+
try {
|
|
68
|
+
await outputBudget.runPlanner(async () => ({ message: 'long' }));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
outputFailure = error;
|
|
71
|
+
}
|
|
72
|
+
expect(outputFailure.limit).to.equal('maxOutputBytes');
|
|
73
|
+
|
|
74
|
+
const tokenBudget = createRuntimeBudget(limits({ maxGeneratedTokens: 1 }));
|
|
75
|
+
let tokenFailure;
|
|
76
|
+
try {
|
|
77
|
+
await tokenBudget.runPlanner(async () => ({
|
|
78
|
+
message: '',
|
|
79
|
+
tokens: { output: 2 }
|
|
80
|
+
}));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
tokenFailure = error;
|
|
83
|
+
}
|
|
84
|
+
expect(tokenFailure.limit).to.equal('maxGeneratedTokens');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Introductory note with a [reference](https://example.com).
|
|
2
|
+
|
|
3
|
+
- Preface item one
|
|
4
|
+
- Preface item two
|
|
5
|
+
|
|
6
|
+
# Chapter One
|
|
7
|
+
|
|
8
|
+
Opening paragraph.
|
|
9
|
+
|
|
10
|
+
- First point
|
|
11
|
+
- Second point
|
|
12
|
+
- Nested point
|
|
13
|
+
|
|
14
|
+
## Scene One
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
# This is code, not a chapter
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
# Chapter One
|
|
21
|
+
|
|
22
|
+
| Character | Role |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| Ada | Engineer |
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const { expect } = require('chai');
|
|
2
|
+
const {
|
|
3
|
+
RlmLimitError,
|
|
4
|
+
createIsolatedVmSandbox
|
|
5
|
+
} = require('..');
|
|
6
|
+
|
|
7
|
+
function limits(sandboxMemoryBytes = 16 * 1024 * 1024) {
|
|
8
|
+
return { sandboxMemoryBytes };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('RLM isolated-vm sandbox', () => {
|
|
12
|
+
it('exposes only copied variables and the validated query callback', async () => {
|
|
13
|
+
const sandbox = createIsolatedVmSandbox();
|
|
14
|
+
const calls = [];
|
|
15
|
+
const value = await sandbox.execute({
|
|
16
|
+
code: `(async () => ({
|
|
17
|
+
item: variables.items[0],
|
|
18
|
+
response: await query({ worker: 'fast', system: 'Classify.', message: 'Ada' }),
|
|
19
|
+
globals: {
|
|
20
|
+
process: typeof process,
|
|
21
|
+
require: typeof require,
|
|
22
|
+
fetch: typeof fetch,
|
|
23
|
+
Buffer: typeof Buffer,
|
|
24
|
+
query: typeof query
|
|
25
|
+
}
|
|
26
|
+
}))()`,
|
|
27
|
+
variables: { items: ['external'] },
|
|
28
|
+
query: async input => {
|
|
29
|
+
calls.push(input);
|
|
30
|
+
return 'person';
|
|
31
|
+
},
|
|
32
|
+
limits: limits(),
|
|
33
|
+
timeoutMs: 1000
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(value).to.deep.equal({
|
|
37
|
+
item: 'external',
|
|
38
|
+
response: 'person',
|
|
39
|
+
globals: {
|
|
40
|
+
process: 'undefined',
|
|
41
|
+
require: 'undefined',
|
|
42
|
+
fetch: 'undefined',
|
|
43
|
+
Buffer: 'undefined',
|
|
44
|
+
query: 'function'
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
expect(calls).to.deep.equal([{
|
|
48
|
+
worker: 'fast',
|
|
49
|
+
system: 'Classify.',
|
|
50
|
+
message: 'Ada'
|
|
51
|
+
}]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('terminates programs that exceed their wall-time limit', async () => {
|
|
55
|
+
let failure;
|
|
56
|
+
try {
|
|
57
|
+
await createIsolatedVmSandbox().execute({
|
|
58
|
+
code: '(async () => { while (true) {} })()',
|
|
59
|
+
variables: {},
|
|
60
|
+
query: async () => '',
|
|
61
|
+
limits: limits(),
|
|
62
|
+
timeoutMs: 20
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
failure = error;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
expect(failure).to.be.instanceOf(RlmLimitError);
|
|
69
|
+
expect(failure.limit).to.equal('maxWallTimeMs');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('rejects memory limits below the isolated-vm minimum', async () => {
|
|
73
|
+
let failure;
|
|
74
|
+
try {
|
|
75
|
+
await createIsolatedVmSandbox().execute({
|
|
76
|
+
code: '(async () => "ok")()',
|
|
77
|
+
variables: {},
|
|
78
|
+
query: async () => '',
|
|
79
|
+
limits: limits(1024 * 1024),
|
|
80
|
+
timeoutMs: 1000
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
failure = error;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
expect(failure).to.be.instanceOf(TypeError);
|
|
87
|
+
expect(failure.message).to.include('at least 8388608');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('terminates programs that exceed the configured isolate heap', async () => {
|
|
91
|
+
let failure;
|
|
92
|
+
try {
|
|
93
|
+
await createIsolatedVmSandbox().execute({
|
|
94
|
+
code: `(() => {
|
|
95
|
+
const values = [];
|
|
96
|
+
while (true) {
|
|
97
|
+
const item = new Uint8Array(1024 * 1024);
|
|
98
|
+
for (let index = 0; index < item.length; index += 4096) item[index] = 1;
|
|
99
|
+
values.push(item);
|
|
100
|
+
}
|
|
101
|
+
})()`,
|
|
102
|
+
variables: {},
|
|
103
|
+
query: async () => '',
|
|
104
|
+
limits: limits(8 * 1024 * 1024),
|
|
105
|
+
timeoutMs: 2000
|
|
106
|
+
});
|
|
107
|
+
} catch (error) {
|
|
108
|
+
failure = error;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
expect(failure).to.be.instanceOf(RlmLimitError);
|
|
112
|
+
expect(failure.limit).to.equal('sandboxMemoryBytes');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { expect } = require('chai');
|
|
4
|
+
const {
|
|
5
|
+
parseMarkdownDocument,
|
|
6
|
+
reconstructMarkdownDocument
|
|
7
|
+
} = require('..');
|
|
8
|
+
|
|
9
|
+
const FIXTURE_PATH = path.resolve(__dirname, 'fixtures/book.md');
|
|
10
|
+
|
|
11
|
+
describe('RLM Markdown document mapping', () => {
|
|
12
|
+
it('creates a stable semantic tree and preserves exact source order', async () => {
|
|
13
|
+
const source = fs.readFileSync(FIXTURE_PATH, 'utf8');
|
|
14
|
+
const document = await parseMarkdownDocument(source);
|
|
15
|
+
|
|
16
|
+
expect(document.format).to.equal('markdown');
|
|
17
|
+
expect(document.stats).to.include({
|
|
18
|
+
utf8Bytes: Buffer.byteLength(source, 'utf8'),
|
|
19
|
+
sectionCount: 3
|
|
20
|
+
});
|
|
21
|
+
expect(document.preamble.source).to.include('Introductory note');
|
|
22
|
+
expect(document.preamble.lists[0].items.map(item => item.text)).to.deep.equal([
|
|
23
|
+
'Preface item one',
|
|
24
|
+
'Preface item two'
|
|
25
|
+
]);
|
|
26
|
+
expect(document.sections).to.have.length(2);
|
|
27
|
+
expect(document.sections[0]).to.include({
|
|
28
|
+
id: 'chapter-one',
|
|
29
|
+
title: 'Chapter One',
|
|
30
|
+
depth: 1,
|
|
31
|
+
order: 0
|
|
32
|
+
});
|
|
33
|
+
expect(document.sections[0].path).to.deep.equal(['chapter-one']);
|
|
34
|
+
expect(document.sections[0].children[0]).to.include({
|
|
35
|
+
id: 'scene-one',
|
|
36
|
+
title: 'Scene One',
|
|
37
|
+
depth: 2,
|
|
38
|
+
order: 1
|
|
39
|
+
});
|
|
40
|
+
expect(document.sections[0].children[0].path).to.deep.equal([
|
|
41
|
+
'chapter-one',
|
|
42
|
+
'scene-one'
|
|
43
|
+
]);
|
|
44
|
+
expect(document.sections[1].id).to.equal('chapter-one-2');
|
|
45
|
+
expect(document.sections[0].body).to.include('Opening paragraph.');
|
|
46
|
+
expect(document.sections[0].lists[0].items[1].lists[0].items[0].text)
|
|
47
|
+
.to.equal('Nested point');
|
|
48
|
+
expect(document.sections[0].children[0].body)
|
|
49
|
+
.to.include('# This is code, not a chapter');
|
|
50
|
+
expect(document.sections[1].body).to.include('| Character | Role |');
|
|
51
|
+
expect(reconstructMarkdownDocument(document)).to.equal(source);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('maps heading-free Markdown to introductory content', async () => {
|
|
55
|
+
const source = 'Paragraph one.\n\n1. Alpha\n2. Beta\n';
|
|
56
|
+
const document = await parseMarkdownDocument(source);
|
|
57
|
+
|
|
58
|
+
expect(document.sections).to.deep.equal([]);
|
|
59
|
+
expect(document.preamble.source).to.equal(source);
|
|
60
|
+
expect(document.preamble.lists[0].items.map(item => item.text))
|
|
61
|
+
.to.deep.equal(['Alpha', 'Beta']);
|
|
62
|
+
expect(reconstructMarkdownDocument(document)).to.equal(source);
|
|
63
|
+
});
|
|
64
|
+
});
|