modelmix 5.2.0 → 5.2.2

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 CHANGED
@@ -193,6 +193,7 @@ ModelMix provides convenient shorthand methods for quickly accessing different A
193
193
  | `gemini35flash()` | Google | gemini-3.5-flash | [\$0.75][3] | [\$4.50][3] |
194
194
  | `gemini35flashLite()` | Google | gemini-3.5-flash-lite | [\$0.30][3] | [\$2.50][3] |
195
195
  | `gemini31flashLite()` | Google | gemini-3.1-flash-lite-preview | [\$0.25][3] | [\$1.50][3] |
196
+ | `grok47()` | Grok | grok-4.7 | — | — |
196
197
  | `grok46()` | Grok | grok-4.6 | [\$2.00][6] | [\$6.00][6] |
197
198
  | `grok45()` | Grok | grok-4.5 | [\$2.00][6] | [\$6.00][6] |
198
199
  | `grok43()` | Grok | grok-4.3 | [\$1.25][6] | [\$2.50][6] |
@@ -1000,6 +1001,33 @@ Supported policies are `'inherit'`, `'none'`, `{ include: [...] }`, and `{ exclu
1000
1001
 
1001
1002
  Child `systemFile` templates use the same EJS engine, `assign()` data contract, and relative Markdown includes as ordinary ModelMix templates. Use either `system` or `systemFile`, not both.
1002
1003
 
1004
+ Plugins may append `{ tool, callback }` entries to `context.request.tools` using the same definitions as `addTools()`. These tools are available for that execution and its tool continuations, alongside registered local/MCP tools. They do not change the instance's tool registry. Duplicate names are rejected before calling a provider. Child invocations rebuild their tools through the selected plugins or explicit `tools` input.
1005
+
1006
+ When plugins add tools, native `options.tools` entries are combined with registered and plugin tools; duplicate function names are rejected. OpenAI Responses converts function definitions and preserves tool calls, results, and accompanying reasoning across continuations.
1007
+
1008
+ ### Skills plugin
1009
+
1010
+ The included plugin loads local [Agent Skills](https://agentskills.io/specification). Pass explicit skill directories or `SKILL.md` files:
1011
+
1012
+ ```javascript
1013
+ import { ModelMix } from 'modelmix';
1014
+ import { skills } from 'modelmix/plugins/skills/index.js';
1015
+
1016
+ const model = ModelMix.new()
1017
+ .gpt6astra()
1018
+ .opus5()
1019
+ .use(await skills({ paths: ['./skills/writing', './skills/research/SKILL.md'] }))
1020
+ .addText('Use the writing skill to improve this paragraph: ...');
1021
+
1022
+ console.log(await model.message());
1023
+ ```
1024
+
1025
+ `skills()` is asynchronous. Each file must have YAML frontmatter with non-empty string `name` and `description` fields; duplicate names and malformed files fail during loading. Relative paths resolve from `process.cwd()`.
1026
+
1027
+ Only names and descriptions are appended to the system prompt. A model supporting tool calls can select a skill with `read_skill({ name })`, then load supporting UTF-8 text files with `read_skill({ name, path: 'references/style.md' })`. Full instructions are returned literally, including any EJS syntax. Existing system instructions and tools are preserved; `read_skill` is reserved while this plugin runs.
1028
+
1029
+ Skill metadata and `SKILL.md` content are snapshots taken when creating the plugin; recreate it to reload edits. Supporting files are read on demand. Reads must stay inside the registered skill directory, including resolved symlinks. Files are returned in full, so callers should register appropriately sized, trusted skills. The plugin does not execute scripts, install tools, or grant permissions from `allowed-tools` metadata. Skills requiring additional capabilities need tools supplied by the application.
1030
+
1003
1031
  ### Benchmark plugin
1004
1032
 
1005
1033
  The included benchmark plugin derives task-specific criteria, runs each configured model independently, and uses the other distinct models as anonymous judges. Model specifications use the same `shortcut@effort` syntax as `chain()`:
package/demo/benchmark.js CHANGED
@@ -43,7 +43,7 @@ await mkdir(resultsDirectory, { recursive: true });
43
43
  for (const result of report.results) {
44
44
  if (result.response === null) continue;
45
45
  const filename = result.id.replace(/[^A-Za-z0-9_-]/g, '_');
46
- const contents = `# ${result.id}\n\nScore: ${result.score === null ? 'N/A' : Number(result.score.toFixed(2))}\n\nEstimated generation cost (USD): ${result.responseMetrics?.cost ?? 'N/A'}\n\n---\n\n${result.response}\n`;
46
+ const contents = `# ${result.id}\n\nScore: ${result.score === null ? 'N/A' : Math.round(result.score * 10)}/100\n\nEstimated generation cost (USD): ${result.responseMetrics?.cost ?? 'N/A'}\n\n---\n\n${result.response}\n`;
47
47
  await writeFile(path.join(resultsDirectory, `${filename}.md`), contents, 'utf8');
48
48
  }
49
49
 
@@ -69,7 +69,7 @@ const ranking = report.results
69
69
  if (right.score === null) return -1;
70
70
  return right.score - left.score;
71
71
  })
72
- .map(row => ({ ...row, score: row.score === null ? null : Number(row.score.toFixed(2)) }));
72
+ .map(row => ({ ...row, score: row.score === null ? null : Math.round(row.score * 10) }));
73
73
 
74
74
  console.table(ranking);
