From 64ec5f18b85d96a8c687c1e0dc35073151dc05d9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 15 Apr 2026 23:14:48 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20feat:=20Skill=20runtime=20?= =?UTF-8?q?integration:=20catalog,=20tools,=20execution,=20file=20priming?= =?UTF-8?q?=20(#12649)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Skill runtime integration — catalog injection, tool registration, execute handler Wires the @librechat/agents SkillTool primitive into LibreChat's agent runtime: **Enums:** - Add `skills` to AgentCapabilities + defaultAgentCapabilities **Data layer:** - Add `getSkillByName(name, accessibleIds)` — compound query that combines name lookup + ACL check in one findOne **Agent initialization (packages/api/src/agents/initialize.ts):** - Accept `accessibleSkillIds` param and `listSkillsByAccess` db method - Query accessible skills, format catalog via `formatSkillCatalog()`, append to `additional_instructions` (appears in agent system prompt) - Register `SkillToolDefinition` + `createSkillTool()` when catalog is non-empty (tool appears in model's tool list) - Store `accessibleSkillIds` and `skillCount` on InitializedAgent **Execute handler (packages/api/src/agents/handlers.ts):** - Add `getSkillByName` to `ToolExecuteOptions` - `handleSkillToolCall()` intercepts `Constants.SKILL_TOOL`: extracts skillName, loads body from DB with ACL check, substitutes $ARGUMENTS, returns ToolExecuteResult with injectedMessages (skill body as isMeta user message) **Caller wiring:** - initialize.js: query skill IDs via findAccessibleResources, pass to initializeAgent + store on agentToolContexts, add getSkillByName to toolExecuteOptions, pass accessibleSkillIds through loadTools configurable - openai.js + responses.js: same pattern for their flows Requires @librechat/agents >= 3.1.65 (PR #91 exports). * feat: Skills toggle in tools menu + backend capability gating Frontend: - Add skills?: boolean to TEphemeralAgent type - Add LAST_SKILLS_TOGGLE_ to LocalStorageKeys for persistence - Add skillsEnabled to useAgentCapabilities hook - Add skills useToolToggle to BadgeRowContext with localStorage init - New Skills.tsx badge component (Scroll icon, cyan theme, permission-gated via PermissionTypes.SKILLS) - Add skills entry to ToolsDropdown with toggle + pin - Render Skills badge in BadgeRow ephemeral section Backend: - Extract injectSkillCatalog() into packages/api/src/agents/skills.ts (reduces initializeAgent module size, reusable helper) - initializeAgent delegates to helper instead of inline block - Capability-gate the findAccessibleResources query: - Agents endpoint: checks AgentCapabilities.skills in admin config - OpenAI/Responses controllers: checks ephemeralAgent.skills toggle - ACL query runs once per run, result shared across all agents * refactor: remove createSkillTool() instance from injectSkillCatalog SkillTool is event-driven only. The tool definition in toolDefinitions is sufficient for the LLM to see the tool schema. No tool instance is needed since the host handler intercepts via ON_TOOL_EXECUTE before tool.invoke() is ever called. Removes tools from InjectSkillCatalogParams/Result, drops the createSkillTool import. * feat: skill file priming, bash tool, and invoked skills state Multi-file skill support: - New primeSkillFiles() helper (packages/api/src/agents/skillFiles.ts) uploads skill files + SKILL.md body to code execution environment - handleSkillToolCall primes files on invocation when skill.fileCount > 0, returns session info as artifact so ToolNode stores the session - Skill-primed files available to subsequent bash/code tool calls Bash tool auto-registration: - BashExecutionToolDefinition added alongside SkillToolDefinition when skills are enabled, giving the model a bash tool for running scripts Conversation state: - Add invokedSkillIds field to conversation schema (Mongoose + Zod) - handleSkillToolCall updates conversation with $addToSet on success - Enables re-priming skill files on subsequent runs (future) Dependency wiring: - Pass listSkillFiles, getStrategyFunctions, uploadCodeEnvFile, updateConversation through ToolExecuteOptions - Pass req and codeApiKey through mergedConfigurable - All three controller entry points wired (initialize.js, openai.js, responses.js) * fix: load bash_tool instance in loadToolsForExecution, remove file listing - Add createBashExecutionTool to loadToolsForExecution alongside PTC/ToolSearch pattern: loads CODE_API_KEY, creates bash tool instance on demand - Add BASH_TOOL and SKILL_TOOL to specialToolNames set so they don't go through the generic loadTools path (bash is created here, skill is intercepted in handler before tool.invoke) - Remove file name listing from skill content text — it's the skill author's responsibility to disclose files in SKILL.md, not the framework * feat: batch upload for skill files, replace sequential uploads - Add batchUploadCodeEnvFiles() to crud.js: single POST to /upload/batch with all files in one multipart request, returns shared session_id - Rewrite primeSkillFiles to collect all streams (SKILL.md + bundled files) then do one batch upload instead of N sequential uploads - Replace uploadCodeEnvFile with batchUploadCodeEnvFiles across all callers (handlers.ts, initialize.js, openai.js, responses.js) * refactor: remove invokedSkillIds from conversation schema Skills aren't re-loaded between runs, so conversation-level state for invoked skills doesn't help. Skill state will live on messages instead (like tool_search discoveredTools and summaries), enabling in-place re-injection on follow-up runs. Removes invokedSkillIds from: convo Mongoose schema, IConversation interface, Zod schema, ToolExecuteOptions.updateConversation, and all three caller wiring points. * feat: smart skill file re-priming with session freshness checking Schema: - Add codeEnvIdentifier field to ISkillFile (type + Mongoose schema) - Add updateSkillFileCodeEnvIds batch method (uses tenantSafeBulkWrite) - Export checkIfActive from Code/process.js Extraction: - Add extractInvokedSkillsFromHistory() to run.ts — scans message history for AIMessage tool_calls where name === 'skill', extracts skillName args. Follows same pattern as extractDiscoveredToolsFromHistory. Smart re-priming in primeSkillFiles: - Before batch uploading, checks if existing codeEnvIdentifiers are still active via getSessionInfo + checkIfActive (23h threshold) - If session is still active, returns cached references (zero uploads) - If stale or missing, batch-uploads everything and persists new identifiers on SkillFile documents (fire-and-forget) - Single session check covers all files (batch shares one session_id) Wiring: - Pass getSessionInfo, checkIfActive, updateSkillFileCodeEnvIds through ToolExecuteOptions and all three controller entry points * feat: wire skill file re-priming at run start via initialSessions Flow: 1. initialize.js creates primeInvokedSkills callback with all deps 2. client.js calls it with message history before createRun 3. extractInvokedSkillsFromHistory scans for skill tool calls 4. For each invoked skill with files, primeSkillFiles uploads/checks 5. Returns initialSessions map passed to createRun 6. createRun passes initialSessions to Run.create (via RunConfig) 7. Run constructor seeds Graph.sessions, making skill files available to subsequent bash/code tool calls via ToolNode session injection Requires @librechat/agents with initialSessions on RunConfig (PR #94). * refactor: use CODE_EXECUTION_TOOLS set for code tool checks Import CODE_EXECUTION_TOOLS from @librechat/agents and replace inline constant checks in handlers.ts and callbacks.js. Fixes missing bash tool coverage in the session context injection (handlers.ts) and code output processing (callbacks.js). * refactor: move primeInvokedSkills to packages/api, add skill body re-injection Moves primeInvokedSkills from an inline closure in initialize.js (with dynamic requires) to a proper exported function in packages/api skillFiles.ts with explicit typed dependencies. Key changes: - primeInvokedSkills now returns both initialSessions (for file priming) AND injectedMessages (skill bodies for context continuity) - createRun accepts invokedSkillMessages and appends skill bodies to systemContent so the model retains skill instructions across runs - initialize.js calls the packaged function with all deps passed explicitly - client.js passes both initialSessions and injectedMessages to createRun * fix: move dynamic requires to top-level module imports Move primeInvokedSkills, getStrategyFunctions, batchUploadCodeEnvFiles, getSessionInfo, and checkIfActive from inline requires to top-level module requires where they belong. * refactor: skill body reconstruction via formatAgentMessages, not systemContent Replaces the lazy systemContent approach with proper message-level reconstruction: SDK (formatAgentMessages): - New invokedSkillBodies param (Map) - Reconstructs HumanMessages after skill ToolMessages at the correct position in the message sequence, matching where ToolNode originally injected them LibreChat: - extractInvokedSkillsFromPayload replaces extractInvokedSkillsFromHistory (works with raw TPayload before formatAgentMessages, not BaseMessage[]) - primeInvokedSkills now takes payload instead of messages, returns skillBodies Map instead of injectedMessages - client.js calls primeInvokedSkills BEFORE formatAgentMessages, passes skillBodies through as the 4th param - Removed invokedSkillMessages from createRun (no more systemContent hack) - Single-pass: skill detection happens inside formatAgentMessages' existing tool_call processing loop, zero extra message iterations * refactor: rename skillBodies to skills for consistency with SDK param * refactor: move auth loading into primeInvokedSkills, pass loadAuthValues as dep The payload/accessibleSkillIds guard and CODE_API_KEY loading now live inside primeInvokedSkills (packages/api) rather than in the CJS caller. initialize.js passes loadAuthValues as a dependency and the callback is only created when skillsCapabilityEnabled. * feat: ReadFile tool + conditional bash registration + skill path namespacing ReadFile tool (read_file): - General-purpose file reader, event-driven (ON_TOOL_EXECUTE) - Schema: { file_path: string } — "{skillName}/{path}" convention - handleReadFileCall: resolves skill name from path, ACL check, reads from DB cache or storage, binary detection, size limits (256KB), lazy caching (512KB), line numbers in output - SKILL.md special case: reads skill.body directly - Dispatched alongside SKILL_TOOL in createToolExecuteHandler - Added to specialToolNames in ToolService Conditional tool registration: - ReadFile + SkillTool: always registered when skills enabled - BashTool: only registered when codeEnvAvailable === true - codeEnvAvailable passed through InitializeAgentParams from caller Skill file path namespacing: - primeSkillFiles now uploads as "{skillName}/SKILL.md" and "{skillName}/{relativePath}" instead of flat names - Prevents file collisions when multiple skills are invoked Wiring: - getSkillFileByPath + updateSkillFileContent passed through ToolExecuteOptions in all three callers * feat: return images/PDFs as artifacts from read_file, tighten caching Binary artifact support: - Images (png, jpeg, gif, webp) returned as base64 in artifact.content with type: 'image_url', processed by existing callback attachment flow - PDFs returned as base64 artifact similarly - Binary size limit: 10MB (MAX_BINARY_BYTES) - Other binary files still return metadata + bash fallback Caching: - Text cached only on first read (file.content == null check) - Binary flag cached only on first detection (file.isBinary == null) - Skill files are immutable; no redundant cache writes Registration: - ReadFileToolDefinition now includes responseFormat: 'content_and_artifact' * chore: update @librechat/agents to version 3.1.66-dev.0 and add peer dependencies in package-lock.json and package.json files * fix: resolve review findings #1,#2,#4,#5,#6,#10,#13 Critical: - #1: primeInvokedSkills now accumulates files across all skills into one session entry instead of overwriting. Parallel processing via Promise.allSettled. - #2: codeEnvAvailable now computed and passed in openai.js and responses.js (was missing, bash tool never registered in those flows) Major: - #4: relativePath in updateSkillFileCodeEnvIds now strips the {skillName}/ prefix to match SkillFile documents. SKILL.md filter uses endsWith instead of exact match. - #5: File priming guarded on apiKey being non-empty (skip when not configured instead of failing with auth error) - #6: Skills processed in parallel via Promise.allSettled instead of sequential for-of loop Minor: - #10: Use top-level imports in initialize.js instead of inline requires - #13: Log warning when skill catalog reaches the 100-skill limit * fix: resolve followup review findings N1,N2,N4 N1 (CRITICAL): Wire skill deps into responses.js non-streaming path. Was completely missing getSkillByName, file strategy functions, etc. N2 (MAJOR): Single batch upload for ALL skills' files. Resolves skills in parallel (Phase 1), then collects all file streams across skills and does ONE batchUploadCodeEnvFiles call (Phase 2). All files share one session_id, eliminating cross-session isolation issues. N4 (MINOR): Move inline require() to top-level in openai.js and responses.js, consistent with initialize.js. * fix: add mocks for new file strategy imports in controller tests * fix: restore session freshness check, parallelize file lookups, add warnings R1: Re-add session freshness check before batch upload. Checks any existing codeEnvIdentifier via getSessionInfo + checkIfActive. If the session is still active (23h window), returns cached file references with zero re-uploads. R2: listSkillFiles calls parallelized via Promise.all (were sequential in the for-of loop). R3: Log warning when skill record lookup fails during identifier persistence (was a silent empty-string fallback). * fix: guard freshness cache on single-session consistency * fix: multi-session freshness check (code env handles mixed sessions natively) The code execution environment fetches each file by its own {session_id, fileId} pair independently — no single-session requirement. Removed the sessionIds.size === 1 guard. Now checks ALL distinct sessions for freshness. If every session is still active (23h window), returns cached references with per-file session_ids preserved. If any session expired, falls through to re-upload everything in a single batch. * perf: parallelize session freshness checks via Promise.all * fix: add optional chaining for session info retrieval in primeInvokedSkills Updated the primeInvokedSkills function to use optional chaining for getSessionInfo and checkIfActive methods, ensuring safer access and preventing potential runtime errors when these methods are undefined. * fix: address review findings #1-#9 + Codex P1/P2 + session probe Critical: - #1/Codex P1: Add codeApiKey loading to openai.js and responses.js loadTools configurable (was missing, file priming broken in 2/3 paths) - Codex P1: Fix cached file name prefix in primeSkillFiles cache path (was sf.relativePath, now ${skill.name}/${sf.relativePath}) Major: - Codex P2: Honor ephemeral skills toggle in agents endpoint (check ephemeralAgent?.skills !== false alongside admin capability) - #4: Early size check using file.bytes from DB before streaming (prevents full-file buffer for oversized files) Minor: - #5: Replace Record with Record - #6: Localize Pin/Unpin aria-labels with com_ui_pin/com_ui_unpin - #8: Parallelize stream acquisition in primeSkillFiles via Promise.allSettled - #9: Log warning for partial batch upload failures with filenames Performance: - Session probe optimization: getSessionInfo now hits per-object endpoint (GET /sessions/{sid}/objects/{fid}) instead of listing entire session (GET /files/{sid}?detail=summary). O(1) stat vs O(N) list + linear scan. * refactor: extract shared skill wiring helper + add unit tests DRY (#3): - New skillDeps.js exports getSkillToolDeps() with all 9 skill-related deps (getSkillByName, listSkillFiles, getStrategyFunctions, etc.) - Replaces 5 identical copy-paste blocks across initialize.js, openai.js, responses.js (streaming + non-streaming paths) - One place to maintain when skill deps change Tests (#2): - 8 unit tests for extractInvokedSkillsFromPayload covering: string args, object args, missing skill tool_calls, non-assistant messages, malformed JSON, empty skillName, empty payload, dedup * fix: remove @jest/globals import, use global jest env * fix: resolve round 2 review findings R2-1 through R2-7 R2-1 (toggle semantics): openai.js + responses.js now check admin capability (AgentCapabilities.skills) alongside ephemeral toggle. Aligns with initialize.js. R2-2 (swallowed error): primeInvokedSkills now logs updateSkillFileCodeEnvIds failures (was .catch(() => {})) R2-4 (test cast): Record → Record R2-5 (DRY regression): Extract enrichWithSkillConfigurable() into skillDeps.js. Replaces 4 identical loadAuthValues blocks. Each loadTools callback is now a one-liner. JSDoc added (R2-6). R2-7 (sequential streams): primeInvokedSkills now uses Promise.allSettled for parallel stream acquisition. * fix: require explicit skills toggle + treat partial cache as miss - initialize.js: change ephemeralSkillsToggle !== false to === true (unset toggle no longer enables skills) - primeSkillFiles cache: require ALL files to have codeEnvIdentifier before using cache (partial persistence = cache miss = re-upload) - primeInvokedSkills cache: same check (allFilesWithIds.length must equal total file count) * fix: pass entity_id=skillId on batch upload, eliminates per-user cache thrashing primeSkillFiles now passes entity_id: skill._id.toString() to batchUploadCodeEnvFiles. This scopes the code env session to the skill, not the user. All users sharing a skill share the same uploaded files — no more cache thrashing from overwriting each other's codeEnvIdentifier. The stored codeEnvIdentifier now includes ?entity_id= suffix so freshness checks pass the entity_id through to the per-object stat endpoint. Both primeSkillFiles and primeInvokedSkills store consistent identifier formats. * fix: pass entity_id on multi-skill batch upload, consistent identifier format * Revert "fix: pass entity_id on multi-skill batch upload, consistent identifier format" This reverts commit c85ce2161e6f608bbebc432e72f2241d0f572517. * refactor: per-skill upload in primeInvokedSkills, eliminate multi-skill batch Replace the monolithic multi-skill batch upload with per-skill primeSkillFiles calls. Each skill gets its own session with entity_id=skillId, ensuring: - Correct session auth (entity_id matches on freshness checks) - Per-skill freshness caching (only expired skills re-upload) - Shared skill sessions work across users (same entity_id=skillId) - Code env handles mixed session_ids natively The big batch block (stream collection, single upload, identifier mapping) is replaced by a simple loop over primeSkillFiles, which already handles freshness caching, batch upload, and identifier persistence per-skill. * fix: resolve review findings #1,#3-5,#7,#9-11 Critical: - #1: Strip ?entity_id= query string before splitting codeEnvIdentifier into session_id/fileId (was corrupting cached file IDs in 4 locations) Major: - #4: Parallelize per-skill primeSkillFiles via Promise.allSettled - #5: Add logger.warn to all empty .catch(() => {}) on cache writes Minor: - #7: Add logger.debug to enrichWithSkillConfigurable catch block - #9: Use error instanceof Error guard in batchUploadCodeEnvFiles - #10: Move enrichWithSkillConfigurable to TypeScript in packages/api (skillConfigurable.ts), skillDeps.js wraps with loadAuthValues dep - #11: Reduce MAX_BINARY_BYTES from 10MB to 5MB (~11.5MB peak with b64) * fix: forward entity_id in session probe + always register bash tool Codex P2 (entity_id in probe): getSessionInfo now preserves and forwards query params (including entity_id) to the per-object stat endpoint. Without this, identifiers stored as ...?entity_id=... would fail auth checks because the entity_id scope was dropped. Codex P2 (bash tool availability): Remove codeEnvAvailable gate from injectSkillCatalog. Bash tool definition is now always registered when skills are enabled. Actual tool instance creation still happens at execution time in loadToolsForExecution (which loads per-user credentials). This ensures users with per-user CODE_API_KEY get bash without requiring a global env var at init time. Removes codeEnvAvailable from InjectSkillCatalogParams, InitializeAgentParams, and all three controller entry points. * fix: add debug logging to primeInvokedSkills catch, rename export alias * fix: stub bash tool when no key + remove PDF artifact path Codex P1 (bash tool): When CODE_API_KEY is unavailable, create a stub tool that returns "Code execution is not available. Use read_file instead." This prevents "tool not found" errors from the model repeatedly calling bash_tool in no-code-env deployments while still registering the definition for per-user credential users. Codex P2 (PDF artifacts): Remove PDF image_url artifact path. The host artifact pipeline processes image_url via saveBase64Image which fails for PDFs. PDFs now fall through to the generic binary handler ("Use bash to process"). TODO comment for future document artifact support. Also: isImageOrPdf → isImage in early size checks (PDFs are no longer treated as artifact candidates). * fix: remove dead PDF_MIME constant, hoist skillToolDeps, document session_id - #7: Remove unused PDF_MIME constant (dead code after PDF artifact removal) - #11: Hoist skillToolDeps to module-level constant (avoid per-call allocation) - #6: Document that CodeSessionContext.session_id is a representative value; ToolNode uses per-file session_id from the files array * fix: call toolEndCallback for skill/read_file artifacts + clear codeEnvIdentifier on re-upload Codex P1 (toolEndCallback bypass): skill and read_file handler branches returned early, bypassing the toolEndCallback that processes artifacts (image attachments). Now calls toolEndCallback when the result has an artifact, using the same metadata pattern as the normal tool.invoke path. Codex P1 (stale identifiers): upsertSkillFile now $unset's codeEnvIdentifier alongside content and isBinary when a file is re-uploaded. Prevents the freshness cache from returning references to old file content after a skill file is replaced. * fix: add session_id comment at cached path, rename skillResult to handlerResult * fix: return content_and_artifact from bash stub so result.content is populated * fix: deterministic skill lookup, dedup warning, and multi-session freshness check - getSkillByName: add sort({updatedAt:-1}) so name collisions resolve deterministically to the most recently updated skill - injectSkillCatalog: warn when multiple accessible skills share a name - primeSkillFiles: check ALL distinct sessions for freshness, not just the first file's session, preventing stale refs after partial bulkWrite * refactor: update icon import in Skills component - Replaced the Scroll icon with ScrollText in the Skills component for improved clarity and consistency in the UI. * fix: SKILL.md cache parity, gate bash_tool on code env, fix read_file too-large message - primeSkillFiles: filter SKILL.md from returned files array on fresh upload so cached and non-cached paths return identical file sets (SKILL.md is still on disk in the session for bash access) - injectSkillCatalog: only register bash_tool when codeEnvAvailable is true; thread the flag from all three CJS callers via execute_code capability check - handleReadFileCall: tell the model to invoke the skill first before suggesting /mnt/data paths for oversized files * fix: use EnvVar constant, deduplicate auth lookup, validate batch upload, stream byte limit - Replace hardcoded 'LIBRECHAT_CODE_API_KEY' with EnvVar.CODE_API_KEY in skillConfigurable.ts and skillFiles.ts - Resolve code API key once at run start in initialize.js and pass to both primeInvokedSkills and enrichWithSkillConfigurable via optional preResolvedCodeApiKey param, eliminating redundant loadAuthValues calls - Add response structure validation in batchUploadCodeEnvFiles before accessing session_id/files to surface unexpected responses early - Add streaming byte counter in handleReadFileCall that aborts and destroys the stream when accumulated bytes exceed MAX_BINARY_BYTES, preventing full file buffering when DB metadata is inaccurate * refactor: update icon import in ToolsDropdown component - Replaced the Scroll icon with ScrollText in the ToolsDropdown component for improved clarity and consistency in the UI. * fix: partial upload failure detection, EnvVar in initialize.js, declaration ordering - primeSkillFiles: return null (failure) when batch upload partially succeeds — missing bundled files would cause runtime bash/read failures with missing paths in code env - initialize.js: replace hardcoded 'LIBRECHAT_CODE_API_KEY' with EnvVar.CODE_API_KEY imported from @librechat/agents - initialize.js: move enabledCapabilities, accessibleSkillIds, and codeApiKey declarations before the toolExecuteOptions closure that references them (eliminates reliance on temporal dead zone hoisting) --- api/package.json | 2 +- .../agents/__tests__/openai.spec.js | 13 + .../agents/__tests__/responses.unit.spec.js | 13 + api/server/controllers/agents/callbacks.js | 9 +- api/server/controllers/agents/client.js | 9 +- api/server/controllers/agents/openai.js | 25 +- api/server/controllers/agents/responses.js | 31 +- .../services/Endpoints/agents/initialize.js | 62 ++- .../services/Endpoints/agents/skillDeps.js | 43 ++ api/server/services/Files/Code/crud.js | 81 ++- api/server/services/Files/Code/process.js | 13 +- api/server/services/ToolService.js | 38 ++ client/src/Providers/BadgeRowContext.tsx | 23 +- client/src/components/Chat/Input/BadgeRow.tsx | 2 + client/src/components/Chat/Input/Skills.tsx | 36 ++ .../components/Chat/Input/ToolsDropdown.tsx | 48 +- .../src/hooks/Agents/useAgentCapabilities.ts | 7 + package-lock.json | 189 ++++++- packages/api/package.json | 2 +- .../api/src/agents/__tests__/skills.test.ts | 183 +++++++ packages/api/src/agents/handlers.ts | 483 +++++++++++++++++- packages/api/src/agents/index.ts | 3 + packages/api/src/agents/initialize.ts | 36 +- packages/api/src/agents/run.ts | 65 ++- packages/api/src/agents/skillConfigurable.ts | 43 ++ packages/api/src/agents/skillFiles.ts | 434 ++++++++++++++++ packages/api/src/agents/skills.ts | 131 +++++ packages/data-provider/src/config.ts | 4 + packages/data-provider/src/types.ts | 1 + packages/data-schemas/src/methods/skill.ts | 37 +- packages/data-schemas/src/schema/skillFile.ts | 3 + packages/data-schemas/src/types/skill.ts | 6 + 32 files changed, 2027 insertions(+), 48 deletions(-) create mode 100644 api/server/services/Endpoints/agents/skillDeps.js create mode 100644 client/src/components/Chat/Input/Skills.tsx create mode 100644 packages/api/src/agents/__tests__/skills.test.ts create mode 100644 packages/api/src/agents/skillConfigurable.ts create mode 100644 packages/api/src/agents/skillFiles.ts create mode 100644 packages/api/src/agents/skills.ts diff --git a/api/package.json b/api/package.json index 239b859084..082009f54e 100644 --- a/api/package.json +++ b/api/package.json @@ -44,7 +44,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.68", + "@librechat/agents": "^3.1.66-dev.0", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index f55b798f14..a444b18863 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -114,6 +114,19 @@ jest.mock('~/server/services/PermissionService', () => ({ checkPermission: jest.fn().mockResolvedValue(true), })); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn().mockReturnValue({}), +})); + +jest.mock('~/server/services/Files/Code/crud', () => ({ + batchUploadCodeEnvFiles: jest.fn().mockResolvedValue({ session_id: '', files: [] }), +})); + +jest.mock('~/server/services/Files/Code/process', () => ({ + getSessionInfo: jest.fn().mockResolvedValue(null), + checkIfActive: jest.fn().mockReturnValue(false), +})); + const mockUpdateBalance = jest.fn().mockResolvedValue({}); const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined); diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 1a6fa6053f..cc2086766f 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -144,6 +144,19 @@ jest.mock('~/cache', () => ({ logViolation: jest.fn(), })); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn().mockReturnValue({}), +})); + +jest.mock('~/server/services/Files/Code/crud', () => ({ + batchUploadCodeEnvFiles: jest.fn().mockResolvedValue({ session_id: '', files: [] }), +})); + +jest.mock('~/server/services/Files/Code/process', () => ({ + getSessionInfo: jest.fn().mockResolvedValue(null), + checkIfActive: jest.fn().mockReturnValue(false), +})); + const mockUpdateBalance = jest.fn().mockResolvedValue({}); const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined); diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 40fdf74212..40483ebdc3 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -7,6 +7,7 @@ const { GraphEvents, GraphNodeKeys, ToolEndHandler, + CODE_EXECUTION_TOOLS, } = require('@librechat/agents'); const { sendEvent, @@ -443,9 +444,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) return; } - const isCodeTool = - output.name === Tools.execute_code || output.name === Constants.PROGRAMMATIC_TOOL_CALLING; - if (!isCodeTool) { + if (!CODE_EXECUTION_TOOLS.has(output.name)) { return; } @@ -651,9 +650,7 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) return; } - const isCodeTool = - output.name === Tools.execute_code || output.name === Constants.PROGRAMMATIC_TOOL_CALLING; - if (!isCodeTool) { + if (!CODE_EXECUTION_TOOLS.has(output.name)) { return; } diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 71fd843b0f..7828048991 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -742,12 +742,18 @@ class AgentClient extends BaseClient { const toolSet = buildToolSet(this.options.agent); const tokenCounter = createTokenCounter(this.getEncoding()); + + /** Pre-resolve invoked skill bodies + re-prime files before formatting messages */ + const skillPrimeResult = this.options.primeInvokedSkills + ? await this.options.primeInvokedSkills(payload) + : undefined; + let { messages: initialMessages, indexTokenCountMap, summary: initialSummary, boundaryTokenAdjustment, - } = formatAgentMessages(payload, this.indexTokenCountMap, toolSet); + } = formatAgentMessages(payload, this.indexTokenCountMap, toolSet, skillPrimeResult?.skills); if (boundaryTokenAdjustment) { logger.debug( `[AgentClient] Boundary token adjustment: ${boundaryTokenAdjustment.original} → ${boundaryTokenAdjustment.adjusted} (${boundaryTokenAdjustment.remainingChars}/${boundaryTokenAdjustment.totalChars} chars)`, @@ -829,6 +835,7 @@ class AgentClient extends BaseClient { messages, indexTokenCountMap, initialSummary, + initialSessions: skillPrimeResult?.initialSessions, calibrationRatio, runId: this.responseMessageId, signal: abortController.signal, diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index e7cd7e1c29..9826adfad2 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -6,6 +6,7 @@ const { ResourceType, PermissionBits, hasPermissions, + AgentCapabilities, } = require('librechat-data-provider'); const { writeSSE, @@ -40,6 +41,10 @@ const { findAccessibleResources, getEffectivePermissions, } = require('~/server/services/PermissionService'); +const { + getSkillToolDeps, + enrichWithSkillConfigurable, +} = require('~/server/services/Endpoints/agents/skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); const { logViolation } = require('~/cache'); const db = require('~/models'); @@ -235,8 +240,22 @@ const OpenAIChatCompletionController = async (req, res) => { getUserCodeFiles: db.getUserCodeFiles, getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, + listSkillsByAccess: db.listSkillsByAccess, }; + const enabledCapabilities = new Set(agentsEConfig?.capabilities); + const ephemeralAgent = req.body?.ephemeralAgent; + const skillsEnabled = + enabledCapabilities.has(AgentCapabilities.skills) && ephemeralAgent?.skills === true; + const accessibleSkillIds = skillsEnabled + ? await findAccessibleResources({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }) + : []; + const primaryConfig = await initializeAgent( { req, @@ -249,6 +268,8 @@ const OpenAIChatCompletionController = async (req, res) => { endpointOption, allowedProviders, isInitialAgent: true, + accessibleSkillIds, + codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), }, dbMethods, ); @@ -375,7 +396,7 @@ const OpenAIChatCompletionController = async (req, res) => { const toolExecuteOptions = { loadTools: async (toolNames, agentId) => { const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {}; - return loadToolsForExecution({ + const result = await loadToolsForExecution({ req, res, toolNames, @@ -386,8 +407,10 @@ const OpenAIChatCompletionController = async (req, res) => { tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, }); + return enrichWithSkillConfigurable(result, req, primaryConfig.accessibleSkillIds); }, toolEndCallback, + ...getSkillToolDeps(), }; const summarizationConfig = appConfig?.summarization; diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 3cb24b2083..3993be345f 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -7,6 +7,7 @@ const { ResourceType, PermissionBits, hasPermissions, + AgentCapabilities, } = require('librechat-data-provider'); const { createRun, @@ -49,6 +50,10 @@ const { findAccessibleResources, getEffectivePermissions, } = require('~/server/services/PermissionService'); +const { + getSkillToolDeps, + enrichWithSkillConfigurable, +} = require('~/server/services/Endpoints/agents/skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); const { logViolation } = require('~/cache'); const db = require('~/models'); @@ -362,8 +367,24 @@ const createResponse = async (req, res) => { getUserCodeFiles: db.getUserCodeFiles, getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, + listSkillsByAccess: db.listSkillsByAccess, }; + const enabledCapabilities = new Set( + appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities, + ); + const ephemeralAgent = req.body?.ephemeralAgent; + const skillsEnabled = + enabledCapabilities.has(AgentCapabilities.skills) && ephemeralAgent?.skills === true; + const accessibleSkillIds = skillsEnabled + ? await findAccessibleResources({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }) + : []; + const primaryConfig = await initializeAgent( { req, @@ -376,6 +397,8 @@ const createResponse = async (req, res) => { endpointOption, allowedProviders, isInitialAgent: true, + accessibleSkillIds, + codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), }, dbMethods, ); @@ -533,7 +556,7 @@ const createResponse = async (req, res) => { loadTools: async (toolNames, agentId) => { const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {}; - return loadToolsForExecution({ + const result = await loadToolsForExecution({ req, res, toolNames, @@ -544,8 +567,10 @@ const createResponse = async (req, res) => { tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, }); + return enrichWithSkillConfigurable(result, req, primaryConfig.accessibleSkillIds); }, toolEndCallback, + ...getSkillToolDeps(), }; // Combine handlers @@ -701,7 +726,7 @@ const createResponse = async (req, res) => { loadTools: async (toolNames, agentId) => { const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {}; - return loadToolsForExecution({ + const result = await loadToolsForExecution({ req, res, toolNames, @@ -712,8 +737,10 @@ const createResponse = async (req, res) => { tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, }); + return enrichWithSkillConfigurable(result, req, primaryConfig.accessibleSkillIds); }, toolEndCallback, + ...getSkillToolDeps(), }; const handlers = { diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 549e9047b5..1cabe402fc 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1,7 +1,8 @@ const { logger } = require('@librechat/data-schemas'); -const { createContentAggregator } = require('@librechat/agents'); +const { EnvVar, createContentAggregator } = require('@librechat/agents'); const { initializeAgent, + primeInvokedSkills, validateAgentModel, GenerationJobManager, getCustomEndpointConfig, @@ -11,6 +12,7 @@ const { EModelEndpoint, isAgentsEndpoint, getResponseSender, + AgentCapabilities, isEphemeralAgentId, } = require('librechat-data-provider'); const { @@ -18,9 +20,11 @@ const { getDefaultHandlers, } = require('~/server/controllers/agents/callbacks'); const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService'); +const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); +const { getSkillToolDeps, enrichWithSkillConfigurable } = require('./skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); -const { checkPermission } = require('~/server/services/PermissionService'); +const { checkPermission, findAccessibleResources } = require('~/server/services/PermissionService'); const AgentClient = require('~/server/controllers/agents/client'); const { processAddedConvo } = require('./addedConvo'); const { logViolation } = require('~/cache'); @@ -102,6 +106,37 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { const { contentParts, aggregateContent } = createContentAggregator(); const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId }); + /** Query accessible skill IDs once per run (shared across all agents). + * Requires both admin capability AND per-conversation toggle (if ephemeral). */ + const enabledCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities); + const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills; + const skillsCapabilityEnabled = + enabledCapabilities.has(AgentCapabilities.skills) && ephemeralSkillsToggle === true; + + const accessibleSkillIds = skillsCapabilityEnabled + ? await findAccessibleResources({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }) + : []; + + // Resolve code API key once for the entire run (shared by primeInvokedSkills + // and enrichWithSkillConfigurable) to avoid redundant auth lookups. + let codeApiKey; + if (skillsCapabilityEnabled && enabledCapabilities.has(AgentCapabilities.execute_code)) { + try { + const authValues = await loadAuthValues({ + userId: req.user.id, + authFields: [EnvVar.CODE_API_KEY], + }); + codeApiKey = authValues[EnvVar.CODE_API_KEY]; + } catch { + // non-fatal — primeInvokedSkills and enrichWithSkillConfigurable will work without it + } + } + /** * Agent context store - populated after initialization, accessed by callback via closure. * Maps agentId -> { userMCPAuthMap, agent, tool_resources, toolRegistry, openAIApiKey } @@ -135,9 +170,10 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { }); logger.debug(`[ON_TOOL_EXECUTE] loaded ${result.loadedTools?.length ?? 0} tools`); - return result; + return enrichWithSkillConfigurable(result, req, ctx.accessibleSkillIds, codeApiKey); }, toolEndCallback, + ...getSkillToolDeps(), }; const summarizationOptions = @@ -200,6 +236,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { endpointOption, allowedProviders, isInitialAgent: true, + accessibleSkillIds, + codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), }, { getFiles: db.getFiles, @@ -212,6 +250,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, filterFilesByAgentAccess, + listSkillsByAccess: db.listSkillsByAccess, }, ); @@ -224,6 +263,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { userMCPAuthMap: primaryConfig.userMCPAuthMap, tool_resources: primaryConfig.tool_resources, actionsEnabled: primaryConfig.actionsEnabled, + accessibleSkillIds: primaryConfig.accessibleSkillIds, }); const { @@ -259,6 +299,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, filterFilesByAgentAccess, + listSkillsByAccess: db.listSkillsByAccess, }, // The callback fires during BFS, before the helper prunes agents // whose edges end up filtered. Don't populate `agentConfigs` here — @@ -273,6 +314,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, actionsEnabled: config.actionsEnabled, + accessibleSkillIds: config.accessibleSkillIds, }); }, // Pass through the `@librechat/api` exports so that tests which @@ -325,6 +367,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, actionsEnabled: config.actionsEnabled, + accessibleSkillIds: config.accessibleSkillIds, }); } @@ -356,6 +399,18 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { modelLabel: endpointOption.model_parameters.modelLabel, }); + const handlePrimeInvokedSkills = skillsCapabilityEnabled + ? (payload) => + primeInvokedSkills({ + req, + payload, + accessibleSkillIds, + codeApiKey, + loadAuthValues, + ...getSkillToolDeps(), + }) + : undefined; + const client = new AgentClient({ req, res, @@ -366,6 +421,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { collectedUsage, aggregateContent, artifactPromises, + primeInvokedSkills: handlePrimeInvokedSkills, agent: primaryConfig, spec: endpointOption.spec, iconURL: endpointOption.iconURL, diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js new file mode 100644 index 0000000000..32a7995a49 --- /dev/null +++ b/api/server/services/Endpoints/agents/skillDeps.js @@ -0,0 +1,43 @@ +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { batchUploadCodeEnvFiles } = require('~/server/services/Files/Code/crud'); +const { getSessionInfo, checkIfActive } = require('~/server/services/Files/Code/process'); +const { loadAuthValues } = require('~/server/services/Tools/credentials'); +const { enrichWithSkillConfigurable } = require('@librechat/api'); +const db = require('~/models'); + +/** Skill-related properties for ToolExecuteOptions (stable references, allocated once). */ +const skillToolDeps = { + getSkillByName: db.getSkillByName, + listSkillFiles: db.listSkillFiles, + getStrategyFunctions, + batchUploadCodeEnvFiles, + getSessionInfo, + checkIfActive, + updateSkillFileCodeEnvIds: db.updateSkillFileCodeEnvIds, + getSkillFileByPath: db.getSkillFileByPath, + updateSkillFileContent: db.updateSkillFileContent, +}; + +function getSkillToolDeps() { + return skillToolDeps; +} + +/** + * Wraps the TS enrichWithSkillConfigurable with the CJS loadAuthValues dependency. + * @param {object} result - The result from loadToolsForExecution + * @param {object} req - The Express request object + * @param {Array} accessibleSkillIds - Pre-computed accessible skill IDs + * @param {string} [preResolvedCodeApiKey] - Pre-resolved code API key (skips redundant lookup) + * @returns {Promise} Augmented result with skill configurable + */ +function enrichConfigurable(result, req, accessibleSkillIds, preResolvedCodeApiKey) { + return enrichWithSkillConfigurable( + result, + req, + accessibleSkillIds, + loadAuthValues, + preResolvedCodeApiKey, + ); +} + +module.exports = { getSkillToolDeps, enrichWithSkillConfigurable: enrichConfigurable }; diff --git a/api/server/services/Files/Code/crud.js b/api/server/services/Files/Code/crud.js index 945aec787b..5bf028702a 100644 --- a/api/server/services/Files/Code/crud.js +++ b/api/server/services/Files/Code/crud.js @@ -1,4 +1,5 @@ const FormData = require('form-data'); +const { logger } = require('@librechat/data-schemas'); const { getCodeBaseURL } = require('@librechat/agents'); const { logAxiosError, @@ -107,4 +108,82 @@ async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = '' } } -module.exports = { getCodeOutputDownloadStream, uploadCodeEnvFile }; +/** + * Uploads multiple files to the code execution environment in a single request. + * Uses the /upload/batch endpoint which shares one session_id across all files. + * + * @param {object} params + * @param {import('express').Request & { user: { id: string } }} params.req - The request object. + * @param {Array<{ stream: NodeJS.ReadableStream; filename: string }>} params.files - Files to upload. + * @param {string} params.apiKey - The API key for authentication. + * @param {string} [params.entity_id] - Optional entity ID. + * @returns {Promise<{ session_id: string; files: Array<{ fileId: string; filename: string }> }>} + * @throws {Error} If the batch upload fails entirely. + */ +async function batchUploadCodeEnvFiles({ req, files, apiKey, entity_id = '' }) { + try { + const form = new FormData(); + if (entity_id.length > 0) { + form.append('entity_id', entity_id); + } + for (const file of files) { + form.append('file', file.stream, file.filename); + } + + const baseURL = getCodeBaseURL(); + /** @type {import('axios').AxiosRequestConfig} */ + const options = { + headers: { + ...form.getHeaders(), + 'Content-Type': 'multipart/form-data', + 'User-Agent': 'LibreChat/1.0', + 'User-Id': req.user.id, + 'X-API-Key': apiKey, + }, + httpAgent: codeServerHttpAgent, + httpsAgent: codeServerHttpsAgent, + timeout: 120000, + maxContentLength: MAX_FILE_SIZE, + maxBodyLength: MAX_FILE_SIZE, + }; + + const response = await axios.post(`${baseURL}/upload/batch`, form, options); + + /** @type {{ message: string; session_id: string; files: Array<{ status: string; fileId?: string; filename: string; error?: string }>; succeeded: number; failed: number }} */ + const result = response.data; + if ( + !result || + typeof result !== 'object' || + !result.session_id || + !Array.isArray(result.files) + ) { + throw new Error(`Unexpected batch upload response: ${JSON.stringify(result).slice(0, 200)}`); + } + if (result.message === 'error') { + throw new Error('All files in batch upload failed'); + } + + if (result.failed > 0) { + const failedNames = result.files + .filter((f) => f.status === 'error') + .map((f) => `${f.filename}: ${f.error || 'unknown'}`) + .join(', '); + logger.warn(`[batchUploadCodeEnvFiles] ${result.failed} file(s) failed: ${failedNames}`); + } + + const successFiles = result.files + .filter((f) => f.status === 'success' && f.fileId) + .map((f) => ({ fileId: f.fileId, filename: f.filename })); + + return { session_id: result.session_id, files: successFiles }; + } catch (error) { + throw new Error( + logAxiosError({ + message: `Error in batch upload to code environment: ${error instanceof Error ? error.message : String(error)}`, + error, + }), + ); + } +} + +module.exports = { getCodeOutputDownloadStream, uploadCodeEnvFile, batchUploadCodeEnvFiles }; diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 7cdebeb202..d7076f75f2 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -292,8 +292,7 @@ async function getSessionInfo(fileIdentifier, apiKey) { try { const baseURL = getCodeBaseURL(); const [path, queryString] = fileIdentifier.split('?'); - const session_id = path.split('/')[0]; - + const [session_id, fileId] = path.split('/'); let queryParams = {}; if (queryString) { queryParams = Object.fromEntries(new URLSearchParams(queryString).entries()); @@ -301,11 +300,8 @@ async function getSessionInfo(fileIdentifier, apiKey) { const response = await axios({ method: 'get', - url: `${baseURL}/files/${session_id}`, - params: { - detail: 'summary', - ...queryParams, - }, + url: `${baseURL}/sessions/${session_id}/objects/${fileId}`, + params: queryParams, headers: { 'User-Agent': 'LibreChat/1.0', 'X-API-Key': apiKey, @@ -315,7 +311,7 @@ async function getSessionInfo(fileIdentifier, apiKey) { timeout: 5000, }); - return response.data.find((file) => file.name.startsWith(path))?.lastModified; + return response.data?.lastModified; } catch (error) { logAxiosError({ message: `Error fetching session info: ${error.message}`, @@ -460,6 +456,7 @@ const primeFiles = async (options, apiKey) => { module.exports = { primeFiles, + checkIfActive, getSessionInfo, processCodeOutput, }; diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 7478564292..79b562caec 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -7,6 +7,7 @@ const { GraphEvents, createToolSearch, Constants: AgentConstants, + createBashExecutionTool, createProgrammaticToolCallingTool, } = require('@librechat/agents'); const { @@ -1269,9 +1270,46 @@ async function loadToolsForExecution({ } } + const isBashTool = toolNames.includes(AgentConstants.BASH_TOOL); + if (isBashTool) { + try { + const authValues = await loadAuthValues({ + userId: req.user.id, + authFields: [EnvVar.CODE_API_KEY], + }); + const codeApiKey = authValues[EnvVar.CODE_API_KEY]; + + if (codeApiKey) { + const bashTool = createBashExecutionTool({ apiKey: codeApiKey }); + allLoadedTools.push(bashTool); + } else { + logger.debug('[loadToolsForExecution] bash_tool requested but CODE_API_KEY not available'); + allLoadedTools.push( + toolFn( + async () => [ + 'Code execution is not available. Use the read_file tool instead.', + undefined, + ], + { + name: AgentConstants.BASH_TOOL, + description: 'Bash execution (unavailable - no code API key configured)', + schema: { type: 'object', properties: { command: { type: 'string' } } }, + responseFormat: AgentConstants.CONTENT_AND_ARTIFACT, + }, + ), + ); + } + } catch (error) { + logger.error('[loadToolsForExecution] Error creating bash tool:', error); + } + } + const specialToolNames = new Set([ AgentConstants.TOOL_SEARCH, AgentConstants.PROGRAMMATIC_TOOL_CALLING, + AgentConstants.BASH_TOOL, + AgentConstants.SKILL_TOOL, + AgentConstants.READ_FILE, ]); let ptcOrchestratedToolNames = []; diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx index 1bedcec66f..05f6528145 100644 --- a/client/src/Providers/BadgeRowContext.tsx +++ b/client/src/Providers/BadgeRowContext.tsx @@ -17,6 +17,7 @@ interface BadgeRowContextType { conversationId?: string | null; storageContextKey?: string; agentsConfig?: TAgentsEndpoint | null; + skills: ReturnType; webSearch: ReturnType; artifacts: ReturnType; fileSearch: ReturnType; @@ -100,13 +101,15 @@ export default function BadgeRowProvider({ const webSearchToggleKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${storageSuffix}`; const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${storageSuffix}`; const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${storageSuffix}`; + const skillsToggleKey = `${LocalStorageKeys.LAST_SKILLS_TOGGLE_}${storageSuffix}`; const codeToggleValue = getTimestampedValue(codeToggleKey); const webSearchToggleValue = getTimestampedValue(webSearchToggleKey); const fileSearchToggleValue = getTimestampedValue(fileSearchToggleKey); const artifactsToggleValue = getTimestampedValue(artifactsToggleKey); + const skillsToggleValue = getTimestampedValue(skillsToggleKey); - const initialValues: Record = {}; + const initialValues: Record = {}; if (codeToggleValue !== null) { try { @@ -140,6 +143,14 @@ export default function BadgeRowProvider({ } } + if (skillsToggleValue !== null) { + try { + initialValues[AgentCapabilities.skills] = JSON.parse(skillsToggleValue); + } catch (e) { + console.error('Failed to parse skills toggle value:', e); + } + } + const hasOverrides = Object.keys(initialValues).length > 0; /** Read persisted MCP values from localStorage */ @@ -238,9 +249,19 @@ export default function BadgeRowProvider({ isAuthenticated: true, }); + /** Skills hook - using a custom key since it's not a Tool but a capability */ + const skills = useToolToggle({ + conversationId, + storageContextKey, + toolKey: AgentCapabilities.skills, + localStorageKey: LocalStorageKeys.LAST_SKILLS_TOGGLE_, + isAuthenticated: true, + }); + const mcpServerManager = useMCPServerManager({ conversationId, storageContextKey }); const value: BadgeRowContextType = { + skills, webSearch, artifacts, fileSearch, diff --git a/client/src/components/Chat/Input/BadgeRow.tsx b/client/src/components/Chat/Input/BadgeRow.tsx index 6fea6b0d58..7b7140c9fb 100644 --- a/client/src/components/Chat/Input/BadgeRow.tsx +++ b/client/src/components/Chat/Input/BadgeRow.tsx @@ -21,6 +21,7 @@ import FileSearch from './FileSearch'; import Artifacts from './Artifacts'; import MCPSelect from './MCPSelect'; import WebSearch from './WebSearch'; +import Skills from './Skills'; import store from '~/store'; interface BadgeRowProps { @@ -373,6 +374,7 @@ function BadgeRow({ + diff --git a/client/src/components/Chat/Input/Skills.tsx b/client/src/components/Chat/Input/Skills.tsx new file mode 100644 index 0000000000..2a5706eb5f --- /dev/null +++ b/client/src/components/Chat/Input/Skills.tsx @@ -0,0 +1,36 @@ +import React, { memo } from 'react'; +import { ScrollText } from 'lucide-react'; +import { CheckboxButton } from '@librechat/client'; +import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import { useLocalize, useHasAccess } from '~/hooks'; +import { useBadgeRowContext } from '~/Providers'; + +function Skills() { + const localize = useLocalize(); + const context = useBadgeRowContext(); + const { toggleState: skillsActive, debouncedChange, isPinned } = context?.skills ?? {}; + + const canUseSkills = useHasAccess({ + permissionType: PermissionTypes.SKILLS, + permission: Permissions.USE, + }); + + if (!canUseSkills) { + return null; + } + + return ( + (skillsActive || isPinned) && ( +