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.
Files changed (39) hide show
  1. package/README.md +121 -7
  2. package/RLM_PLUGIN_SPEC.md +465 -0
  3. package/demo/gemini.js +3 -4
  4. package/demo/moderation.js +17 -0
  5. package/demo/short.js +1 -1
  6. package/effort.js +2 -0
  7. package/index.d.ts +107 -1
  8. package/index.js +443 -46
  9. package/package.json +4 -2
  10. package/plugins/rlm/index.d.ts +194 -0
  11. package/plugins/rlm/index.js +25 -0
  12. package/plugins/rlm/lib/budget.js +153 -0
  13. package/plugins/rlm/lib/isolated-vm-sandbox.js +90 -0
  14. package/plugins/rlm/lib/markdown.js +156 -0
  15. package/plugins/rlm/lib/planner-prompt.js +137 -0
  16. package/plugins/rlm/lib/plugin.js +203 -0
  17. package/plugins/rlm/lib/runtime.js +146 -0
  18. package/plugins/rlm/lib/variable-descriptors.js +228 -0
  19. package/plugins/rlm/lib/worker-catalog.js +70 -0
  20. package/plugins/rlm/package.json +32 -0
  21. package/plugins/rlm/prompts/partials/processing-rules.md +8 -0
  22. package/plugins/rlm/prompts/planner.md +53 -0
  23. package/plugins/rlm/test/budget.test.js +86 -0
  24. package/plugins/rlm/test/fixtures/book.md +24 -0
  25. package/plugins/rlm/test/isolated-vm-sandbox.test.js +114 -0
  26. package/plugins/rlm/test/markdown.test.js +64 -0
  27. package/plugins/rlm/test/planner-template.test.js +140 -0
  28. package/plugins/rlm/test/plugin-contract.test.js +182 -0
  29. package/plugins/rlm/test/rlm-e2e.test.js +338 -0
  30. package/plugins/rlm/test/variable-descriptors.test.js +170 -0
  31. package/plugins/rlm/test/worker-catalog.test.js +104 -0
  32. package/pnpm-workspace.yaml +6 -0
  33. package/skills/modelmix/SKILL.md +26 -4
  34. package/test/effort.test.js +14 -1
  35. package/test/live.mcp.js +6 -6
  36. package/test/live.test.js +2 -2
  37. package/test/moderation.test.js +135 -0
  38. package/test/plugins.test.js +356 -0
  39. package/test/tokens.test.js +37 -5
