From 2f06d1441b0cf00161488c35a6857597a5f83ac1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 4 Jun 2026 14:19:52 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=AA=A7=20chore:=20Generalize=20Projec?= =?UTF-8?q?t=20Name=20Placeholder=20(#13514)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/locales/en/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 4281dc8573ee..b236162bd9ff 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1359,7 +1359,7 @@ "com_ui_project_create_error": "Failed to create project", "com_ui_project_delete_error": "Failed to delete project", "com_ui_project_name": "Project name", - "com_ui_project_name_placeholder": "Copenhagen Trip", + "com_ui_project_name_placeholder": "New project", "com_ui_project_not_found": "Project not found", "com_ui_project_rename_error": "Failed to rename project", "com_ui_project_update_error": "Failed to update project assignment", From 7d0feadc903b5c56ed84956ce81a4f5d26d52e4d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 4 Jun 2026 14:32:18 -0400 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=A6=20chore:=20npm=20audit=20fix?= =?UTF-8?q?=20(#13515)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgraded @langchain/langgraph from 1.3.2 to 1.3.4 - Upgraded @langchain/langgraph-checkpoint from 1.0.2 to 1.0.4 - Upgraded @langchain/langgraph-sdk from 1.9.4 to 1.9.15 - Updated uuid from 10.0.0 to 14.0.0 across multiple packages - Upgraded @langchain/protocol from 0.0.15 to 0.0.16 - Upgraded @remix-run/router from 1.23.2 to 1.23.3 - Upgraded hono from 4.12.18 to 4.12.23 - Upgraded react-router from 6.30.3 to 6.30.4 --- package-lock.json | 92 +++++++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2f681bbfe0f6..8c9220c6b7b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11358,16 +11358,16 @@ } }, "node_modules/@langchain/langgraph": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.3.2.tgz", - "integrity": "sha512-SL7Ktsr681R7da+1b2MVOWEbaCoFJOXEJPTGOjg4JIG4C7quWbTYC8DzxhcCxte6D/8cGp0rYDBnbKLXEpNqlA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.3.4.tgz", + "integrity": "sha512-i5nlhy2INcX326sTJ+dAkvSe+sYO8DoGHn4OwxcI7U6OSm/n8xek24yvL5ahX09M1PES9hfooKz1lYn+cyPULw==", "license": "MIT", "dependencies": { - "@langchain/langgraph-checkpoint": "^1.0.2", - "@langchain/langgraph-sdk": "~1.9.4", - "@langchain/protocol": "^0.0.15", + "@langchain/langgraph-checkpoint": "^1.0.4", + "@langchain/langgraph-sdk": "~1.9.12", + "@langchain/protocol": "^0.0.16", "@standard-schema/spec": "1.1.0", - "uuid": "^10.0.0" + "uuid": "^14.0.0" }, "engines": { "node": ">=18" @@ -11384,12 +11384,12 @@ } }, "node_modules/@langchain/langgraph-checkpoint": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.2.tgz", - "integrity": "sha512-F4E5Tr0nt8FGghgdscJtHw+ABzChOHeI80R7Y1pjIHdiJom6c2ieo76vL+FWiny80JmoGqhrVAEIWrw0cXKPxg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.4.tgz", + "integrity": "sha512-1y5MgZ0gXXrtmoy56e3kaBChI3GwFPIKl27xkrHwN+VE/3iUsyr9gO3Jtp7kdKAe6diZGbcas5bdC/r0yUwTZA==", "license": "MIT", "dependencies": { - "uuid": "^10.0.0" + "uuid": "^14.0.0" }, "engines": { "node": ">=18" @@ -11399,30 +11399,29 @@ } }, "node_modules/@langchain/langgraph-checkpoint/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/@langchain/langgraph-sdk": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.4.tgz", - "integrity": "sha512-hhASJGKa2MDJDtDkuIFdWGysMTog/HkYe0r6B6Gn1XqsURWnF7FIFl9diITAPOv1tB8YpyjnbpsBj/NkT5d+jQ==", + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.15.tgz", + "integrity": "sha512-QUW9c7ikycmTkpr/eQYkKA4gpriqizG1OJ6H2YLbDOVkEYLSFnJeTO4MG6pBBO8EqHHa8m1zavKi5ug259VSQQ==", "license": "MIT", "dependencies": { - "@langchain/protocol": "^0.0.15", + "@langchain/protocol": "^0.0.16", "@types/json-schema": "^7.0.15", "p-queue": "^9.0.1", "p-retry": "^7.1.1", - "uuid": "^13.0.0" + "uuid": "^14.0.0" }, "peerDependencies": { "@langchain/core": "^1.1.44", @@ -11475,9 +11474,9 @@ } }, "node_modules/@langchain/langgraph-sdk/node_modules/uuid": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", - "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -11488,17 +11487,16 @@ } }, "node_modules/@langchain/langgraph/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/@langchain/mistralai": { @@ -11564,9 +11562,9 @@ } }, "node_modules/@langchain/protocol": { - "version": "0.0.15", - "resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.15.tgz", - "integrity": "sha512-MllvbpMjqHevUm+v94M422mH7XKN+wGCvJRBVROTWBotEDOATYB4Ktk2UheYP859y9o2LlhtPek5t1T9eyfAbQ==", + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.16.tgz", + "integrity": "sha512-ws+J7MaHyhO5dG7f0vdyHQiUn9hoCnki0f3crJPa4MCTGzcRC39jYSCghyrGtBPYQnZbUQiGyRVpW3z3M8IpJg==", "license": "MIT" }, "node_modules/@langchain/textsplitters": { @@ -17327,9 +17325,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -28037,9 +28035,9 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -38007,12 +38005,12 @@ } }, "node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2" + "@remix-run/router": "1.23.3" }, "engines": { "node": ">=14.0.0" @@ -38022,13 +38020,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2", - "react-router": "6.30.3" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" }, "engines": { "node": ">=14.0.0" From 44ed7864fb545e8f6d9f9a032a46484e2263caf1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 4 Jun 2026 18:36:16 -0400 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=93=9C=20feat:=20Improve=20Skill=20Au?= =?UTF-8?q?thoring=20Guidance=20(#13517)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Improve skill authoring guidance * test: Guard tool description lengths * fix: Align skill template guidance * fix: Satisfy advisory limit test lint * fix: Transform LangGraph ESM in Jest --- api/jest.config.js | 20 +++++- api/package.json | 1 + package-lock.json | 1 + packages/api/jest.config.mjs | 8 +++ packages/api/src/agents/tools.spec.ts | 67 ++++++++++++++++++- packages/api/src/agents/tools.ts | 19 +++--- .../data-schemas/src/methods/skill.spec.ts | 2 + packages/data-schemas/src/methods/skill.ts | 2 + 8 files changed, 109 insertions(+), 11 deletions(-) diff --git a/api/jest.config.js b/api/jest.config.js index 47f8b7287bf2..07588ea45544 100644 --- a/api/jest.config.js +++ b/api/jest.config.js @@ -1,3 +1,13 @@ +const esModules = [ + 'openid-client', + 'oauth4webapi', + 'jose', + '@langchain/langgraph', + '@langchain/langgraph-checkpoint', + '@langchain/langgraph-sdk', + 'uuid', +].join('|'); + module.exports = { testEnvironment: 'node', clearMocks: true, @@ -12,5 +22,13 @@ module.exports = { '^openid-client/passport$': '/test/__mocks__/openid-client-passport.js', '^openid-client$': '/test/__mocks__/openid-client.js', }, - transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'], + transform: { + '\\.[jt]sx?$': [ + 'babel-jest', + { + presets: [['@babel/preset-env', { targets: { node: 'current' } }]], + }, + ], + }, + transformIgnorePatterns: [`/node_modules/(?!(${esModules})/).*/`], }; diff --git a/api/package.json b/api/package.json index 652cec9ccd36..91c0b8e16044 100644 --- a/api/package.json +++ b/api/package.json @@ -131,6 +131,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@babel/preset-env": "^7.29.5", "@types/sanitize-html": "^2.13.0", "jest": "^30.2.0", "mongodb-memory-server": "^11.0.1", diff --git a/package-lock.json b/package-lock.json index 8c9220c6b7b8..7931480bc6b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -146,6 +146,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@babel/preset-env": "^7.29.5", "@types/sanitize-html": "^2.13.0", "jest": "^30.2.0", "mongodb-memory-server": "^11.0.1", diff --git a/packages/api/jest.config.mjs b/packages/api/jest.config.mjs index b0029fe1a658..c49f4cc7cfaf 100644 --- a/packages/api/jest.config.mjs +++ b/packages/api/jest.config.mjs @@ -1,3 +1,10 @@ +const esModules = [ + '@langchain/langgraph', + '@langchain/langgraph-checkpoint', + '@langchain/langgraph-sdk', + 'uuid', +].join('|'); + export default { collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!/node_modules/'], coveragePathIgnorePatterns: ['/node_modules/', '/dist/'], @@ -23,6 +30,7 @@ export default { }, ], }, + transformIgnorePatterns: [`/node_modules/(?!(${esModules})/).*/`], moduleNameMapper: { '^@src/(.*)$': '/src/$1', '~/(.*)': '/src/$1', diff --git a/packages/api/src/agents/tools.spec.ts b/packages/api/src/agents/tools.spec.ts index 3a0306d0f57d..570eba170899 100644 --- a/packages/api/src/agents/tools.spec.ts +++ b/packages/api/src/agents/tools.spec.ts @@ -5,7 +5,6 @@ * Mirrors the same pattern used in `__tests__/skills.test.ts`. */ jest.mock('@librechat/agents', () => ({ - ...jest.requireActual('@librechat/agents'), CODE_EXECUTION_TOOLS: new Set(['execute_code', 'bash_tool']), ReadFileToolDefinition: { name: 'read_file', @@ -51,6 +50,9 @@ import { isCodeSessionToolName, } from './tools'; +/** Portable ceiling for OpenAI-compatible tool description validators. */ +const TOOL_DESCRIPTION_ADVISORY_MAX_LENGTH = 1024; + function filePathDescription(tool?: LCTool): string { const parameters = tool?.parameters as | { properties?: { file_path?: { description?: string } } } @@ -58,6 +60,13 @@ function filePathDescription(tool?: LCTool): string { return parameters?.properties?.file_path?.description ?? ''; } +function maxToolDescriptionLength(definitions: LCTool[]): number { + return definitions.reduce((max, definition) => { + const length = definition.description?.length ?? Number.POSITIVE_INFINITY; + return Math.max(max, length); + }, 0); +} + describe('buildToolSet', () => { describe('event-driven mode (toolDefinitions)', () => { it('builds toolSet from toolDefinitions when available', () => { @@ -274,6 +283,30 @@ describe('registerCodeExecutionTools', () => { const names = result.toolDefinitions.map((d) => d.name); expect(names).toEqual(['calculator', 'read_file', 'bash_tool']); }); + + it('keeps code-execution tool descriptions within provider advisory limits', () => { + const skillAwareWithRefs = registerCodeExecutionTools({ + toolRegistry: makeRegistry(), + toolDefinitions: [], + includeBash: true, + includeSkillFileInstructions: true, + enableToolOutputReferences: true, + }); + const codeOnlyWithoutRefs = registerCodeExecutionTools({ + toolRegistry: makeRegistry(), + toolDefinitions: [], + includeBash: true, + includeSkillFileInstructions: false, + enableToolOutputReferences: false, + }); + + expect( + maxToolDescriptionLength([ + ...skillAwareWithRefs.toolDefinitions, + ...codeOnlyWithoutRefs.toolDefinitions, + ]), + ).toBeLessThanOrEqual(TOOL_DESCRIPTION_ADVISORY_MAX_LENGTH); + }); }); describe('idempotence (second call in same run)', () => { @@ -469,7 +502,12 @@ describe('registerFileAuthoringTools', () => { expect(result.toolDefinitions[0].responseFormat).toBe('content_and_artifact'); expect(result.toolDefinitions.map((d) => d.description).join('\n')).toContain('skills/'); expect(toolRegistry.get('create_file')?.description).toContain('frontmatter name must match'); + expect(toolRegistry.get('create_file')?.description).toContain('trigger-friendly'); + expect(toolRegistry.get('create_file')?.description).toContain('references/template.html'); + expect(toolRegistry.get('create_file')?.description).toContain('templates/{file}'); expect(toolRegistry.get('edit_file')?.description).toContain('edit_file cannot rename skills'); + expect(toolRegistry.get('edit_file')?.description).toContain('Keep SKILL.md concise'); + expect(toolRegistry.get('edit_file')?.description).toContain('templates/'); expect(filePathDescription(toolRegistry.get('create_file'))).toContain( 'frontmatter name must match', ); @@ -537,6 +575,33 @@ describe('registerFileAuthoringTools', () => { expect(toolRegistry.get('edit_file')?.description).toContain('skills/'); }); + it('keeps file-authoring tool descriptions within provider advisory limits', () => { + const skillAware = registerFileAuthoringTools({ + toolRegistry: makeRegistry(), + toolDefinitions: [], + includeSkillFileInstructions: true, + }); + const codeOnlyRegistry = makeRegistry(); + const codeOnly = registerFileAuthoringTools({ + toolRegistry: codeOnlyRegistry, + toolDefinitions: [], + includeSkillFileInstructions: false, + }); + const upgraded = registerFileAuthoringTools({ + toolRegistry: codeOnlyRegistry, + toolDefinitions: codeOnly.toolDefinitions, + includeSkillFileInstructions: true, + }); + + expect( + maxToolDescriptionLength([ + ...skillAware.toolDefinitions, + ...codeOnly.toolDefinitions, + ...upgraded.toolDefinitions, + ]), + ).toBeLessThanOrEqual(TOOL_DESCRIPTION_ADVISORY_MAX_LENGTH); + }); + it('distinguishes host file authoring definitions from user tools with matching names', () => { const result = registerFileAuthoringTools({ toolRegistry: makeRegistry(), diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts index b86047cba7f1..f554dd3df649 100644 --- a/packages/api/src/agents/tools.ts +++ b/packages/api/src/agents/tools.ts @@ -263,17 +263,18 @@ const CODE_EDIT_FILE_PARAMETERS: LCTool['parameters'] = Object.freeze({ const SKILL_CREATE_FILE_DESCRIPTION = `Create a new file, or overwrite an existing file with explicit intent. -Use for new files and full rewrites where the change is larger than half the file. Requires overwrite: true to replace existing files. Refuses otherwise. +Use for new files and full rewrites. Requires overwrite: true to replace existing files. -Paths starting with "skills/" target the skill file system: -- skills/{skillName}/SKILL.md - the skill's main instruction file -- skills/{skillName}/references/{file} - supporting reference files -- skills/{skillName}/scripts/{file} - helper scripts -- skills/{skillName}/templates/{file} - output templates +Paths starting with "skills/" write skill files: +- skills/{skillName}/SKILL.md - main instructions; keep it lean with YAML frontmatter, trigger-friendly description, workflow steps, and short snippets. +- skills/{skillName}/references/{file} - long docs, schemas, examples, large templates, HTML/CSS/JS dashboards. +- skills/{skillName}/scripts/{file} - helper scripts. +- skills/{skillName}/assets/{file} - static assets. +- skills/{skillName}/templates/{file} - reusable output templates. -For skills/{skillName}/SKILL.md, YAML frontmatter name must match {skillName}. To use a different skill name, create a new skills/{newName}/SKILL.md instead. +For SKILL.md, frontmatter name must match {skillName}; create skills/{newName}/SKILL.md to rename. Put large runnable artifacts in bundled files such as references/template.html, and have SKILL.md tell the agent when to read or reuse them. -When code execution is enabled, non-skills paths target the code-execution sandbox. Prefer /mnt/data/{file} for files that should remain available to later sandbox calls.`; +Non-skills paths target the code-execution sandbox when enabled. Prefer /mnt/data/{file}.`; const CODE_CREATE_FILE_DESCRIPTION = `Create a new file, or overwrite an existing file with explicit intent. @@ -285,7 +286,7 @@ const SKILL_EDIT_FILE_DESCRIPTION = `Apply targeted text replacements to an exis Use for small, precise changes. Each old_text must match exactly one location. Tries exact match first; falls back to whitespace-tolerant matching if needed. Reports which matching strategy was used. Returns a unified diff. -For skills/{skillName}/SKILL.md, edit description, title, or body content, but keep YAML frontmatter name equal to {skillName}. edit_file cannot rename skills; create a new skills/{newName}/SKILL.md for a different skill name. +For skills/{skillName}/SKILL.md, edit description, title, or body content, but keep YAML frontmatter name equal to {skillName}. edit_file cannot rename skills; create a new skills/{newName}/SKILL.md for a different skill name. Keep SKILL.md concise; move large templates, HTML/CSS/JS dashboards, examples, schemas, and long docs into references/, scripts/, assets/, or templates/ files and point to them from SKILL.md. Paths starting with "skills/" target the skill file system. When code execution is enabled, non-skills paths target the code-execution sandbox.`; diff --git a/packages/data-schemas/src/methods/skill.spec.ts b/packages/data-schemas/src/methods/skill.spec.ts index bbd1d1653021..c56ada418ccb 100644 --- a/packages/data-schemas/src/methods/skill.spec.ts +++ b/packages/data-schemas/src/methods/skill.spec.ts @@ -253,7 +253,9 @@ describe('skill validation helpers', () => { 'user-invocable': true, effort: 5, version: '1.0.0', + license: 'MIT', hooks: { 'pre-run': 'echo hi' }, + metadata: { owner: 'data-team' }, }), ).toEqual([]); }); diff --git a/packages/data-schemas/src/methods/skill.ts b/packages/data-schemas/src/methods/skill.ts index 85fc7d30a3be..bb4b2d8786ef 100644 --- a/packages/data-schemas/src/methods/skill.ts +++ b/packages/data-schemas/src/methods/skill.ts @@ -251,6 +251,7 @@ const ALLOWED_FRONTMATTER_KEYS = new Set([ 'shell', 'hooks', 'version', + 'license', 'metadata', ]); @@ -277,6 +278,7 @@ const FRONTMATTER_KIND: Record = { paths: ['string', 'stringArray'], shell: 'string', version: 'string', + license: 'string', }; function isPlainObject(value: unknown): value is Record { From 40ec77e061fefe05abbd76e7c53d8596bf8243c7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 4 Jun 2026 21:06:12 -0400 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=AA=A1=20fix:=20Handle=20Missing=20Sk?= =?UTF-8?q?ill=20File=20Upsert=20Metadata=20(#13520)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/Endpoints/agents/skillDeps.js | 5 + .../Endpoints/agents/skillDeps.spec.js | 102 ++++++++++++++++++ .../data-schemas/src/methods/skill.spec.ts | 34 ++++++ packages/data-schemas/src/methods/skill.ts | 40 ++++--- 4 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 api/server/services/Endpoints/agents/skillDeps.spec.js diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js index 1a0a5ff1490d..39cbc7e858a1 100644 --- a/api/server/services/Endpoints/agents/skillDeps.js +++ b/api/server/services/Endpoints/agents/skillDeps.js @@ -74,6 +74,11 @@ async function saveSkillFileContent({ req, skillId, relativePath, content, mimeT author: req.user._id ?? req.user.id, tenantId, }); + if (!result) { + const error = new Error('Skill file save failed to persist metadata'); + error.code = 'SKILL_FILE_UPSERT_NOT_FOUND'; + throw error; + } } catch (error) { const { deleteFile } = getStrategyFunctions(storage.source); if (deleteFile) { diff --git a/api/server/services/Endpoints/agents/skillDeps.spec.js b/api/server/services/Endpoints/agents/skillDeps.spec.js new file mode 100644 index 000000000000..2f221f89e6a0 --- /dev/null +++ b/api/server/services/Endpoints/agents/skillDeps.spec.js @@ -0,0 +1,102 @@ +const mockSaveBuffer = jest.fn(); +const mockDeleteFile = jest.fn(); +const mockGetStrategyFunctions = jest.fn(); +const mockGetFileStrategy = jest.fn(); +const mockGetStorageMetadata = jest.fn(); +const mockResolveRequestTenantId = jest.fn(); + +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args), +})); + +jest.mock('~/server/services/Files/Code/crud', () => ({ + batchUploadCodeEnvFiles: jest.fn(), +})); + +jest.mock('~/server/services/Files/Code/process', () => ({ + getSessionInfo: jest.fn(), + checkIfActive: jest.fn(), + readSandboxFile: jest.fn(), + writeSandboxFile: jest.fn(), +})); + +jest.mock('@librechat/api', () => ({ + checkAccess: jest.fn(), + enrichWithSkillConfigurable: jest.fn(), + getStorageMetadata: (...args) => mockGetStorageMetadata(...args), + resolveRequestTenantId: (...args) => mockResolveRequestTenantId(...args), +})); + +jest.mock('librechat-data-provider', () => ({ + AccessRoleIds: { SKILL_OWNER: 'SKILL_OWNER' }, + FileContext: { skill_file: 'skill_file' }, + PermissionBits: { EDIT: 2 }, + Permissions: { USE: 'USE', CREATE: 'CREATE' }, + PermissionTypes: { SKILLS: 'SKILLS' }, + PrincipalType: { USER: 'USER' }, + ResourceType: { SKILL: 'SKILL' }, + isEphemeralAgentId: jest.fn(() => false), +})); + +jest.mock('~/server/services/PermissionService', () => ({ + checkPermission: jest.fn(), + grantPermission: jest.fn(), +})); + +jest.mock('~/server/utils/getFileStrategy', () => ({ + getFileStrategy: (...args) => mockGetFileStrategy(...args), +})); + +const mockDb = { + getSkillFileByPath: jest.fn(), + upsertSkillFile: jest.fn(), +}; + +jest.mock('~/models', () => mockDb); + +const { getSkillToolDeps } = require('./skillDeps'); + +describe('skillDeps saveSkillFileContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetFileStrategy.mockReturnValue('s3'); + mockGetStrategyFunctions.mockReturnValue({ + saveBuffer: mockSaveBuffer, + deleteFile: mockDeleteFile, + }); + mockSaveBuffer.mockResolvedValue('https://files.example.test/uploads/file.txt'); + mockDeleteFile.mockResolvedValue(undefined); + mockGetStorageMetadata.mockReturnValue({ + storageKey: 'uploads/file.txt', + storageRegion: 'us-east-2', + }); + mockResolveRequestTenantId.mockReturnValue('tenant-1'); + mockDb.getSkillFileByPath.mockResolvedValue(null); + }); + + it('cleans up the uploaded object when metadata upsert returns no row', async () => { + mockDb.upsertSkillFile.mockResolvedValue(null); + + await expect( + getSkillToolDeps().saveSkillFileContent({ + req: { + user: { id: 'user-1', _id: 'user-1' }, + config: {}, + }, + skillId: 'skill-1', + relativePath: 'references/template.html', + content: '', + mimeType: 'text/html', + }), + ).rejects.toMatchObject({ code: 'SKILL_FILE_UPSERT_NOT_FOUND' }); + + expect(mockDeleteFile).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ id: 'user-1' }) }), + { + filepath: 'https://files.example.test/uploads/file.txt', + user: 'user-1', + tenantId: 'tenant-1', + }, + ); + }); +}); diff --git a/packages/data-schemas/src/methods/skill.spec.ts b/packages/data-schemas/src/methods/skill.spec.ts index c56ada418ccb..9d27105c0ec4 100644 --- a/packages/data-schemas/src/methods/skill.spec.ts +++ b/packages/data-schemas/src/methods/skill.spec.ts @@ -1390,6 +1390,40 @@ describe('SkillFile methods', () => { expect(files[0].storageRegion).toBe('us-east-2'); }); + it('throws an explicit error when the upsert returns no saved file row', async () => { + const { skill } = await methods.createSkill(makeSkillInput()); + const missingUpsert = { + lean: jest.fn().mockResolvedValue({ + value: null, + lastErrorObject: { updatedExisting: false }, + }), + } as unknown as ReturnType; + const findOneAndUpdateSpy = jest + .spyOn(SkillFile, 'findOneAndUpdate') + .mockReturnValueOnce(missingUpsert); + const bumpSpy = jest.spyOn(Skill, 'findByIdAndUpdate'); + + try { + await expect( + methods.upsertSkillFile({ + skillId: skill._id, + relativePath: 'scripts/parse.sh', + file_id: 'file-1', + filename: 'parse.sh', + filepath: '/tmp/v1', + source: 'local', + mimeType: 'text/plain', + bytes: 10, + author: owner._id, + }), + ).rejects.toMatchObject({ code: 'SKILL_FILE_UPSERT_NOT_FOUND' }); + expect(bumpSpy).not.toHaveBeenCalled(); + } finally { + findOneAndUpdateSpy.mockRestore(); + bumpSpy.mockRestore(); + } + }); + it('clears codeEnvRef when a skill file is upserted (replacement)', async () => { /* A re-upload of a skill file replaces the row's contents — but the * cached `codeEnvRef` refers to the OLD bytes living in codeapi. diff --git a/packages/data-schemas/src/methods/skill.ts b/packages/data-schemas/src/methods/skill.ts index bb4b2d8786ef..512ce90c1393 100644 --- a/packages/data-schemas/src/methods/skill.ts +++ b/packages/data-schemas/src/methods/skill.ts @@ -42,6 +42,13 @@ export type ValidationIssue = { severity?: 'error' | 'warning'; }; +type SkillFileUpsertResult = { + value: (ISkillFile & { _id: Types.ObjectId }) | null; + lastErrorObject?: { + updatedExisting?: boolean; + }; +}; + /** Partition an issue list into blocking errors and non-blocking warnings. */ export function partitionIssues(issues: ValidationIssue[]): { errors: ValidationIssue[]; @@ -1437,12 +1444,7 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk } const SkillFile = mongoose.models.SkillFile as Model; const category = inferSkillFileCategory(row.relativePath); - // Atomic new-vs-replace detection: with `new: false, upsert: true`, - // `findOneAndUpdate` returns the pre-update document (or null if the doc - // did not exist and was just inserted). Checking the return value replaces - // a non-atomic `findOne` + `upsert` pair that could double-count on - // concurrent uploads of the same (skillId, relativePath). - const previous = await SkillFile.findOneAndUpdate( + const result = (await SkillFile.findOneAndUpdate( { skillId: row.skillId, relativePath: row.relativePath }, { $set: { @@ -1463,23 +1465,17 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk }, $unset: { content: '', isBinary: '', codeEnvRef: '' }, }, - { new: false, upsert: true }, - ).lean(); - const delta = previous ? 0 : 1; + { new: true, upsert: true, includeResultMetadata: true }, + ).lean()) as unknown as SkillFileUpsertResult; + const current = result.value; + if (!current) { + const error = new Error('Skill file upsert failed to read the saved file row'); + (error as Error & { code?: string }).code = 'SKILL_FILE_UPSERT_NOT_FOUND'; + throw error; + } + const delta = result.lastErrorObject?.updatedExisting === false ? 1 : 0; await bumpSkillVersionAndAdjustFileCount(row.skillId, delta); - - // Fetch the current (post-upsert) document for the caller. This second - // round-trip is an intentional tradeoff for the TOCTOU-safe detection - // above: `new: false` is required to distinguish insert from replace - // atomically, which means `findOneAndUpdate` returns the pre-update - // document (null on insert). A separate `findOne` is the simplest way - // to return the authoritative post-upsert state. Performance impact is - // negligible compared to the file upload I/O this sits behind. - const current = await SkillFile.findOne({ - skillId: row.skillId, - relativePath: row.relativePath, - }).lean(); - return current as unknown as ISkillFile & { _id: Types.ObjectId }; + return current; } async function deleteSkillFile(