75
75
  if (report.errors.length > 0) {
package/demo/grok.js CHANGED
@@ -13,7 +13,7 @@ const mmix = new ModelMix({
13
13
  });
14
14
 
15
15
 
16
- const r = await mmix.grok46()
16
+ const r = await mmix.grok47()
17
17
  .addText('hi there!')
18
18
  .addText('do you like cats?')
19
19
  .raw();
package/index.d.ts CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * Type definitions for modelmix
3
- * @see https://github.com/clasen/ModelMix
4
- */
5
-
6
1
  export type MessageRole = 'user' | 'assistant' | 'system' | 'tool' | string;
7
2
 
8
3
  export type DebugLevel = 0 | 1 | 2 | 3 | 4;
@@ -258,6 +253,7 @@ export interface PluginExecutionContext {
258
253
  request: {
259
254
  system: string;
260
255
  messages: ChatMessage[];
256
+ tools: ToolWithCallback[];
261
257
  options: ModelMixOptions;
262
258
  config: ModelMixConfig;
263
259
  outputMode: ModelMixOutputMode;
@@ -499,6 +495,7 @@ export declare class ModelMix {
499
495
  sonar(args?: ModelAttachArgs): this;
500
496
 
501
497
  // Grok
498
+ grok47(args?: ModelAttachArgs): this;
502
499
  grok46(args?: ModelAttachArgs): this;
503
500
  grok45(args?: ModelAttachArgs): this;
504
501
  grok43(args?: ModelAttachArgs): this;
package/index.js CHANGED
@@ -603,6 +603,9 @@ class ModelMix {
603
603
  return this.attach('sonar', new MixPerplexity({ options, config }));
604
604
  }
605
605
 
606
+ grok47({ options = {}, config = {} } = {}) {
607
+ return this.attach('grok-4.7', new MixGrok({ options, config }));
608
+ }
606
609
  grok46({ options = {}, config = {} } = {}) {
607
610
  return this.attach('grok-4.6', new MixGrok({ options, config }));
608
611
  }
@@ -1312,6 +1315,7 @@ class ModelMix {
1312
1315
  const request = {
1313
1316
  system: this._renderSystem(config, {}, systemSuffix, templateContext),
1314
1317
  messages: clonePluginValue(preparedMessages),
1318
+ tools: [],
1315
1319
  options: clonePluginValue({ ...this.options, ...options }),
1316
1320
  config: clonePluginValue(this._mergeRequestConfig(config)),
1317
1321
  outputMode
@@ -1383,15 +1387,34 @@ class ModelMix {
1383
1387
  templateContext
1384
1388
  }) {
1385
1389
  const provider = currentModel.provider;
1390
+ const tools = pluginRequest?.tools.length ? {
1391
+ ...this.tools,
1392
+ local: [...(this.tools.local || []), ...pluginRequest.tools.map(entry => entry.tool)]
1393
+ } : this.tools;
1394
+ const toolOptions = provider.getOptionsTools(tools);
1386
1395
  const currentOptions = {
1387
1396
  ...this.options,
1388
1397
  messages: preparedMessages,
1389
1398
  ...provider.options,
1390
- ...provider.getOptionsTools(this.tools),
1399
+ ...toolOptions,
1391
1400
  ...options,
1392
1401
  ...(pluginRequest?.options || {}),
1393
1402
  model: currentModel.key
1394
1403
  };
1404
+ if (pluginRequest?.tools.length && currentOptions.tools !== toolOptions.tools) {
1405
+ if (!Array.isArray(currentOptions.tools)) {
1406
+ throw new TypeError('Request options.tools must be an array when using plugin tools.');
1407
+ }
1408
+ currentOptions.tools = [...(toolOptions.tools || []), ...currentOptions.tools];
1409
+ const names = new Set();
1410
+ for (const tool of currentOptions.tools) {
1411
+ for (const definition of tool.functionDeclarations || [tool.function || tool]) {
1412
+ if (!definition.name) continue;
1413
+ if (names.has(definition.name)) throw new Error(`Duplicate tool name: ${definition.name}`);
1414
+ names.add(definition.name);
1415
+ }
1416
+ }
1417
+ }
1395
1418
  const currentConfig = pluginRequest
1396
1419
  ? {
1397
1420
  ...provider.config,
@@ -1512,7 +1535,7 @@ class ModelMix {
1512
1535
  result.tokens.speed = elapsedSec > 0 ? Math.round(result.tokens.output / elapsedSec) : 0;
1513
1536
  }
1514
1537
 
1515
- async _continueToolCalls(result, pluginRequest, execution) {
1538
+ async _continueToolCalls(result, pluginRequest, execution, pluginTools) {
1516
1539
  const originalMessages = this.messages;
1517
1540
  const toolMessages = pluginRequest
1518
1541
  ? clonePluginValue(pluginRequest.messages)
@@ -1540,7 +1563,7 @@ class ModelMix {
1540
1563
  if (!result.assistantMessage) {
1541
1564
  toolMessages.push({ role: 'assistant', content: null, tool_calls: result.toolCalls });
1542
1565
  }
1543
- const toolResults = await this.processToolCalls(result.toolCalls, execution.signal);
1566
+ const toolResults = await this.processToolCalls(result.toolCalls, execution.signal, pluginTools);
1544
1567
  for (const toolResult of toolResults) {
1545
1568
  toolMessages.push({
1546
1569
  role: 'tool',
@@ -1650,6 +1673,23 @@ class ModelMix {
1650
1673
  this._requirePreparedMessages(preparedMessages);
1651
1674
 
1652
1675
  const finalConfig = pluginRequest ? pluginRequest.config : this._mergeRequestConfig(config);
1676
+ const pluginTools = pluginRequest ? new MCPToolsManager() : null;
1677
+ if (pluginRequest) {
1678
+ if (!Array.isArray(pluginRequest.tools)) {
1679
+ throw new TypeError('Plugin request tools must be an array.');
1680
+ }
1681
+ const names = new Set(Object.values(this.tools).flat().map(tool => tool.name));
1682
+ for (const entry of pluginRequest.tools) {
1683
+ if (!isPlainObject(entry) || !isPlainObject(entry.tool)) {
1684
+ throw new TypeError('Plugin request tools must contain { tool, callback }.');
1685
+ }
1686
+ if (names.has(entry.tool.name)) {
1687
+ throw new Error(`Duplicate tool name: ${entry.tool.name}`);
1688
+ }
1689
+ pluginTools.registerTool(entry.tool, entry.callback);
1690
+ names.add(entry.tool.name);
1691
+ }
1692
+ }
1653
1693
  const modelsToTry = this.models.map((model, index) => ({ model, index }));
1654
1694
  if (finalConfig.roundRobin && this.models.length > 1) {
1655
1695
  this.models.push(this.models.shift());
@@ -1695,7 +1735,7 @@ class ModelMix {
1695
1735
  _templateContext: templateContext,
1696
1736
  _executionMetadata: executionMetadata,
1697
1737
  _pluginsApplied: pluginsApplied
1698
- });
1738
+ }, pluginTools);
1699
1739
  }
1700
1740
 
1701
1741
  this._logProviderSuccess(result, providerAttempt.currentConfig);
@@ -1771,7 +1811,7 @@ class ModelMix {
1771
1811
  if (isRootExecution) this._commitTemplateRenderContext(templateContext);
1772
1812
  return result;
1773
1813
  }
1774
- async processToolCalls(toolCalls, signal) {
1814
+ async processToolCalls(toolCalls, signal, pluginTools) {
1775
1815
  assertAbortSignal(signal);
1776
1816
  const result = []
1777
1817
 
@@ -1804,8 +1844,9 @@ class ModelMix {
1804
1844
  }
1805
1845
 
1806
1846
  // Verificar si es una herramienta local registrada
1807
- if (this.mcpToolsManager.hasTool(toolName)) {
1808
- const response = await this.mcpToolsManager.executeTool(toolName, toolArgs, signal);
1847
+ if (pluginTools?.hasTool(toolName) || this.mcpToolsManager.hasTool(toolName)) {
1848
+ const manager = pluginTools?.hasTool(toolName) ? pluginTools : this.mcpToolsManager;
1849
+ const response = await manager.executeTool(toolName, toolArgs, signal);
1809
1850
  throwIfAborted(signal);
1810
1851
  result.push({
1811
1852
  name: toolName,
@@ -9,7 +9,7 @@ const CHAIN_MODEL_SHORTCUTS = new Set([
9
9
  'sonnet50', 'sonnet5', 'sonnet46', 'sonnet45', 'haiku45',
10
10
  'gemini31pro', 'gemini38flash', 'gemini37flash', 'gemini36flash', 'gemini35flash',
11
11
  'gemini35flashLite', 'gemini31flashLite', 'sonarPro', 'sonar',
12
- 'grok46', 'grok45', 'grok43', 'grok420multiAgent', 'grok420',
12
+ 'grok47', 'grok46', 'grok45', 'grok43', 'grok420multiAgent', 'grok420',
13
13
  'museGlimmer30b', 'museSpark12', 'museSpark12c', 'museSpark13', 'museSpark13c',
14
14
  'qwen35397b', 'qwen36plus', 'qwen37plus', 'qwen38max', 'qwen3827b', 'qwen38flash',
15
15
  'hermes470b', 'hermes4405b', 'hermes3',
@@ -54,6 +54,17 @@ function createOpenAIProviders({
54
54
 
55
55
  if (options.reasoning_effort) request.reasoning = { effort: options.reasoning_effort };
56
56
  if (options.verbosity) request.text = { verbosity: options.verbosity };
57
+ if (options.tools !== undefined) {
58
+ request.tools = options.tools.map(tool => tool.type === 'function' && tool.function
59
+ ? { type: 'function', strict: false, ...tool.function }
60
+ : tool);
61
+ }
62
+ if (options.tool_choice !== undefined) {
63
+ request.tool_choice = options.tool_choice?.function
64
+ ? { type: 'function', name: options.tool_choice.function.name }
65
+ : options.tool_choice;
66
+ }
67
+ if (options.parallel_tool_calls !== undefined) request.parallel_tool_calls = options.parallel_tool_calls;
57
68
 
58
69
  if (options.response_format) {
59
70
  const rf = options.response_format;
@@ -138,10 +149,25 @@ function createOpenAIProviders({
138
149
  static processResponsesResponse(response) {
139
150
  MixCustom.assertResponse(response.data);
140
151
  const message = MixOpenAIResponses.extractResponsesMessage(response.data);
152
+ const toolCalls = (response.data.output || [])
153
+ .filter(item => item.type === 'function_call')
154
+ .map(item => ({
155
+ id: item.call_id,
156
+ type: 'function',
157
+ function: { name: item.name, arguments: item.arguments }
158
+ }));
141
159
  return {
142
160
  message,
143
161
  think: null,
144
- toolCalls: [],
162
+ toolCalls,
163
+ ...(toolCalls.length > 0 && {
164
+ assistantMessage: {
165
+ role: 'assistant',
166
+ content: message || null,
167
+ tool_calls: toolCalls,
168
+ _responsesOutput: response.data.output
169
+ }
170
+ }),
145
171
  tokens: MixOpenAIResponses.extractResponsesTokens(response.data),
146
172
  response: response.data
147
173
  };
@@ -179,7 +205,14 @@ function createOpenAIProviders({
179
205
 
180
206
  for (const message of messages) {
181
207
  if (!message || !message.role) continue;
182
- if (message.tool_calls || message.role === 'tool') continue;
208
+ if (message.role === 'assistant' && Array.isArray(message._responsesOutput)) {
209
+ mapped.push(...message._responsesOutput);
210
+ continue;
211
+ }
212
+ if (message.role === 'tool') {
213
+ mapped.push({ type: 'function_call_output', call_id: message.tool_call_id, output: message.content });
214
+ continue;
215
+ }
183
216
 
184
217
  const content = [];
185
218
  const isAssistant = message.role === 'assistant';
@@ -234,11 +267,18 @@ function createOpenAIProviders({
234
267
  }
235
268
  }
236
269
 
237
- if (content.length === 0) continue;
238
- mapped.push({
239
- role: message.role,
240
- content
241
- });
270
+ if (content.length > 0) {
271
+ mapped.push({ role: message.role, content });
272
+ }
273
+ for (const call of message.tool_calls || []) {
274
+ const args = call.function ? call.function.arguments : call.input ?? call.arguments ?? {};
275
+ mapped.push({
276
+ type: 'function_call',
277
+ call_id: call.id,
278
+ name: call.function ? call.function.name : call.name,
279
+ arguments: typeof args === 'string' ? args : JSON.stringify(args)
280
+ });
281
+ }
242
282
  }
243
283
 
244
284
  return mapped;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmix",
3
- "version": "5.2.0",
3
+ "version": "5.2.2",
4
4
  "description": "🧬 Reliable interface with automatic fallback for AI LLMs.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -56,6 +56,7 @@
56
56
  "bottleneck": "^2.19.5",
57
57
  "ejs": "6.0.1",
58
58
  "file-type": "^21.3.4",
59
+ "js-yaml": "4.3.2",
59
60
  "lemonlog": "^1.2.2",
60
61
  "ws": "^8.21.1"
61
62
  },
@@ -77,8 +78,9 @@
77
78
  "test:live.mcp": "mocha test/live.mcp.js --timeout 60000 --require test/setup.js",
78
79
  "test:tokens": "mocha test/tokens.test.js --timeout 10000 --require test/setup.js",
79
80
  "test:plugins": "mocha test/plugins.test.js --timeout 10000 --require test/setup.js",
81
+ "test:skills": "mocha plugins/skills/test/**/*.test.js --timeout 10000 --require test/setup.js",
80
82
  "test:benchmark": "mocha plugins/benchmark/test/**/*.test.js --timeout 10000 --require test/setup.js",
81
83
  "test:rlm": "mocha plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js",
82
- "test:offline": "mocha test/abort.test.js test/json.test.js test/fallback.test.js test/templates.test.js test/images.test.js test/bottleneck.test.js test/tokens.test.js test/history.test.js test/anthropic.test.js test/effort.test.js test/grok.test.js test/google.test.js test/moderation.test.js test/plugins.test.js plugins/benchmark/test/**/*.test.js plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js"
84
+ "test:offline": "mocha test/abort.test.js test/json.test.js test/fallback.test.js test/templates.test.js test/images.test.js test/bottleneck.test.js test/tokens.test.js test/history.test.js test/anthropic.test.js test/effort.test.js test/grok.test.js test/google.test.js test/moderation.test.js test/plugins.test.js plugins/skills/test/**/*.test.js plugins/benchmark/test/**/*.test.js plugins/rlm/test/**/*.test.js --timeout 10000 --require test/setup.js"
83
85
  }
84
86
  }
@@ -0,0 +1,9 @@
1
+ import type { ModelMixPlugin } from '../..';
2
+
3
+ export interface SkillsOptions {
4
+ /** Explicit skill directories or SKILL.md files, resolved relative to process.cwd(). */
5
+ paths: string[];
6
+ }
7
+
8
+ /** Load local skill metadata and expose instructions and references through read_skill. */
9
+ export declare function skills(options: SkillsOptions): Promise<ModelMixPlugin>;
@@ -0,0 +1,107 @@
1
+ const fs = require('node:fs/promises');
2
+ const path = require('node:path');
3
+ const yaml = require('js-yaml');
4
+
5
+ async function resolveFile(root, relativePath, signal) {
6
+ signal?.throwIfAborted();
7
+ if (typeof relativePath !== 'string' || !relativePath || path.isAbsolute(relativePath)) {
8
+ throw new TypeError('Skill file path must be a non-empty relative path.');
9
+ }
10
+ const filename = await fs.realpath(path.resolve(root, relativePath));
11
+ const relative = path.relative(root, filename);
12
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
13
+ throw new Error('Skill file path must stay inside the skill directory.');
14
+ }
15
+ if (!(await fs.stat(filename)).isFile()) {
16
+ throw new Error('Skill file path must point to a regular file.');
17
+ }
18
+ return filename;
19
+ }
20
+
21
+ async function readText(filename, signal) {
22
+ const buffer = await fs.readFile(filename, { signal });
23
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
24
+ if (text.includes('\0')) throw new Error('Skill files must contain UTF-8 text.');
25
+ return text;
26
+ }
27
+
28
+ function parseSkill(source, filename) {
29
+ const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
30
+ if (!match) throw new Error(`Missing YAML frontmatter in ${filename}.`);
31
+ const metadata = yaml.load(match[1], { schema: yaml.JSON_SCHEMA, filename });
32
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
33
+ throw new Error(`Skill frontmatter must be a mapping in ${filename}.`);
34
+ }
35
+ for (const field of ['name', 'description']) {
36
+ if (typeof metadata[field] !== 'string' || !metadata[field].trim()) {
37
+ throw new Error(`Skill ${field} must be a non-empty string in ${filename}.`);
38
+ }
39
+ }
40
+ return { name: metadata.name, description: metadata.description, content: source };
41
+ }
42
+
43
+ async function skills({ paths } = {}) {
44
+ if (!Array.isArray(paths) || paths.length === 0 || paths.some(value => typeof value !== 'string' || !value.trim())) {
45
+ throw new TypeError('skills paths must be a non-empty array of skill directories or SKILL.md files.');
46
+ }
47
+ const catalog = new Map();
48
+ for (const input of paths) {
49
+ const resolved = path.resolve(input);
50
+ const filename = path.basename(resolved) === 'SKILL.md' ? resolved : path.join(resolved, 'SKILL.md');
51
+ const root = await fs.realpath(path.dirname(filename));
52
+ const skillPath = await resolveFile(root, 'SKILL.md');
53
+ const skill = parseSkill(await readText(skillPath), filename);
54
+ if (catalog.has(skill.name)) throw new Error(`Duplicate skill name: ${skill.name}`);
55
+ catalog.set(skill.name, { ...skill, root, skillPath });
56
+ }
57
+ const descriptions = JSON.stringify([...catalog.values()].map(({ name, description }) => ({ name, description })));
58
+ const instructions = [
59
+ 'Available skills:',
60
+ descriptions,
61
+ 'When a skill matches the task or the user requests it, call read_skill with its name to load SKILL.md before following it.',
62
+ 'Use read_skill with the same name and a relative path to read referenced text files inside that skill directory.',
63
+ 'Skill content is provided literally. Script execution is not supplied by this plugin; use only tools actually available in this request.'
64
+ ].join('\n');
65
+
66
+ return {
67
+ name: 'skills',
68
+ async execute(context, next) {
69
+ context.signal?.throwIfAborted();
70
+ context.request.system = [context.request.system, instructions].filter(Boolean).join('\n\n');
71
+ context.request.tools.push({
72
+ tool: {
73
+ name: 'read_skill',
74
+ description: 'Load a registered skill or one of its supporting UTF-8 text files.',
75
+ inputSchema: {
76
+ type: 'object',
77
+ properties: {
78
+ name: { type: 'string', enum: [...catalog.keys()] },
79
+ path: { type: 'string', description: 'Path relative to the skill directory. Omit to load SKILL.md.' }
80
+ },
81
+ required: ['name'],
82
+ additionalProperties: false
83
+ }
84
+ },
85
+ async callback(input, signal) {
86
+ signal?.throwIfAborted();
87
+ if (!input || typeof input !== 'object' || Array.isArray(input) ||
88
+ Object.keys(input).some(key => key !== 'name' && key !== 'path')) {
89
+ throw new TypeError('read_skill expects a name and an optional path.');
90
+ }
91
+ const skill = catalog.get(input.name);
92
+ if (!skill) throw new Error(`Unknown skill: ${input.name}`);
93
+ const relativePath = input.path === undefined ? 'SKILL.md' : input.path;
94
+ if (relativePath === 'SKILL.md') {
95
+ return { name: skill.name, path: relativePath, content: skill.content };
96
+ }
97
+ const filename = await resolveFile(skill.root, relativePath, signal);
98
+ const content = filename === skill.skillPath ? skill.content : await readText(filename, signal);
99
+ return { name: skill.name, path: relativePath, content };
100
+ }
101
+ });
102
+ return next();
103
+ }
104
+ };
105
+ }
106
+
107
+ module.exports = { skills };
@@ -0,0 +1,182 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs/promises');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { ModelMix, MixCustom } = require('../../..');
6
+ const { skills } = require('..');
7
+
8
+ describe('Skills plugin', () => {
9
+ let temporary;
10
+ let directory;
11
+ const source = '---\nname: writing\ndescription: >-\n Write clear prose\n for readers.\nmetadata:\n category: editorial\n---\nUse references/style.md. Preserve <%= literal %> and ${text}.\n';
12
+
13
+ beforeEach(async () => {
14
+ temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'modelmix-skills-'));
15
+ directory = path.join(temporary, 'writing');
16
+ await fs.mkdir(path.join(directory, 'references'), { recursive: true });
17
+ await fs.writeFile(path.join(directory, 'SKILL.md'), source);
18
+ await fs.writeFile(path.join(directory, 'references/style.md'), 'Use concrete verbs.');
19
+ });
20
+
21
+ afterEach(async () => {
22
+ await fs.rm(temporary, { recursive: true, force: true });
23
+ });
24
+
25
+ async function prepare(plugin) {
26
+ const request = { system: 'Existing system', tools: [] };
27
+ await plugin.execute({ request }, async () => ({ message: 'ok' }));
28
+ return request;
29
+ }
30
+
31
+ it('loads only metadata into the prompt and exposes literal skill content on demand', async () => {
32
+ const plugin = await skills({ paths: [directory] });
33
+ const request = await prepare(plugin);
34
+ assert.ok(request.system.startsWith('Existing system\n\n'));
35
+ assert.ok(request.system.includes('Write clear prose for readers.'));
36
+ assert.ok(!request.system.includes('Preserve <%= literal %>'));
37
+ const result = await request.tools[0].callback({ name: 'writing' });
38
+ assert.deepEqual(result, { name: 'writing', path: 'SKILL.md', content: source });
39
+ });
40
+
41
+ it('supports SKILL.md paths and reads references relative to the skill root', async () => {
42
+ const request = await prepare(await skills({ paths: [path.join(directory, 'SKILL.md')] }));
43
+ assert.deepEqual(await request.tools[0].callback({ name: 'writing', path: 'references/style.md' }), {
44
+ name: 'writing', path: 'references/style.md', content: 'Use concrete verbs.'
45
+ });
46
+ });
47
+
48
+ it('returns the loaded snapshot for every path that resolves to SKILL.md', async () => {
49
+ await fs.symlink(path.join(directory, 'SKILL.md'), path.join(directory, 'alias.md'));
50
+ const request = await prepare(await skills({ paths: [directory] }));
51
+ await fs.writeFile(path.join(directory, 'SKILL.md'), '---\nname: writing\ndescription: Changed\n---\nChanged body.\n');
52
+ for (const relative of ['./SKILL.md', 'references/../SKILL.md', 'alias.md']) {
53
+ assert.deepEqual(await request.tools[0].callback({ name: 'writing', path: relative }), {
54
+ name: 'writing', path: relative, content: source
55
+ });
56
+ }
57
+ });
58
+
59
+ it('reads supporting files from disk on every call', async () => {
60
+ const request = await prepare(await skills({ paths: [directory] }));
61
+ await fs.writeFile(path.join(directory, 'references/style.md'), 'Use strong verbs.');
62
+ assert.deepEqual(await request.tools[0].callback({ name: 'writing', path: 'references/style.md' }), {
63
+ name: 'writing', path: 'references/style.md', content: 'Use strong verbs.'
64
+ });
65
+ });
66
+
67
+ it('returns the snapshot for SKILL.md aliases without decoding the changed file', async () => {
68
+ await fs.symlink(path.join(directory, 'SKILL.md'), path.join(directory, 'alias.md'));
69
+ const request = await prepare(await skills({ paths: [directory] }));
70
+ await fs.writeFile(path.join(directory, 'SKILL.md'), Buffer.from([0, 255, 128]));
71
+ for (const relative of ['./SKILL.md', 'alias.md']) {
72
+ assert.deepEqual(await request.tools[0].callback({ name: 'writing', path: relative }), {
73
+ name: 'writing', path: relative, content: source
74
+ });
75
+ }
76
+ });
77
+
78
+ it('runs the complete tool loop without re-rendering skill text or changing instance configuration', async () => {
79
+ const requests = [];
80
+ const provider = new MixCustom();
81
+ provider.create = async request => {
82
+ requests.push(request);
83
+ if (requests.length === 1) return {
84
+ message: '', toolCalls: [{ id: 'skill', name: 'read_skill', input: { name: 'writing' } }]
85
+ };
86
+ if (requests.length === 2) return {
87
+ message: '', toolCalls: [{ id: 'reference', name: 'read_skill', input: { name: 'writing', path: 'references/style.md' } }]
88
+ };
89
+ return { message: '{"answer":"Concrete verbs"}', toolCalls: [] };
90
+ };
91
+ const model = ModelMix.new({ config: { system: 'Be concise.' } })
92
+ .attach('custom', provider).use(await skills({ paths: [directory] })).addText('Use writing.');
93
+ assert.deepEqual(await model.json(), { answer: 'Concrete verbs' });
94
+ assert.equal(requests.length, 3);
95
+ for (const request of requests) {
96
+ assert.equal(request.config.system.split('Available skills:').length, 2);
97
+ assert.equal(request.options.tools[0].function.name, 'read_skill');
98
+ }
99
+ const outputs = requests[2].options.messages.filter(message => message.role === 'tool').map(message => JSON.parse(message.content));
100
+ assert.equal(outputs[0].content, source);
101
+ assert.equal(outputs[1].content, 'Use concrete verbs.');
102
+ assert.equal(model.config.max_history, 0);
103
+ assert.equal(model.config.system, 'Be concise.');
104
+ assert.deepEqual(model.tools, {});
105
+ assert.deepEqual(model.messages, []);
106
+ });
107
+
108
+ it('supports inherited plugins without leaking tools into sibling or later requests', async () => {
109
+ const provider = new MixCustom();
110
+ const requests = [];
111
+ provider.create = async request => {
112
+ requests.push(request);
113
+ return { message: 'ok', toolCalls: [] };
114
+ };
115
+ const model = ModelMix.new().attach('custom', provider).use(await skills({ paths: [directory] }));
116
+ await model.new().addText('child').message();
117
+ await model.addText('first').message();
118
+ await model.addText('second').message();
119
+ await ModelMix.new().attach('custom', provider).addText('sibling').message();
120
+ for (const request of requests.slice(0, 3)) assert.equal(request.options.tools.length, 1);
121
+ assert.equal(requests[3].options.tools, undefined);
122
+ });
123
+
124
+ it('rejects missing files, missing metadata, malformed YAML, and duplicate names', async () => {
125
+ await assert.rejects(skills({ paths: [path.join(temporary, 'missing')] }), /ENOENT/);
126
+ for (const content of ['No frontmatter', '---\nname: writing\n---\nBody', '---\nname: [broken\n---\nBody', '---\nname: writing\nname: duplicate\ndescription: text\n---\nBody']) {
127
+ await fs.writeFile(path.join(directory, 'SKILL.md'), content);
128
+ await assert.rejects(skills({ paths: [directory] }));
129
+ }
130
+ await fs.writeFile(path.join(directory, 'SKILL.md'), source);
131
+ await assert.rejects(skills({ paths: [directory, directory] }), /Duplicate skill name/);
132
+ });
133
+
134
+ it('validates configuration and tool arguments', async () => {
135
+ for (const options of [undefined, {}, { paths: [] }, { paths: [''] }, { paths: 'directory' }]) {
136
+ await assert.rejects(skills(options), /paths must be/);
137
+ }
138
+ const request = await prepare(await skills({ paths: [directory] }));
139
+ const read = request.tools[0].callback;
140
+ await assert.rejects(read({ name: 'missing' }), /Unknown skill/);
141
+ await assert.rejects(read({ name: 'writing', extra: true }), /expects a name/);
142
+ for (const value of ['', null, 42, directory]) {
143
+ await assert.rejects(read({ name: 'writing', path: value }), /relative path/);
144
+ }
145
+ });
146
+
147
+ it('blocks traversal, prefix siblings, and symlinks outside the skill directory', async () => {
148
+ await fs.writeFile(path.join(temporary, 'outside.md'), 'outside');
149
+ const sibling = path.join(temporary, 'writing-other');
150
+ await fs.mkdir(sibling);
151
+ await fs.writeFile(path.join(sibling, 'file.md'), 'sibling');
152
+ await fs.symlink(path.join(temporary, 'outside.md'), path.join(directory, 'linked.md'));
153
+ const request = await prepare(await skills({ paths: [directory] }));
154
+ for (const relative of ['../outside.md', '../writing-other/file.md', 'linked.md']) {
155
+ await assert.rejects(request.tools[0].callback({ name: 'writing', path: relative }), /inside the skill directory/);
156
+ }
157
+ });
158
+
159
+ it('blocks SKILL.md symlinks outside the registered directory', async () => {
160
+ await fs.writeFile(path.join(temporary, 'outside.md'), source);
161
+ await fs.unlink(path.join(directory, 'SKILL.md'));
162
+ await fs.symlink(path.join(temporary, 'outside.md'), path.join(directory, 'SKILL.md'));
163
+ await assert.rejects(skills({ paths: [directory] }), /inside the skill directory/);
164
+ });
165
+
166
+ it('rejects directories and binary resources', async () => {
167
+ await fs.writeFile(path.join(directory, 'binary'), Buffer.from([0, 255, 128]));
168
+ const request = await prepare(await skills({ paths: [directory] }));
169
+ await assert.rejects(request.tools[0].callback({ name: 'writing', path: 'references' }), /regular file/);
170
+ await assert.rejects(request.tools[0].callback({ name: 'writing', path: 'binary' }));
171
+ });
172
+
173
+ it('propagates cancellation before middleware and resource reads', async () => {
174
+ const plugin = await skills({ paths: [directory] });
175
+ const request = await prepare(plugin);
176
+ const controller = new AbortController();
177
+ const reason = new Error('cancelled');
178
+ controller.abort(reason);
179
+ await assert.rejects(plugin.execute({ request, signal: controller.signal }, () => assert.fail('next called')), error => error === reason);
180
+ await assert.rejects(request.tools[0].callback({ name: 'writing', path: 'references/style.md' }, controller.signal), error => error === reason);
181
+ });
182
+ });
@@ -16,8 +16,8 @@ overrides:
16
16
  brace-expansion: 5.0.9
17
17
  diff: 8.0.4
18
18
  fast-uri: 3.1.6
19
- hono: 4.12.34
19
+ hono: 4.13.5
20
20
  ip-address: 10.3.1
21
- js-yaml: 4.3.1
21
+ js-yaml: 4.3.2
22
22
  qs: 6.16.0
23
23
  serialize-javascript: 7.0.5
@@ -121,6 +121,27 @@ model.use({
121
121
 
122
122
  The optional `@modelmix/rlm` package is a separate workspace/npm package for recursive processing of large structured inputs. Pass Markdown through `documents: { name: { format: 'markdown', content } }`, register named ModelMix worker chains, and provide every runtime limit explicitly. A worker uses either `model: anotherModelMixInstance` or `useParent: true`. Its planner sees content-free variable size/shape metadata, while document values and generated orchestration code stay inside an `isolated-vm` sandbox. RLM planner prompts are Markdown files rendered with the normal child `assign` plus `systemFile` path.
123
123
 
124
+ ### Loading local skills
125
+
126
+ Use the included skills plugin to expose local `SKILL.md` instructions to a model with tool-call support:
127
+
128
+ ```javascript
129
+ import { ModelMix } from 'modelmix';
130
+ import { skills } from 'modelmix/plugins/skills/index.js';
131
+ const model = ModelMix.new()
132
+ .gpt6astra()
133
+ .opus5()
134
+ .use(await skills({ paths: ['./skills/writing'] }))
135
+ .addText('Use the writing skill to revise this paragraph: ...');
136
+ const answer = await model.message();
137
+ ```
138
+
139
+ Paths identify explicit skill directories or `SKILL.md` files relative to the working directory. YAML `name` and `description` must be non-empty strings. The system prompt receives only the catalog; `read_skill({ name })` loads instructions and `read_skill({ name, path })` reads a supporting UTF-8 file inside that skill directory. Instructions are literal, never EJS-rendered. Skill metadata and instructions are snapshots; recreate the plugin to reload them. References are read on demand. The plugin supplies no script execution or automatic permissions from skill metadata.
140
+
141
+ Plugin tools use `context.request.tools.push({ tool, callback })`. They coexist with local/MCP tools, are scoped to the current execution, and survive tool continuations. Duplicate names fail before a provider call; the skills plugin reserves `read_skill`. Child executions receive the tools only when the plugin is inherited or the tools are explicitly passed to `context.invoke()`.
142
+
143
+ With plugin tools, native `options.tools` entries are combined with registered and plugin tools. OpenAI Responses supports this tool loop, including reasoning returned with function calls.
144
+
124
145
  ### Unified effort
125
146
 
126
147
  Provider-agnostic reasoning intensity. **Not** an `options` field — use `config.effort` or `.effort(n)`.
@@ -166,7 +187,7 @@ Use `.effort(n)` (or `config.effort`) to enable Anthropic thinking — e.g. `.ef
166
187
  `gemini31pro()` `gemini38flash()` `gemini37flash()` `gemini36flash()` `gemini35flash()` `gemini35flashLite()` `gemini31flashLite()`
167
188
 
168
189
  ### Grok
169
- `grok46()` `grok45()` `grok43()` `grok420multiAgent()` `grok420()`
190
+ `grok47()` `grok46()` `grok45()` `grok43()` `grok420multiAgent()` `grok420()`
170
191
 
171
192
  ### Perplexity
172
193
  `sonar()` `sonarPro()`
package/test/grok.test.js CHANGED
@@ -11,6 +11,7 @@ const {
11
11
 
12
12
  describe('Grok Model Registration Tests', () => {
13
13
  const grokModels = [
14
+ { method: 'grok47', key: 'grok-4.7' },
14
15
  { method: 'grok46', key: 'grok-4.6' },
15
16
  { method: 'grok45', key: 'grok-4.5' },
16
17
  { method: 'grok43', key: 'grok-4.3' },
@@ -38,6 +39,38 @@ describe('Grok Model Registration Tests', () => {
38
39
  expect(model.models[0].provider.config).to.include(config);
39
40
  });
40
41
 
42
+ it('supports Grok 4.7 in chain() and preserves attachment options', () => {
43
+ const options = { temperature: 0.5 };
44
+ const config = { max_history: 3 };
45
+ const model = ModelMix.new().grok47({ options, config });
46
+
47
+ expect(model.models[0].provider).to.be.instanceOf(MixGrok);
48
+ expect(model.models[0].provider.options).to.deep.equal(options);
49
+ expect(model.models[0].provider.config).to.include(config);
50
+ expect(ModelMix.new().chain('grok47').models[0].key).to.equal('grok-4.7');
51
+ });
52
+
53
+ it('sends Grok 4.7 requests to xAI', async () => {
54
+ const api = nock('https://api.x.ai')
55
+ .post('/v1/chat/completions', body => {
56
+ expect(body.model).to.equal('grok-4.7');
57
+ expect(body.temperature).to.equal(0.5);
58
+ return true;
59
+ })
60
+ .reply(200, {
61
+ choices: [{ message: { content: 'ok' } }],
62
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }
63
+ });
64
+
65
+ const response = await ModelMix.new()
66
+ .grok47({ options: { temperature: 0.5 }, config: { apiKey: 'test-key' } })
67
+ .addText('Hi')
68
+ .message();
69
+
70
+ expect(response).to.equal('ok');
71
+ api.done();
72
+ });
73
+
41
74
  it('maps unified effort to Grok 4.6 supported levels', () => {
42
75
  expect(mapEffort('openai', 0, 'grok-4.6')).to.deep.equal({ reasoning_effort: 'low' });
43
76
  expect(mapEffort('openai', 39, 'grok-4.6')).to.deep.equal({ reasoning_effort: 'low' });
@@ -1,6 +1,8 @@
1
1
  const { expect } = require('chai');
2
2
  const path = require('path');
3
- const { MixCustom, ModelMix } = require('../index.js');
3
+ const nock = require('nock');
4
+ const { MixCustom, MixOpenAIResponses, MixAnthropic, MixGoogle, ModelMix } = require('../index.js');
5
+ const { skills } = require('../plugins/skills');
4
6
 
5
7
  function createProvider(handler = async () => ({ message: 'provider', toolCalls: [] })) {
6
8
  const provider = new MixCustom();
@@ -9,6 +11,153 @@ function createProvider(handler = async () => ({ message: 'provider', toolCalls:
9
11
  }
10
12
 
11
13
  describe('ModelMix plugins', () => {
14
+ it('runs a native Responses skill loop including reasoning and parallel tool outputs', async () => {
15
+ const requests = [];
16
+ const output = [
17
+ { type: 'reasoning', id: 'rs_skill', summary: [], encrypted_content: 'opaque-reasoning' },
18
+ { type: 'message', id: 'msg_skill', role: 'assistant', content: [{ type: 'output_text', text: 'Reading the skill.' }] },
19
+ { type: 'function_call', id: 'fc_skill', call_id: 'call_skill', name: 'read_skill', arguments: JSON.stringify({ name: 'modelmix' }) },
20
+ { type: 'function_call', id: 'fc_local', call_id: 'call_local', name: 'local_tool', arguments: '{"value":7}' }
21
+ ];
22
+ const scope = nock('https://api.openai.com')
23
+ .post('/v1/responses', body => { requests.push(body); return true; })
24
+ .reply(200, { output })
25
+ .post('/v1/responses', body => { requests.push(body); return true; })
26
+ .reply(200, { output: [{ type: 'message', content: [{ type: 'output_text', text: 'Done.' }] }] });
27
+ try {
28
+ const model = ModelMix.new({ config: { bottleneck: { minTime: 0 } } }).gpt6astra()
29
+ .addTool({ name: 'local_tool', description: 'Local tool', inputSchema: { type: 'object' } }, input => `value=${input.value}`)
30
+ .use(await skills({ paths: [path.join(__dirname, '../skills/modelmix')] }))
31
+ .addText('Use the modelmix skill.');
32
+ expect(await model.message()).to.equal('Done.');
33
+ expect(scope.isDone()).to.equal(true);
34
+ for (const request of requests) {
35
+ expect(request.tools.map(tool => tool.name)).to.have.members(['local_tool', 'read_skill']);
36
+ const skillTool = request.tools.find(tool => tool.name === 'read_skill');
37
+ expect(skillTool.strict).to.equal(false);
38
+ expect(skillTool.parameters.required).to.deep.equal(['name']);
39
+ }
40
+ expect(requests[1].input.slice(2, 6)).to.deep.equal(output);
41
+ const results = requests[1].input.filter(item => item.type === 'function_call_output');
42
+ expect(results.map(item => item.call_id)).to.deep.equal(['call_skill', 'call_local']);
43
+ expect(JSON.parse(results[0].output).content).to.include('name: modelmix');
44
+ expect(results[1].output).to.equal('value=7');
45
+ } finally {
46
+ nock.cleanAll();
47
+ }
48
+ });
49
+
50
+ it('converts neutral tool history and native Responses tool options', () => {
51
+ const request = MixOpenAIResponses.buildResponsesRequest({
52
+ tools: [{ type: 'web_search' }, { type: 'function', function: { name: 'lookup', parameters: { type: 'object' }, strict: true } }],
53
+ tool_choice: { type: 'function', function: { name: 'lookup' } },
54
+ parallel_tool_calls: false,
55
+ messages: [
56
+ { role: 'assistant', content: 'Checking.', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'lookup', arguments: '{"query":"test"}' } }] },
57
+ { role: 'tool', tool_call_id: 'call_1', content: 'Found.' }
58
+ ]
59
+ });
60
+ expect(request.tools).to.deep.equal([{ type: 'web_search' }, { type: 'function', name: 'lookup', parameters: { type: 'object' }, strict: true }]);
61
+ expect(request.tool_choice).to.deep.equal({ type: 'function', name: 'lookup' });
62
+ expect(request.parallel_tool_calls).to.equal(false);
63
+ expect(request.input).to.deep.equal([
64
+ { role: 'assistant', content: [{ type: 'output_text', text: 'Checking.' }] },
65
+ { type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{"query":"test"}' },
66
+ { type: 'function_call_output', call_id: 'call_1', output: 'Found.' }
67
+ ]);
68
+ });
69
+
70
+ for (const Provider of [MixCustom, MixAnthropic, MixGoogle]) {
71
+ for (const hasExplicitTool of [false, true]) {
72
+ it(`preserves plugin and registered tools with ${Provider.name} options.tools (${hasExplicitTool ? 'populated' : 'empty'})`, async () => {
73
+ const registered = { name: 'registered', description: 'Registered tool', inputSchema: { type: 'object' } };
74
+ const extra = { ...registered, name: 'explicit' };
75
+ const provider = new Provider();
76
+ const explicitTools = hasExplicitTool ? provider.getOptionsTools({ local: [extra] }).tools : [];
77
+ let received;
78
+ provider.create = async ({ options }) => { received = options.tools; return { message: 'done', toolCalls: [] }; };
79
+ const model = ModelMix.new({ options: { tools: explicitTools } }).attach('custom', provider)
80
+ .addTool(registered, () => 'registered')
81
+ .use(await skills({ paths: [path.join(__dirname, '../skills/modelmix')] })).addText('Use skills');
82
+ await model.message();
83
+ const names = received.flatMap(tool => tool.functionDeclarations || [tool.function || tool]).map(tool => tool.name);
84
+ expect(names).to.have.members(['registered', 'read_skill', ...(hasExplicitTool ? ['explicit'] : [])]);
85
+ expect(model.options.tools).to.deep.equal(explicitTools);
86
+ });
87
+ }
88
+ }
89
+
90
+ it('rejects collisions between explicit options and plugin tools before calling the provider', async () => {
91
+ let calls = 0;
92
+ const model = ModelMix.new({ options: { tools: [{ type: 'function', function: { name: 'read_skill' } }] } })
93
+ .attach('custom', createProvider(async () => { calls++; return { message: 'unexpected' }; }))
94
+ .use(await skills({ paths: [path.join(__dirname, '../skills/modelmix')] })).addText('test');
95
+ let failure;
96
+ try { await model.message(); } catch (error) { failure = error; }
97
+ expect(failure?.message).to.include('Duplicate tool name: read_skill');
98
+ expect(calls).to.equal(0);
99
+ });
100
+
101
+ it('keeps plugin tools request-scoped across tool continuations and alongside local tools', async () => {
102
+ const requests = [];
103
+ const signal = new AbortController().signal;
104
+ const provider = createProvider(async request => {
105
+ requests.push(request);
106
+ if (requests.length === 1) return {
107
+ message: '',
108
+ toolCalls: [
109
+ { id: 'skill', name: 'read_skill', input: {} },
110
+ { id: 'local', name: 'local_tool', input: {} }
111
+ ]
112
+ };
113
+ return { message: 'done', toolCalls: [] };
114
+ });
115
+ const model = ModelMix.new().attach('custom', provider)
116
+ .addTool({ name: 'local_tool', description: 'Local tool', inputSchema: { type: 'object' } }, () => 'local')
117
+ .use({
118
+ name: 'skills-test',
119
+ async execute(context, next) {
120
+ context.request.tools.push({
121
+ tool: { name: 'read_skill', description: 'Read skill', inputSchema: { type: 'object' } },
122
+ callback: (_args, callbackSignal) => {
123
+ expect(callbackSignal).to.equal(signal);
124
+ return 'skill instructions';
125
+ }
126
+ });
127
+ return next();
128
+ }
129
+ }).addText('Use both tools');
130
+
131
+ expect(await model.message(signal)).to.equal('done');
132
+ expect(requests).to.have.length(2);
133
+ for (const request of requests) {
134
+ expect(request.options.tools.map(tool => tool.function.name)).to.have.members(['local_tool', 'read_skill']);
135
+ }
136
+ const results = requests[1].options.messages.filter(message => message.role === 'tool');
137
+ expect(results.map(result => result.content)).to.deep.equal(['skill instructions', 'local']);
138
+ expect(model.mcpToolsManager.hasTool('read_skill')).to.equal(false);
139
+ expect(model.tools.local.map(tool => tool.name)).to.deep.equal(['local_tool']);
140
+ });
141
+
142
+ it('rejects collisions between plugin tools and existing tools before calling a provider', async () => {
143
+ let calls = 0;
144
+ const tool = { name: 'same', description: 'Same tool', inputSchema: { type: 'object' } };
145
+ const model = ModelMix.new().attach('custom', createProvider(async () => {
146
+ calls += 1;
147
+ return { message: 'unexpected' };
148
+ })).addTool(tool, () => 'local').use({
149
+ name: 'collision',
150
+ async execute(context, next) {
151
+ context.request.tools.push({ tool, callback: () => 'plugin' });
152
+ return next();
153
+ }
154
+ }).addText('test');
155
+ let failure;
156
+ try { await model.message(); } catch (error) { failure = error; }
157
+ expect(failure?.message).to.include('Duplicate tool name: same');
158
+ expect(calls).to.equal(0);
159
+ });
160
+
12
161
  it('keeps registration instance-scoped and lets new instances inherit plugins without history', () => {
13
162
  const plugin = { name: 'metrics', execute: (_context, next) => next() };
14
163
  const parent = ModelMix.new().use(plugin).addText('parent history');