@@ -0,0 +1,137 @@
1
+ const path = require('path');
2
+ const { describeVariables } = require('./variable-descriptors');
3
+
4
+ const PLANNER_SYSTEM_TEMPLATE = path.resolve(__dirname, '../prompts/planner.md');
5
+
6
+ function positiveInteger(value, name) {
7
+ if (!Number.isInteger(value) || value <= 0) {
8
+ throw new TypeError(`${name} must be a positive integer.`);
9
+ }
10
+ return value;
11
+ }
12
+
13
+ function planningHint(name, descriptor, maxQueryBytes) {
14
+ const payloadBytes = descriptor.utf8Bytes ?? descriptor.estimatedBytes;
15
+ if (payloadBytes <= maxQueryBytes) {
16
+ return {
17
+ path: name,
18
+ strategy: 'direct',
19
+ reason: 'The complete serialized value fits within one query payload.'
20
+ };
21
+ }
22
+ if (descriptor.type === 'string') {
23
+ return {
24
+ path: name,
25
+ strategy: 'split-string-semantically',
26
+ boundary: 'paragraph',
27
+ reason: 'The string is larger than the maximum query payload.'
28
+ };
29
+ }
30
+ if (descriptor.type === 'array') {
31
+ const oversizedFields = [];
32
+ for (const [field, fieldDescriptor] of Object.entries(descriptor.itemShape.properties || {})) {
33
+ if (fieldDescriptor.stringSize?.utf8Bytes.max > maxQueryBytes) oversizedFields.push(field);
34
+ }
35
+ if (descriptor.itemSize.max > maxQueryBytes) {
36
+ return {
37
+ path: name,
38
+ strategy: 'split-oversized-items-semantically',
39
+ boundary: 'paragraph',
40
+ oversizedStringFields: oversizedFields,
41
+ reason: 'At least one array item is larger than the maximum query payload.'
42
+ };
43
+ }
44
+ const averageBytes = Math.max(1, descriptor.itemSize.average);
45
+ return {
46
+ path: name,
47
+ strategy: 'batch-array-items',
48
+ suggestedMaxItemsPerQuery: Math.max(1, Math.floor(maxQueryBytes / averageBytes)),
49
+ reason: 'The array is larger than one query payload while individual items fit.'
50
+ };
51
+ }
52
+ return {
53
+ path: name,
54
+ strategy: 'select-properties-or-descendants',
55
+ reason: 'The structured value is larger than the maximum query payload.'
56
+ };
57
+ }
58
+
59
+ function plannerTemplateData({
60
+ variables,
61
+ limits,
62
+ workerManifest,
63
+ outputMode = 'raw',
64
+ outputSchema = null
65
+ }) {
66
+ if (!limits || typeof limits !== 'object' || Array.isArray(limits)) {
67
+ throw new TypeError('limits must be a plain object.');
68
+ }
69
+ const maxQueryBytes = positiveInteger(limits.maxQueryBytes, 'limits.maxQueryBytes');
70
+ const sandboxMemoryBytes = positiveInteger(limits.sandboxMemoryBytes, 'limits.sandboxMemoryBytes');
71
+ const maxConcurrentQueries = positiveInteger(
72
+ limits.maxConcurrentQueries,
73
+ 'limits.maxConcurrentQueries'
74
+ );
75
+ const manifest = describeVariables(variables);
76
+ const planningHints = Object.entries(manifest.descriptors)
77
+ .map(([name, descriptor]) => planningHint(name, descriptor, maxQueryBytes));
78
+ if (!workerManifest || typeof workerManifest !== 'object' || Array.isArray(workerManifest)) {
79
+ throw new TypeError('workerManifest must be a plain object.');
80
+ }
81
+ if (!['message', 'json', 'block', 'raw'].includes(outputMode)) {
82
+ throw new TypeError(`Unsupported RLM planner output mode "${outputMode}".`);
83
+ }
84
+
85
+ return {
86
+ variableManifest: JSON.stringify(manifest, null, 2),
87
+ processingLimits: JSON.stringify({
88
+ maxQueryBytes,
89
+ sandboxMemoryBytes,
90
+ maxConcurrentQueries
91
+ }, null, 2),
92
+ planningHints: JSON.stringify(planningHints, null, 2),
93
+ workerManifest: JSON.stringify(workerManifest, null, 2),
94
+ outputRequirements: JSON.stringify({
95
+ mode: outputMode,
96
+ schema: outputSchema
97
+ }, null, 2),
98
+ maxQueryBytes,
99
+ maxConcurrentQueries
100
+ };
101
+ }
102
+
103
+ function createPlannerInvocation({
104
+ task,
105
+ variables,
106
+ limits,
107
+ workerManifest,
108
+ outputMode = 'raw',
109
+ outputSchema = null
110
+ }) {
111
+ if (typeof task !== 'string' || task.trim().length === 0) {
112
+ throw new TypeError('task must be a non-empty string.');
113
+ }
114
+ return {
115
+ systemFile: PLANNER_SYSTEM_TEMPLATE,
116
+ assign: plannerTemplateData({
117
+ variables,
118
+ limits,
119
+ workerManifest,
120
+ outputMode,
121
+ outputSchema
122
+ }),
123
+ messages: [{
124
+ role: 'user',
125
+ content: [{ type: 'text', text: task }]
126
+ }],
127
+ plugins: { exclude: ['rlm'] },
128
+ history: false,
129
+ outputMode: 'raw'
130
+ };
131
+ }
132
+
133
+ module.exports = {
134
+ PLANNER_SYSTEM_TEMPLATE,
135
+ createPlannerInvocation,
136
+ plannerTemplateData
137
+ };
@@ -0,0 +1,203 @@
1
+ const { RlmLimitError, validateRuntimeLimits } = require('./budget');
2
+ const { createIsolatedVmSandbox } = require('./isolated-vm-sandbox');
3
+ const { parseMarkdownDocument } = require('./markdown');
4
+ const { createPlannerInvocation } = require('./planner-prompt');
5
+ const {
6
+ RlmExecutionState,
7
+ createQueryRuntime,
8
+ requestTask,
9
+ sumTokens
10
+ } = require('./runtime');
11
+ const { createWorkerCatalog } = require('./worker-catalog');
12
+
13
+ function isPlainObject(value) {
14
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
15
+ const prototype = Object.getPrototypeOf(value);
16
+ return prototype === Object.prototype || prototype === null;
17
+ }
18
+
19
+ function validateSandbox(sandbox) {
20
+ if (!sandbox || typeof sandbox !== 'object' || typeof sandbox.execute !== 'function') {
21
+ throw new TypeError('sandbox must define execute({ code, variables, query, limits }).');
22
+ }
23
+ return sandbox;
24
+ }
25
+
26
+ function validateDocuments(documents) {
27
+ if (documents === undefined) return {};
28
+ if (!isPlainObject(documents)) {
29
+ throw new TypeError('documents must be a plain object.');
30
+ }
31
+ for (const [name, document] of Object.entries(documents)) {
32
+ if (!isPlainObject(document)) {
33
+ throw new TypeError(`documents.${name} must be a plain object.`);
34
+ }
35
+ if (document.format !== 'markdown') {
36
+ throw new TypeError(`documents.${name}.format must be "markdown".`);
37
+ }
38
+ if (typeof document.content !== 'string') {
39
+ throw new TypeError(`documents.${name}.content must be a string.`);
40
+ }
41
+ }
42
+ return documents;
43
+ }
44
+
45
+ async function createExternalVariables(variables, documents) {
46
+ const result = { ...variables };
47
+ for (const [name, document] of Object.entries(documents)) {
48
+ if (Object.prototype.hasOwnProperty.call(result, name)) {
49
+ throw new TypeError(`External variable "${name}" is defined more than once.`);
50
+ }
51
+ result[name] = await parseMarkdownDocument(document.content);
52
+ }
53
+ return result;
54
+ }
55
+
56
+ function validateGeneratedCode(code) {
57
+ if (typeof code !== 'string' || code.trim().length === 0) {
58
+ throw new TypeError('RLM planner must return non-empty JavaScript code.');
59
+ }
60
+ if (/```|~~~/.test(code)) {
61
+ throw new SyntaxError('RLM planner returned Markdown fences instead of raw JavaScript.');
62
+ }
63
+ return code.trim();
64
+ }
65
+
66
+ function serializeResult(value) {
67
+ if (typeof value === 'string') return value;
68
+ let serialized;
69
+ try {
70
+ serialized = JSON.stringify(value);
71
+ } catch (error) {
72
+ throw new TypeError(`RLM sandbox result must be JSON-serializable: ${error.message}`);
73
+ }
74
+ if (serialized === undefined) {
75
+ throw new TypeError('RLM sandbox result must be JSON-serializable.');
76
+ }
77
+ return serialized;
78
+ }
79
+
80
+ function executionInput(context, configuredVariables) {
81
+ const invocation = context.request.config.rlmInvocation;
82
+ if (invocation !== undefined) {
83
+ if (!isPlainObject(invocation) || !(invocation.state instanceof RlmExecutionState)) {
84
+ throw new TypeError('Invalid internal RLM invocation state.');
85
+ }
86
+ return {
87
+ state: invocation.state,
88
+ task: invocation.task,
89
+ variables: invocation.variables
90
+ };
91
+ }
92
+ return {
93
+ state: null,
94
+ task: requestTask(context.request),
95
+ variables: configuredVariables
96
+ };
97
+ }
98
+
99
+ function runWithTimeout(operation, timeoutMs) {
100
+ let timeout;
101
+ return Promise.race([
102
+ operation(),
103
+ new Promise((_, reject) => {
104
+ timeout = setTimeout(() => reject(new RlmLimitError(
105
+ 'maxWallTimeMs',
106
+ 'RLM wall-time limit exceeded.'
107
+ )), timeoutMs);
108
+ })
109
+ ]).finally(() => clearTimeout(timeout));
110
+ }
111
+
112
+ function rlm({ maxDepth, variables = {}, documents, workers, limits, sandbox } = {}) {
113
+ if (!Number.isInteger(maxDepth) || maxDepth < 0) {
114
+ throw new TypeError('maxDepth must be a non-negative integer.');
115
+ }
116
+ if (!isPlainObject(variables)) {
117
+ throw new TypeError('variables must be a plain object.');
118
+ }
119
+ const validatedDocuments = validateDocuments(documents);
120
+ const catalog = createWorkerCatalog(workers);
121
+ const validatedLimits = validateRuntimeLimits(limits);
122
+ const sandboxAdapter = validateSandbox(
123
+ sandbox === undefined ? createIsolatedVmSandbox() : sandbox
124
+ );
125
+ const configuredVariables = createExternalVariables(variables, validatedDocuments);
126
+
127
+ return {
128
+ name: 'rlm',
129
+ async execute(context) {
130
+ if (context.request.outputMode === 'stream') {
131
+ throw new Error('RLM streaming is not supported; use a buffered output mode.');
132
+ }
133
+ const input = executionInput(context, await configuredVariables);
134
+ if (typeof input.task !== 'string' || input.task.trim().length === 0) {
135
+ throw new TypeError('RLM execution requires a non-empty task.');
136
+ }
137
+ if (!isPlainObject(input.variables)) {
138
+ throw new TypeError('RLM execution variables must be a plain object.');
139
+ }
140
+ const state = input.state || new RlmExecutionState(validatedLimits);
141
+ try {
142
+ const plannerStartedAt = Date.now();
143
+ const plannerResult = await state.budget.runPlanner(() => context.invoke(
144
+ createPlannerInvocation({
145
+ task: input.task,
146
+ variables: input.variables,
147
+ limits: validatedLimits,
148
+ workerManifest: catalog.manifest,
149
+ outputMode: context.request.outputMode,
150
+ outputSchema: context.request.config.schema || null
151
+ })
152
+ ));
153
+ state.record('planner', plannerResult, {
154
+ worker: null,
155
+ elapsedMs: Date.now() - plannerStartedAt
156
+ });
157
+ const code = validateGeneratedCode(plannerResult.message);
158
+ const query = createQueryRuntime({
159
+ context,
160
+ catalog,
161
+ maxDepth,
162
+ state
163
+ });
164
+ const timeoutMs = Math.max(
165
+ 1,
166
+ validatedLimits.maxWallTimeMs - state.budget.snapshot().elapsedMs
167
+ );
168
+ const value = await runWithTimeout(
169
+ () => sandboxAdapter.execute({
170
+ code,
171
+ variables: input.variables,
172
+ query,
173
+ limits: validatedLimits,
174
+ execution: context.execution,
175
+ timeoutMs
176
+ }),
177
+ timeoutMs
178
+ );
179
+ const message = serializeResult(value);
180
+ state.budget.accountFinalOutput(message);
181
+ return {
182
+ message,
183
+ tokens: sumTokens(state.calls),
184
+ rlm: state.diagnostics(context.execution)
185
+ };
186
+ } catch (error) {
187
+ error.rlm = state.diagnostics(
188
+ context.execution,
189
+ error.limit ? `limit:${error.limit}` : 'error'
190
+ );
191
+ throw error;
192
+ }
193
+ }
194
+ };
195
+ }
196
+
197
+ module.exports = {
198
+ createExternalVariables,
199
+ rlm,
200
+ serializeResult,
201
+ validateDocuments,
202
+ validateGeneratedCode
203
+ };
@@ -0,0 +1,146 @@
1
+ const { createRuntimeBudget } = require('./budget');
2
+
3
+ function textContent(content) {
4
+ if (typeof content === 'string') return content;
5
+ if (!Array.isArray(content)) return '';
6
+ return content
7
+ .filter(part => part?.type === 'text' && typeof part.text === 'string')
8
+ .map(part => part.text)
9
+ .join('\n');
10
+ }
11
+
12
+ function requestTask(request) {
13
+ return request.messages
14
+ .filter(message => message?.role === 'user')
15
+ .map(message => textContent(message.content))
16
+ .filter(Boolean)
17
+ .join('\n\n');
18
+ }
19
+
20
+ function queryPayloadBytes({ system, message }) {
21
+ return Buffer.byteLength(JSON.stringify({ system, message }), 'utf8');
22
+ }
23
+
24
+ function validateQueryInput(input) {
25
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
26
+ throw new TypeError('query() expects a plain object.');
27
+ }
28
+ if (typeof input.worker !== 'string' || input.worker.length === 0) {
29
+ throw new TypeError('query.worker must be a non-empty string.');
30
+ }
31
+ if (typeof input.system !== 'string' || input.system.trim().length === 0) {
32
+ throw new TypeError('query.system must be a non-empty string.');
33
+ }
34
+ if (typeof input.message !== 'string') {
35
+ throw new TypeError('query.message must be a string.');
36
+ }
37
+ return input;
38
+ }
39
+
40
+ function sumTokens(calls) {
41
+ const totals = {
42
+ input: 0,
43
+ output: 0,
44
+ total: 0,
45
+ cached: 0,
46
+ cacheWrite: 0,
47
+ cacheWrite5m: 0,
48
+ cacheWrite1h: 0,
49
+ uncachedInput: 0,
50
+ cost: 0
51
+ };
52
+ let found = false;
53
+ for (const call of calls) {
54
+ const tokens = call.tokens;
55
+ if (!tokens) continue;
56
+ found = true;
57
+ for (const key of Object.keys(totals)) {
58
+ if (Number.isFinite(tokens[key])) totals[key] += tokens[key];
59
+ }
60
+ }
61
+ return found ? totals : undefined;
62
+ }
63
+
64
+ class RlmExecutionState {
65
+ constructor(limits) {
66
+ this.budget = createRuntimeBudget(limits);
67
+ this.calls = [];
68
+ }
69
+
70
+ record(kind, result, details = {}, { includeTokens = true } = {}) {
71
+ this.calls.push({
72
+ kind,
73
+ ...details,
74
+ execution: result.execution || null,
75
+ tokens: includeTokens ? (result.tokens || null) : null
76
+ });
77
+ }
78
+
79
+ diagnostics(rootExecution, terminationReason = 'completed') {
80
+ return {
81
+ execution: rootExecution,
82
+ calls: this.calls.map(call => ({ ...call })),
83
+ budget: this.budget.snapshot(),
84
+ terminationReason
85
+ };
86
+ }
87
+ }
88
+
89
+ function createQueryRuntime({ context, catalog, maxDepth, state }) {
90
+ return async rawInput => {
91
+ const startedAt = Date.now();
92
+ const input = validateQueryInput(rawInput);
93
+ const model = catalog.get(input.worker);
94
+ const directLeaf = context.execution.depth >= maxDepth;
95
+ const payloadBytes = queryPayloadBytes(input);
96
+ state.budget.assertQueryPayload(payloadBytes);
97
+ const invocation = directLeaf
98
+ ? {
99
+ model,
100
+ system: input.system,
101
+ messages: [{ role: 'user', content: input.message }],
102
+ plugins: { exclude: ['rlm'] },
103
+ history: false,
104
+ outputMode: 'raw'
105
+ }
106
+ : {
107
+ model,
108
+ messages: [{ role: 'user', content: input.system }],
109
+ config: {
110
+ rlmInvocation: {
111
+ state,
112
+ task: input.system,
113
+ variables: { input: input.message }
114
+ },
115
+ },
116
+ plugins: 'inherit',
117
+ history: false,
118
+ outputMode: 'raw'
119
+ };
120
+ const result = directLeaf
121
+ ? await state.budget.runQuery(
122
+ { payloadBytes },
123
+ () => context.invoke(invocation)
124
+ )
125
+ : await context.invoke(invocation);
126
+ if (typeof result.message !== 'string') {
127
+ throw new TypeError(`RLM worker "${input.worker}" must return a message string.`);
128
+ }
129
+ state.record('worker', result, {
130
+ worker: input.worker,
131
+ directLeaf,
132
+ depthBoundary: directLeaf ? maxDepth : null,
133
+ elapsedMs: Date.now() - startedAt
134
+ }, { includeTokens: directLeaf });
135
+ return result.message;
136
+ };
137
+ }
138
+
139
+ module.exports = {
140
+ RlmExecutionState,
141
+ createQueryRuntime,
142
+ queryPayloadBytes,
143
+ requestTask,
144
+ sumTokens,
145
+ validateQueryInput
146
+ };