mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-31 08:56:48 +00:00
⚙️ feat: Skill runtime integration: catalog, tools, execution, file priming (#12649)
* 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<string, string>)
- 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<string, any> with Record<string, boolean | string>
- #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<string, string> → Record<string, unknown>
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 c85ce2161e.
* 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)
This commit is contained in:
parent
f6ee2ea0ee
commit
64ec5f18b8
32 changed files with 2027 additions and 48 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
43
api/server/services/Endpoints/agents/skillDeps.js
Normal file
43
api/server/services/Endpoints/agents/skillDeps.js
Normal file
|
|
@ -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<object>} Augmented result with skill configurable
|
||||
*/
|
||||
function enrichConfigurable(result, req, accessibleSkillIds, preResolvedCodeApiKey) {
|
||||
return enrichWithSkillConfigurable(
|
||||
result,
|
||||
req,
|
||||
accessibleSkillIds,
|
||||
loadAuthValues,
|
||||
preResolvedCodeApiKey,
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { getSkillToolDeps, enrichWithSkillConfigurable: enrichConfigurable };
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface BadgeRowContextType {
|
|||
conversationId?: string | null;
|
||||
storageContextKey?: string;
|
||||
agentsConfig?: TAgentsEndpoint | null;
|
||||
skills: ReturnType<typeof useToolToggle>;
|
||||
webSearch: ReturnType<typeof useToolToggle>;
|
||||
artifacts: ReturnType<typeof useToolToggle>;
|
||||
fileSearch: ReturnType<typeof useToolToggle>;
|
||||
|
|
@ -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<string, any> = {};
|
||||
const initialValues: Record<string, boolean | string> = {};
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<WebSearch />
|
||||
<CodeInterpreter />
|
||||
<FileSearch />
|
||||
<Skills />
|
||||
<Artifacts />
|
||||
<MCPSelect />
|
||||
</>
|
||||
|
|
|
|||
36
client/src/components/Chat/Input/Skills.tsx
Normal file
36
client/src/components/Chat/Input/Skills.tsx
Normal file
|
|
@ -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) && (
|
||||
<CheckboxButton
|
||||
className="max-w-fit"
|
||||
checked={skillsActive}
|
||||
setValue={debouncedChange}
|
||||
label={localize('com_ui_skills')}
|
||||
isCheckedClassName="border-cyan-600/40 bg-cyan-500/10 hover:bg-cyan-700/10"
|
||||
icon={<ScrollText className="icon-md" aria-hidden="true" />}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Skills);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { Globe, Settings, Settings2, TerminalSquareIcon } from 'lucide-react';
|
||||
import { TooltipAnchor, DropdownPopup, PinIcon, VectorIcon } from '@librechat/client';
|
||||
import { Globe, ScrollText, Settings, Settings2, TerminalSquareIcon } from 'lucide-react';
|
||||
import type { MenuItemProps } from '~/common';
|
||||
import {
|
||||
AuthType,
|
||||
|
|
@ -26,7 +26,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
const context = useBadgeRowContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
const { codeEnabled, webSearchEnabled, artifactsEnabled, fileSearchEnabled } =
|
||||
const { codeEnabled, webSearchEnabled, artifactsEnabled, fileSearchEnabled, skillsEnabled } =
|
||||
useAgentCapabilities(context?.agentsConfig?.capabilities ?? defaultAgentCapabilities);
|
||||
|
||||
const canUseWebSearch = useHasAccess({
|
||||
|
|
@ -49,9 +49,15 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const canUseSkills = useHasAccess({
|
||||
permissionType: PermissionTypes.SKILLS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
const isDisabled = disabled ?? false;
|
||||
const {
|
||||
skills,
|
||||
webSearch,
|
||||
artifacts,
|
||||
fileSearch,
|
||||
|
|
@ -77,6 +83,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
} = codeInterpreter ?? {};
|
||||
const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch ?? {};
|
||||
const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts ?? {};
|
||||
const { isPinned: isSkillsPinned, setIsPinned: setIsSkillsPinned } = skills ?? {};
|
||||
|
||||
const showWebSearchSettings = useMemo(() => {
|
||||
const authTypes = webSearchAuthData?.authTypes ?? [];
|
||||
|
|
@ -131,6 +138,11 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
}
|
||||
}, [artifacts]);
|
||||
|
||||
const handleSkillsToggle = useCallback(() => {
|
||||
const newValue = !skills?.toggleState;
|
||||
skills?.debouncedChange({ value: newValue });
|
||||
}, [skills]);
|
||||
|
||||
const mcpPlaceholder = startupConfig?.interface?.mcpServers?.placeholder;
|
||||
|
||||
const dropdownItems: MenuItemProps[] = [];
|
||||
|
|
@ -221,6 +233,38 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
});
|
||||
}
|
||||
|
||||
if (canUseSkills && skillsEnabled) {
|
||||
dropdownItems.push({
|
||||
onClick: handleSkillsToggle,
|
||||
hideOnClick: false,
|
||||
render: (props) => (
|
||||
<div {...props}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="icon-md" aria-hidden="true" />
|
||||
<span>{localize('com_ui_skills')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSkillsPinned?.(!isSkillsPinned);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded p-1 transition-all duration-200',
|
||||
'hover:bg-surface-secondary hover:shadow-sm',
|
||||
!isSkillsPinned && 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
aria-label={isSkillsPinned ? localize('com_ui_unpin') : localize('com_ui_pin')}
|
||||
>
|
||||
<div className="h-4 w-4">
|
||||
<PinIcon unpin={isSkillsPinned} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (canRunCode && codeEnabled) {
|
||||
dropdownItems.push({
|
||||
onClick: handleCodeInterpreterToggle,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ interface AgentCapabilitiesResult {
|
|||
fileSearchEnabled: boolean;
|
||||
webSearchEnabled: boolean;
|
||||
codeEnabled: boolean;
|
||||
skillsEnabled: boolean;
|
||||
deferredToolsEnabled: boolean;
|
||||
programmaticToolsEnabled: boolean;
|
||||
}
|
||||
|
|
@ -57,6 +58,11 @@ export default function useAgentCapabilities(
|
|||
[capabilities],
|
||||
);
|
||||
|
||||
const skillsEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.skills) ?? false,
|
||||
[capabilities],
|
||||
);
|
||||
|
||||
const deferredToolsEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.deferred_tools) ?? false,
|
||||
[capabilities],
|
||||
|
|
@ -71,6 +77,7 @@ export default function useAgentCapabilities(
|
|||
ocrEnabled,
|
||||
codeEnabled,
|
||||
toolsEnabled,
|
||||
skillsEnabled,
|
||||
actionsEnabled,
|
||||
contextEnabled,
|
||||
artifactsEnabled,
|
||||
|
|
|
|||
189
package-lock.json
generated
189
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -95,7 +95,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/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@smithy/node-http-handler": "^4.4.5",
|
||||
|
|
|
|||
183
packages/api/src/agents/__tests__/skills.test.ts
Normal file
183
packages/api/src/agents/__tests__/skills.test.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/** Mock Constants.SKILL_TOOL since the installed SDK version may not include it yet */
|
||||
jest.mock('@librechat/agents', () => ({
|
||||
...jest.requireActual('@librechat/agents'),
|
||||
Constants: {
|
||||
...(jest.requireActual('@librechat/agents') as { Constants: Record<string, unknown> })
|
||||
.Constants,
|
||||
SKILL_TOOL: 'skill',
|
||||
},
|
||||
}));
|
||||
|
||||
import { extractInvokedSkillsFromPayload } from '../run';
|
||||
|
||||
describe('extractInvokedSkillsFromPayload', () => {
|
||||
it('extracts skill names from assistant messages with skill tool_calls', () => {
|
||||
const payload = [
|
||||
{ role: 'user', content: 'Analyze this' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
tool_call: {
|
||||
id: 'call_1',
|
||||
name: 'skill' /* Constants.SKILL_TOOL */,
|
||||
args: JSON.stringify({ skillName: 'pdf-analyzer' }),
|
||||
output: 'Skill loaded.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = extractInvokedSkillsFromPayload(payload);
|
||||
expect(result).toEqual(new Set(['pdf-analyzer']));
|
||||
});
|
||||
|
||||
it('handles object args (not stringified)', () => {
|
||||
const payload = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
tool_call: {
|
||||
id: 'call_1',
|
||||
name: 'skill' /* Constants.SKILL_TOOL */,
|
||||
args: { skillName: 'code-review' },
|
||||
output: 'Loaded.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = extractInvokedSkillsFromPayload(payload);
|
||||
expect(result).toEqual(new Set(['code-review']));
|
||||
});
|
||||
|
||||
it('returns empty set for no skill tool_calls', () => {
|
||||
const payload = [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
tool_call: { id: 'call_1', name: 'web_search', args: '{}', output: 'Results' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = extractInvokedSkillsFromPayload(payload);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('skips non-assistant messages', () => {
|
||||
const payload = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
tool_call: {
|
||||
id: 'call_1',
|
||||
name: 'skill' /* Constants.SKILL_TOOL */,
|
||||
args: '{"skillName":"x"}',
|
||||
output: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = extractInvokedSkillsFromPayload(payload);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('gracefully handles malformed JSON args', () => {
|
||||
const payload = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
tool_call: {
|
||||
id: 'call_1',
|
||||
name: 'skill' /* Constants.SKILL_TOOL */,
|
||||
args: '{bad json',
|
||||
output: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = extractInvokedSkillsFromPayload(payload);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('skips empty skillName', () => {
|
||||
const payload = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
tool_call: {
|
||||
id: 'call_1',
|
||||
name: 'skill' /* Constants.SKILL_TOOL */,
|
||||
args: '{"skillName":""}',
|
||||
output: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = extractInvokedSkillsFromPayload(payload);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty set for empty payload', () => {
|
||||
expect(extractInvokedSkillsFromPayload([]).size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates across multiple messages', () => {
|
||||
const payload = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
tool_call: {
|
||||
id: 'c1',
|
||||
name: 'skill' /* Constants.SKILL_TOOL */,
|
||||
args: '{"skillName":"pdf"}',
|
||||
output: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
tool_call: {
|
||||
id: 'c2',
|
||||
name: 'skill' /* Constants.SKILL_TOOL */,
|
||||
args: '{"skillName":"pdf"}',
|
||||
output: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = extractInvokedSkillsFromPayload(payload);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.has('pdf')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,19 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { GraphEvents, Constants } from '@librechat/agents';
|
||||
import { GraphEvents, Constants, CODE_EXECUTION_TOOLS } from '@librechat/agents';
|
||||
import type {
|
||||
LCTool,
|
||||
EventHandler,
|
||||
LCToolRegistry,
|
||||
InjectedMessage,
|
||||
ToolCallRequest,
|
||||
ToolExecuteResult,
|
||||
ToolExecuteBatchRequest,
|
||||
} from '@librechat/agents';
|
||||
import type { Types } from 'mongoose';
|
||||
import type { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import type { ServerRequest } from '~/types';
|
||||
import { primeSkillFiles } from './skillFiles';
|
||||
import type { SkillFileRecord } from './skillFiles';
|
||||
import { runOutsideTracing } from '~/utils';
|
||||
|
||||
export interface ToolEndCallbackData {
|
||||
|
|
@ -43,6 +48,446 @@ export interface ToolExecuteOptions {
|
|||
}>;
|
||||
/** Callback to process tool artifacts (code output files, file citations, etc.) */
|
||||
toolEndCallback?: ToolEndCallback;
|
||||
/** Loads a skill by name with ACL constraint (returns full body for injection) */
|
||||
getSkillByName?: (
|
||||
name: string,
|
||||
accessibleIds: Types.ObjectId[],
|
||||
) => Promise<{
|
||||
body: string;
|
||||
name: string;
|
||||
_id: Types.ObjectId;
|
||||
fileCount: number;
|
||||
} | null>;
|
||||
/** Lists files bundled with a skill (for code env priming) */
|
||||
listSkillFiles?: (skillId: Types.ObjectId | string) => Promise<SkillFileRecord[]>;
|
||||
/** Storage strategy resolver for skill file streaming */
|
||||
getStrategyFunctions?: (source: string) => {
|
||||
getDownloadStream?: (req: ServerRequest, filepath: string) => Promise<NodeJS.ReadableStream>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Batch uploads files to the code execution environment */
|
||||
batchUploadCodeEnvFiles?: (params: {
|
||||
req: ServerRequest;
|
||||
files: Array<{ stream: NodeJS.ReadableStream; filename: string }>;
|
||||
apiKey: string;
|
||||
entity_id?: string;
|
||||
}) => Promise<{ session_id: string; files: Array<{ fileId: string; filename: string }> }>;
|
||||
/** Checks if a code env file is still active. Returns lastModified or null. */
|
||||
getSessionInfo?: (fileIdentifier: string, apiKey: string) => Promise<string | null>;
|
||||
/** 23-hour freshness check */
|
||||
checkIfActive?: (dateString: string) => boolean;
|
||||
/** Persists codeEnvIdentifiers on skill files after upload */
|
||||
updateSkillFileCodeEnvIds?: (
|
||||
updates: Array<{
|
||||
skillId: Types.ObjectId | string;
|
||||
relativePath: string;
|
||||
codeEnvIdentifier: string;
|
||||
}>,
|
||||
) => Promise<void>;
|
||||
/** Loads a skill file by path (for read_file tool) */
|
||||
getSkillFileByPath?: (
|
||||
skillId: Types.ObjectId | string,
|
||||
relativePath: string,
|
||||
) => Promise<{
|
||||
content?: string;
|
||||
isBinary?: boolean;
|
||||
mimeType: string;
|
||||
bytes: number;
|
||||
filepath: string;
|
||||
source: string;
|
||||
relativePath: string;
|
||||
} | null>;
|
||||
/** Updates cached content on a skill file (lazy caching after first read) */
|
||||
updateSkillFileContent?: (
|
||||
skillId: Types.ObjectId | string,
|
||||
relativePath: string,
|
||||
update: { content?: string; isBinary?: boolean },
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const MAX_READABLE_BYTES = 262_144;
|
||||
const MAX_BINARY_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_CACHE_BYTES = 512 * 1024;
|
||||
|
||||
const IMAGE_MIMES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||
|
||||
function addLineNumbers(content: string): string {
|
||||
const lines = content.split('\n');
|
||||
const w = String(lines.length).length;
|
||||
return lines.map((l, i) => `${String(i + 1).padStart(w, ' ')} | ${l}`).join('\n');
|
||||
}
|
||||
|
||||
async function handleReadFileCall(
|
||||
tc: ToolCallRequest,
|
||||
mergedConfigurable: Record<string, unknown>,
|
||||
options: ToolExecuteOptions,
|
||||
req?: ServerRequest,
|
||||
): Promise<ToolExecuteResult> {
|
||||
const { getSkillByName, getSkillFileByPath, getStrategyFunctions, updateSkillFileContent } =
|
||||
options;
|
||||
const args = tc.args as { file_path?: string };
|
||||
if (!args.file_path) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: 'file_path is required',
|
||||
};
|
||||
}
|
||||
|
||||
const slashIdx = args.file_path.indexOf('/');
|
||||
if (slashIdx < 1) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: `Invalid file path "${args.file_path}". Use format: {skillName}/{path}`,
|
||||
};
|
||||
}
|
||||
|
||||
const skillName = args.file_path.slice(0, slashIdx);
|
||||
const relativePath = args.file_path.slice(slashIdx + 1);
|
||||
if (!relativePath) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: 'Missing file path after skill name',
|
||||
};
|
||||
}
|
||||
|
||||
if (!getSkillByName) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: 'File reading is not configured',
|
||||
};
|
||||
}
|
||||
|
||||
const accessibleIds = (mergedConfigurable?.accessibleSkillIds as Types.ObjectId[]) ?? [];
|
||||
const skill = await getSkillByName(skillName, accessibleIds);
|
||||
if (!skill) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: `Skill "${skillName}" not found or not accessible`,
|
||||
};
|
||||
}
|
||||
|
||||
// SKILL.md special case: read from skill.body directly
|
||||
if (relativePath === 'SKILL.md') {
|
||||
if (!skill.body) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: `SKILL.md is empty for skill "${skillName}"`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'success',
|
||||
content: `File: ${args.file_path}\n\n${addLineNumbers(skill.body)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!getSkillFileByPath) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: 'File reading is not configured',
|
||||
};
|
||||
}
|
||||
|
||||
const file = await getSkillFileByPath(skill._id, relativePath);
|
||||
if (!file) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: `File not found: "${relativePath}" in skill "${skillName}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Known binary — serve images as artifacts, others as metadata
|
||||
if (file.isBinary === true) {
|
||||
if (IMAGE_MIMES.has(file.mimeType) && file.bytes <= MAX_BINARY_BYTES) {
|
||||
// Stream and return as image artifact (handled below in stream path)
|
||||
} else {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'success',
|
||||
content: `Binary file (${file.mimeType}, ${file.bytes} bytes). Use bash to process: /mnt/data/${args.file_path}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Cached text content
|
||||
if (file.isBinary !== true && file.content != null && file.content !== '') {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'success',
|
||||
content: `File: ${args.file_path} (${file.bytes} bytes)\n\n${addLineNumbers(file.content)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Early size check from DB metadata before streaming
|
||||
const isImage = IMAGE_MIMES.has(file.mimeType);
|
||||
if (!isImage && file.bytes > MAX_READABLE_BYTES) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'success',
|
||||
content: `File "${args.file_path}" is too large to read directly (${file.bytes} bytes, limit: ${MAX_READABLE_BYTES}). Invoke the skill first, then use bash to read it at /mnt/data/${args.file_path}.`,
|
||||
};
|
||||
}
|
||||
if (isImage && file.bytes > MAX_BINARY_BYTES) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'success',
|
||||
content: `File too large (${file.bytes} bytes, limit: ${MAX_BINARY_BYTES}). Use bash to process: /mnt/data/${args.file_path}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Stream from storage
|
||||
if (!getStrategyFunctions || !req) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: 'Storage access not available',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const strategy = getStrategyFunctions(file.source);
|
||||
if (!strategy.getDownloadStream) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: 'Download not supported for this storage backend',
|
||||
};
|
||||
}
|
||||
|
||||
const stream = await strategy.getDownloadStream(req, file.filepath);
|
||||
const chunks: Buffer[] = [];
|
||||
// Use the larger binary limit as streaming cap; cheaper type-specific
|
||||
// checks happen after binary detection on the assembled buffer.
|
||||
const streamLimit = MAX_BINARY_BYTES;
|
||||
let streamedBytes = 0;
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
streamedBytes += chunk.length;
|
||||
if (streamedBytes > streamLimit) {
|
||||
// Destroy the stream if possible to free resources
|
||||
if (
|
||||
'destroy' in stream &&
|
||||
typeof (stream as NodeJS.ReadableStream & { destroy?: () => void }).destroy === 'function'
|
||||
) {
|
||||
(stream as NodeJS.ReadableStream & { destroy: () => void }).destroy();
|
||||
}
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'success',
|
||||
content: `File "${args.file_path}" exceeded streaming limit (${streamLimit} bytes). Invoke the skill first, then use bash to read it at /mnt/data/${args.file_path}.`,
|
||||
};
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
// Binary detection on first 8KB
|
||||
const checkLen = Math.min(buffer.length, 8192);
|
||||
let isBinary = file.isBinary === true;
|
||||
if (!isBinary) {
|
||||
for (let i = 0; i < checkLen; i++) {
|
||||
if (buffer[i] === 0) {
|
||||
isBinary = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isBinary) {
|
||||
// Cache the binary flag (first read only)
|
||||
if (file.isBinary == null && updateSkillFileContent) {
|
||||
updateSkillFileContent(skill._id, relativePath, { isBinary: true }).catch(
|
||||
(err: unknown) => {
|
||||
logger.warn(
|
||||
'[handleReadFileCall] cache write failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Return images/PDFs as artifacts
|
||||
if (IMAGE_MIMES.has(file.mimeType) && buffer.length <= MAX_BINARY_BYTES) {
|
||||
const base64 = buffer.toString('base64');
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'success',
|
||||
content: `Image: ${args.file_path} (${buffer.length} bytes, ${file.mimeType})`,
|
||||
artifact: {
|
||||
content: [
|
||||
{ type: 'image_url', image_url: { url: `data:${file.mimeType};base64,${base64}` } },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: PDF artifact support requires a document content block path
|
||||
// (image_url runs image processing which fails for PDFs). Falls through
|
||||
// to the generic binary handler below.
|
||||
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'success',
|
||||
content: `Binary file (${file.mimeType}, ${buffer.length} bytes). Use bash to process: /mnt/data/${args.file_path}`,
|
||||
};
|
||||
}
|
||||
|
||||
const text = buffer.toString('utf-8');
|
||||
|
||||
// Cache text on first read (skill files are immutable)
|
||||
if (file.content == null && updateSkillFileContent && buffer.length <= MAX_CACHE_BYTES) {
|
||||
updateSkillFileContent(skill._id, relativePath, { content: text, isBinary: false }).catch(
|
||||
(err: unknown) => {
|
||||
logger.warn(
|
||||
'[handleReadFileCall] cache write failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (buffer.length > MAX_READABLE_BYTES) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'success',
|
||||
content: `File too large (${buffer.length} bytes, limit: ${MAX_READABLE_BYTES}). Use bash: cat /mnt/data/${args.file_path}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'success',
|
||||
content: `File: ${args.file_path} (${buffer.length} bytes)\n\n${addLineNumbers(text)}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: `Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSkillToolCall(
|
||||
tc: ToolCallRequest,
|
||||
mergedConfigurable: Record<string, unknown>,
|
||||
options: ToolExecuteOptions,
|
||||
req?: ServerRequest,
|
||||
): Promise<ToolExecuteResult> {
|
||||
const {
|
||||
getSkillByName,
|
||||
listSkillFiles,
|
||||
getStrategyFunctions,
|
||||
batchUploadCodeEnvFiles,
|
||||
getSessionInfo,
|
||||
checkIfActive,
|
||||
updateSkillFileCodeEnvIds,
|
||||
} = options;
|
||||
const args = tc.args as { skillName?: string; args?: string };
|
||||
if (!args.skillName) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: 'skillName is required',
|
||||
};
|
||||
}
|
||||
|
||||
if (!getSkillByName) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: 'Skill execution is not configured',
|
||||
};
|
||||
}
|
||||
|
||||
const accessibleIds = (mergedConfigurable?.accessibleSkillIds as Types.ObjectId[]) ?? [];
|
||||
const skill = await getSkillByName(args.skillName, accessibleIds);
|
||||
|
||||
if (!skill) {
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
status: 'error',
|
||||
content: '',
|
||||
errorMessage: `Skill "${args.skillName}" not found or not accessible`,
|
||||
};
|
||||
}
|
||||
|
||||
let body = skill.body;
|
||||
if (args.args) {
|
||||
body = body.replace(/\$ARGUMENTS/g, args.args);
|
||||
}
|
||||
|
||||
const injectedMessages: InjectedMessage[] = [
|
||||
{ role: 'user', content: body, isMeta: true, source: 'skill', skillName: skill.name },
|
||||
];
|
||||
|
||||
const contentText = `Skill "${args.skillName}" loaded. Follow the instructions below.`;
|
||||
let artifact:
|
||||
| { session_id: string; files: Array<{ id: string; session_id: string; name: string }> }
|
||||
| undefined;
|
||||
|
||||
// Prime skill files to code env when the skill has bundled files
|
||||
if (
|
||||
skill.fileCount > 0 &&
|
||||
req &&
|
||||
listSkillFiles &&
|
||||
getStrategyFunctions &&
|
||||
batchUploadCodeEnvFiles
|
||||
) {
|
||||
const codeApiKey = (mergedConfigurable?.codeApiKey as string) ?? '';
|
||||
if (codeApiKey) {
|
||||
try {
|
||||
const skillFiles = await listSkillFiles(skill._id);
|
||||
const primeResult = await primeSkillFiles({
|
||||
skill,
|
||||
skillFiles,
|
||||
req,
|
||||
apiKey: codeApiKey,
|
||||
getStrategyFunctions,
|
||||
batchUploadCodeEnvFiles,
|
||||
getSessionInfo,
|
||||
checkIfActive,
|
||||
updateSkillFileCodeEnvIds,
|
||||
});
|
||||
if (primeResult) {
|
||||
artifact = primeResult;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[handleSkillToolCall] Failed to prime files for skill "${args.skillName}":`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
content: contentText,
|
||||
status: 'success',
|
||||
artifact,
|
||||
injectedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -70,6 +515,36 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
|
||||
const results: ToolExecuteResult[] = await Promise.all(
|
||||
toolCalls.map(async (tc: ToolCallRequest) => {
|
||||
if (tc.name === Constants.SKILL_TOOL || tc.name === Constants.READ_FILE) {
|
||||
const req = mergedConfigurable?.req as ServerRequest | undefined;
|
||||
const handlerResult =
|
||||
tc.name === Constants.SKILL_TOOL
|
||||
? await handleSkillToolCall(tc, mergedConfigurable, options, req)
|
||||
: await handleReadFileCall(tc, mergedConfigurable, options, req);
|
||||
|
||||
if (toolEndCallback && handlerResult.artifact) {
|
||||
await toolEndCallback(
|
||||
{
|
||||
output: {
|
||||
name: tc.name,
|
||||
tool_call_id: tc.id,
|
||||
content: handlerResult.content,
|
||||
artifact: handlerResult.artifact,
|
||||
},
|
||||
},
|
||||
{
|
||||
run_id: (metadata as Record<string, unknown>)?.run_id as string | undefined,
|
||||
thread_id: (metadata as Record<string, unknown>)?.thread_id as
|
||||
| string
|
||||
| undefined,
|
||||
...metadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return handlerResult;
|
||||
}
|
||||
|
||||
const tool = toolMap.get(tc.name);
|
||||
|
||||
if (!tool) {
|
||||
|
|
@ -91,11 +566,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
turn: tc.turn,
|
||||
};
|
||||
|
||||
if (
|
||||
tc.codeSessionContext &&
|
||||
(tc.name === Constants.EXECUTE_CODE ||
|
||||
tc.name === Constants.PROGRAMMATIC_TOOL_CALLING)
|
||||
) {
|
||||
if (tc.codeSessionContext && CODE_EXECUTION_TOOLS.has(tc.name)) {
|
||||
toolCallConfig.session_id = tc.codeSessionContext.session_id;
|
||||
if (tc.codeSessionContext.files && tc.codeSessionContext.files.length > 0) {
|
||||
toolCallConfig._injected_files = tc.codeSessionContext.files;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ export * from './transactions';
|
|||
export * from './usage';
|
||||
export * from './resources';
|
||||
export * from './responses';
|
||||
export * from './skills';
|
||||
export * from './skillConfigurable';
|
||||
export * from './skillFiles';
|
||||
export * from './run';
|
||||
export * from './tools';
|
||||
export * from './validation';
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
import { filterFilesByEndpointConfig } from '~/files';
|
||||
import { generateArtifactsPrompt } from '~/prompts';
|
||||
import { getProviderConfig } from '~/endpoints';
|
||||
import { injectSkillCatalog } from './skills';
|
||||
import { primeResources } from './resources';
|
||||
import type { TFilterFilesByAgentAccess } from './resources';
|
||||
|
||||
|
|
@ -66,6 +67,10 @@ export type InitializedAgent = Agent & {
|
|||
actionsEnabled?: boolean;
|
||||
/** Maximum characters allowed in a single tool result before truncation. */
|
||||
maxToolResultChars?: number;
|
||||
/** Accessible skill IDs for ACL checking at execute time */
|
||||
accessibleSkillIds?: import('mongoose').Types.ObjectId[];
|
||||
/** Number of skills in the catalog (used to determine if SkillTool should be registered) */
|
||||
skillCount?: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_MAX_CONTEXT_TOKENS = 32000;
|
||||
|
|
@ -114,6 +119,10 @@ export interface InitializeAgentParams {
|
|||
allowedProviders: Set<string>;
|
||||
/** Whether this is the initial agent */
|
||||
isInitialAgent?: boolean;
|
||||
/** Accessible skill IDs for this user (pre-computed by the caller via ACL query) */
|
||||
accessibleSkillIds?: import('mongoose').Types.ObjectId[];
|
||||
/** Whether the code execution environment is available (execute_code capability enabled) */
|
||||
codeEnvAvailable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -145,6 +154,11 @@ export interface InitializeAgentDbMethods extends EndpointDbMethods {
|
|||
parentMessageId?: string;
|
||||
files?: Array<{ file_id: string }>;
|
||||
}> | null>;
|
||||
/** List skill summaries for catalog injection (paginated, omits body/frontmatter) */
|
||||
listSkillsByAccess?: (params: {
|
||||
accessibleIds: import('mongoose').Types.ObjectId[];
|
||||
limit: number;
|
||||
}) => Promise<{ skills: Array<{ name: string; description: string }> }>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -301,7 +315,7 @@ export async function initializeAgent(
|
|||
toolRegistry,
|
||||
toolContextMap,
|
||||
userMCPAuthMap,
|
||||
toolDefinitions,
|
||||
toolDefinitions: loadedToolDefinitions,
|
||||
hasDeferredTools,
|
||||
actionsEnabled,
|
||||
tools: structuredTools,
|
||||
|
|
@ -324,6 +338,8 @@ export async function initializeAgent(
|
|||
actionsEnabled: undefined,
|
||||
};
|
||||
|
||||
let toolDefinitions = loadedToolDefinitions;
|
||||
|
||||
const { getOptions, overrideProvider, customEndpointConfig } = getProviderConfig({
|
||||
provider,
|
||||
appConfig: req.config,
|
||||
|
|
@ -416,6 +432,22 @@ export async function initializeAgent(
|
|||
agent.additional_instructions = artifactsPromptResult ?? undefined;
|
||||
}
|
||||
|
||||
let skillCount = 0;
|
||||
const { accessibleSkillIds } = params;
|
||||
if (accessibleSkillIds && accessibleSkillIds.length > 0) {
|
||||
const skillResult = await injectSkillCatalog({
|
||||
agent,
|
||||
toolDefinitions,
|
||||
toolRegistry,
|
||||
accessibleSkillIds,
|
||||
contextWindowTokens: Number(agentMaxContextTokens) || 200_000,
|
||||
listSkillsByAccess: db?.listSkillsByAccess,
|
||||
codeEnvAvailable: params.codeEnvAvailable,
|
||||
});
|
||||
toolDefinitions = skillResult.toolDefinitions;
|
||||
skillCount = skillResult.skillCount;
|
||||
}
|
||||
|
||||
const agentMaxContextNum = Number(agentMaxContextTokens) || DEFAULT_MAX_CONTEXT_TOKENS;
|
||||
const maxOutputTokensNum = Number(maxOutputTokens) || 0;
|
||||
const baseContextTokens = Math.max(0, agentMaxContextNum - maxOutputTokensNum);
|
||||
|
|
@ -447,6 +479,8 @@ export async function initializeAgent(
|
|||
hasDeferredTools,
|
||||
actionsEnabled,
|
||||
baseContextTokens,
|
||||
skillCount,
|
||||
accessibleSkillIds: params.accessibleSkillIds,
|
||||
attachments: finalAttachments,
|
||||
toolContextMap: toolContextMap ?? {},
|
||||
useLegacyContent: !!options.useLegacyContent,
|
||||
|
|
|
|||
|
|
@ -117,6 +117,62 @@ export function extractDiscoveredToolsFromHistory(messages: BaseMessage[]): Set<
|
|||
return discoveredTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts skill names that were invoked in previous turns from raw message payload.
|
||||
* Scans assistant messages for tool_call content parts where name === 'skill'.
|
||||
* Works with TPayload (raw message objects) so it can run before formatAgentMessages.
|
||||
*
|
||||
* @param payload - The raw conversation message payload
|
||||
* @returns Set of skill names that were previously invoked
|
||||
*/
|
||||
export function extractInvokedSkillsFromPayload(
|
||||
payload: Array<Partial<{ role: string; content: unknown }>>,
|
||||
): Set<string> {
|
||||
const invokedSkills = new Set<string>();
|
||||
|
||||
for (const message of payload) {
|
||||
if (message.role !== 'assistant') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const part of content) {
|
||||
if (
|
||||
part == null ||
|
||||
typeof part !== 'object' ||
|
||||
(part as { type?: string }).type !== 'tool_call'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const toolCall = (part as { tool_call?: { name?: string; args?: unknown } }).tool_call;
|
||||
if (toolCall?.name !== Constants.SKILL_TOOL) {
|
||||
continue;
|
||||
}
|
||||
const rawArgs = toolCall.args;
|
||||
const args =
|
||||
typeof rawArgs === 'string'
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(rawArgs) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})()
|
||||
: (rawArgs as Record<string, unknown> | undefined);
|
||||
const skillName = args?.skillName;
|
||||
if (typeof skillName === 'string' && skillName.length > 0) {
|
||||
invokedSkills.add(skillName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return invokedSkills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides defer_loading to false for tools that were already discovered via tool_search.
|
||||
* This prevents the LLM from having to re-discover tools on every turn.
|
||||
|
|
@ -463,6 +519,7 @@ export async function createRun({
|
|||
tokenCounter,
|
||||
customHandlers,
|
||||
indexTokenCountMap,
|
||||
initialSessions,
|
||||
summarizationConfig,
|
||||
initialSummary,
|
||||
calibrationRatio,
|
||||
|
|
@ -489,9 +546,10 @@ export async function createRun({
|
|||
* (e.g. "Ollama") in the summarization config to SDK-recognized providers.
|
||||
*/
|
||||
appConfig?: AppConfig;
|
||||
} & Pick<RunConfig, 'tokenCounter' | 'customHandlers' | 'indexTokenCountMap'>): Promise<
|
||||
Run<IState>
|
||||
> {
|
||||
} & Pick<
|
||||
RunConfig,
|
||||
'tokenCounter' | 'customHandlers' | 'indexTokenCountMap' | 'initialSessions'
|
||||
>): Promise<Run<IState>> {
|
||||
/**
|
||||
* Only extract discovered tools if:
|
||||
* 1. We have message history to parse
|
||||
|
|
@ -641,6 +699,7 @@ export async function createRun({
|
|||
tokenCounter,
|
||||
customHandlers,
|
||||
indexTokenCountMap,
|
||||
initialSessions,
|
||||
calibrationRatio,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
43
packages/api/src/agents/skillConfigurable.ts
Normal file
43
packages/api/src/agents/skillConfigurable.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { EnvVar } from '@librechat/agents';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
|
||||
/**
|
||||
* Augments a loadTools result with skill-specific configurable properties.
|
||||
* Loads the code API key and merges it with accessibleSkillIds and the request object.
|
||||
*/
|
||||
export async function enrichWithSkillConfigurable(
|
||||
result: { loadedTools: unknown[]; configurable?: Record<string, unknown> },
|
||||
req: { user?: { id?: string } },
|
||||
accessibleSkillIds: unknown[],
|
||||
loadAuthValues: (params: {
|
||||
userId: string;
|
||||
authFields: string[];
|
||||
}) => Promise<Record<string, string>>,
|
||||
/** Pre-resolved code API key. When provided, loadAuthValues is skipped. */
|
||||
preResolvedCodeApiKey?: string,
|
||||
): Promise<{ loadedTools: unknown[]; configurable: Record<string, unknown> }> {
|
||||
let codeApiKey: string | undefined = preResolvedCodeApiKey;
|
||||
if (!codeApiKey) {
|
||||
try {
|
||||
const authValues = await loadAuthValues({
|
||||
userId: req.user?.id ?? '',
|
||||
authFields: [EnvVar.CODE_API_KEY],
|
||||
});
|
||||
codeApiKey = authValues[EnvVar.CODE_API_KEY];
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
'[enrichWithSkillConfigurable] loadAuthValues failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
configurable: {
|
||||
...result.configurable,
|
||||
req,
|
||||
codeApiKey,
|
||||
accessibleSkillIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
434
packages/api/src/agents/skillFiles.ts
Normal file
434
packages/api/src/agents/skillFiles.ts
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import { Readable } from 'stream';
|
||||
import { Constants, EnvVar } from '@librechat/agents';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type { ToolSessionMap, CodeSessionContext } from '@librechat/agents';
|
||||
import type { Types } from 'mongoose';
|
||||
import type { ServerRequest } from '~/types';
|
||||
import { extractInvokedSkillsFromPayload } from './run';
|
||||
|
||||
export interface SkillFileRecord {
|
||||
relativePath: string;
|
||||
filename: string;
|
||||
filepath: string;
|
||||
source: string;
|
||||
bytes: number;
|
||||
codeEnvIdentifier?: string;
|
||||
}
|
||||
|
||||
export interface PrimeSkillFilesParams {
|
||||
skill: { body: string; name: string; _id: Types.ObjectId | string };
|
||||
skillFiles: SkillFileRecord[];
|
||||
req: ServerRequest;
|
||||
apiKey: string;
|
||||
getStrategyFunctions: (source: string) => {
|
||||
getDownloadStream?: (req: ServerRequest, filepath: string) => Promise<NodeJS.ReadableStream>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
batchUploadCodeEnvFiles: (params: {
|
||||
req: ServerRequest;
|
||||
files: Array<{ stream: NodeJS.ReadableStream; filename: string }>;
|
||||
apiKey: string;
|
||||
entity_id?: string;
|
||||
}) => Promise<{
|
||||
session_id: string;
|
||||
files: Array<{ fileId: string; filename: string }>;
|
||||
}>;
|
||||
/** Checks if a code env file is still active. Returns lastModified timestamp or null. */
|
||||
getSessionInfo?: (fileIdentifier: string, apiKey: string) => Promise<string | null>;
|
||||
/** 23-hour freshness check */
|
||||
checkIfActive?: (dateString: string) => boolean;
|
||||
/** Persists codeEnvIdentifier on skill files after upload */
|
||||
updateSkillFileCodeEnvIds?: (
|
||||
updates: Array<{
|
||||
skillId: Types.ObjectId | string;
|
||||
relativePath: string;
|
||||
codeEnvIdentifier: string;
|
||||
}>,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PrimeSkillFilesResult {
|
||||
session_id: string;
|
||||
files: Array<{ id: string; session_id: string; name: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads skill files to the code execution environment.
|
||||
*
|
||||
* Smart re-upload: if skill files have existing codeEnvIdentifiers,
|
||||
* checks session freshness first. If the session is still active,
|
||||
* returns cached references. Otherwise batch-uploads everything.
|
||||
*
|
||||
* After upload, persists new codeEnvIdentifiers on the SkillFile
|
||||
* documents for future freshness checks.
|
||||
*/
|
||||
export async function primeSkillFiles(
|
||||
params: PrimeSkillFilesParams,
|
||||
): Promise<PrimeSkillFilesResult | null> {
|
||||
const {
|
||||
skill,
|
||||
skillFiles,
|
||||
req,
|
||||
apiKey,
|
||||
getStrategyFunctions,
|
||||
batchUploadCodeEnvFiles,
|
||||
getSessionInfo,
|
||||
checkIfActive,
|
||||
updateSkillFileCodeEnvIds,
|
||||
} = params;
|
||||
|
||||
// Check if ALL existing sessions are still active before reusing cached refs.
|
||||
// Files normally share one session, but a partial bulkWrite failure during
|
||||
// updateSkillFileCodeEnvIds can leave mixed session IDs. Checking every
|
||||
// distinct session prevents serving stale identifiers that 404 in code env.
|
||||
if (getSessionInfo && checkIfActive && skillFiles.length > 0) {
|
||||
// All files must have identifiers for the cache to be complete.
|
||||
// Any missing identifier means partial persistence — fall through to re-upload.
|
||||
const allHaveIds = skillFiles.every((sf) => sf.codeEnvIdentifier);
|
||||
if (allHaveIds) {
|
||||
const sessionIds = new Set(
|
||||
skillFiles.map((sf) => (sf.codeEnvIdentifier as string).split('?')[0].split('/')[0]),
|
||||
);
|
||||
|
||||
try {
|
||||
const checkResults = await Promise.all(
|
||||
Array.from(sessionIds).map(async (sid) => {
|
||||
const representative = skillFiles.find((sf) =>
|
||||
sf.codeEnvIdentifier!.startsWith(`${sid}/`),
|
||||
);
|
||||
if (!representative) {
|
||||
return false;
|
||||
}
|
||||
const lastModified = await getSessionInfo(representative.codeEnvIdentifier!, apiKey);
|
||||
return !!(lastModified && checkIfActive(lastModified));
|
||||
}),
|
||||
);
|
||||
const allActive = checkResults.every(Boolean);
|
||||
|
||||
if (allActive) {
|
||||
const files: PrimeSkillFilesResult['files'] = [];
|
||||
for (const sf of skillFiles) {
|
||||
const [sid, fid] = (sf.codeEnvIdentifier as string).split('?')[0].split('/');
|
||||
files.push({ id: fid, session_id: sid, name: `${skill.name}/${sf.relativePath}` });
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
logger.debug(
|
||||
`[primeSkillFiles] All ${sessionIds.size} session(s) active for skill "${skill.name}", reusing ${files.length} files`,
|
||||
);
|
||||
return { session_id: files[0].session_id, files };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Session check failed — fall through to re-upload
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect streams for batch upload
|
||||
const filesToUpload: Array<{ stream: NodeJS.ReadableStream; filename: string }> = [];
|
||||
|
||||
// SKILL.md from the skill body
|
||||
const bodyBuffer = Buffer.from(skill.body, 'utf-8');
|
||||
filesToUpload.push({ stream: Readable.from(bodyBuffer), filename: `${skill.name}/SKILL.md` });
|
||||
|
||||
// Bundled files from storage (parallel stream acquisition)
|
||||
const streamResults = await Promise.allSettled(
|
||||
skillFiles.map(async (file) => {
|
||||
const strategy = getStrategyFunctions(file.source);
|
||||
if (!strategy.getDownloadStream) {
|
||||
logger.warn(
|
||||
`[primeSkillFiles] No download stream for "${file.relativePath}" (source: ${file.source})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const stream = await strategy.getDownloadStream(req, file.filepath);
|
||||
return { stream, filename: `${skill.name}/${file.relativePath}` };
|
||||
}),
|
||||
);
|
||||
for (const result of streamResults) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
filesToUpload.push(result.value);
|
||||
} else if (result.status === 'rejected') {
|
||||
logger.error('[primeSkillFiles] Failed to get stream:', result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
if (filesToUpload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const entityId = skill._id.toString();
|
||||
const result = await batchUploadCodeEnvFiles({
|
||||
req,
|
||||
files: filesToUpload,
|
||||
apiKey,
|
||||
entity_id: entityId,
|
||||
});
|
||||
// Exclude SKILL.md from the returned files array — it is uploaded to disk
|
||||
// for bash access but has no codeEnvIdentifier (cannot be cached). Omitting
|
||||
// it here keeps the fresh-upload and cache-hit code paths consistent.
|
||||
const files = result.files
|
||||
.filter((f) => !f.filename.endsWith('/SKILL.md'))
|
||||
.map((f) => ({
|
||||
id: f.fileId,
|
||||
session_id: result.session_id,
|
||||
name: f.filename,
|
||||
}));
|
||||
|
||||
// Treat partial upload failures as a priming failure — missing bundled
|
||||
// files cause follow-up bash/read calls to fail at runtime with missing paths.
|
||||
const expectedCount = filesToUpload.filter((f) => !f.filename.endsWith('/SKILL.md')).length;
|
||||
if (files.length < expectedCount) {
|
||||
const uploadedNames = new Set(result.files.map((f) => f.filename));
|
||||
const missingNames = filesToUpload
|
||||
.filter((f) => !f.filename.endsWith('/SKILL.md') && !uploadedNames.has(f.filename))
|
||||
.map((f) => f.filename);
|
||||
logger.error(
|
||||
`[primeSkillFiles] Partial upload failure for skill "${skill.name}": ${missingNames.length} file(s) missing: ${missingNames.join(', ')}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Persist codeEnvIdentifiers on skill files (fire-and-forget)
|
||||
if (updateSkillFileCodeEnvIds) {
|
||||
const updates = result.files
|
||||
.filter((f) => !f.filename.endsWith('/SKILL.md'))
|
||||
.map((f) => ({
|
||||
skillId: skill._id,
|
||||
relativePath: f.filename.slice(f.filename.indexOf('/') + 1),
|
||||
codeEnvIdentifier: `${result.session_id}/${f.fileId}?entity_id=${entityId}`,
|
||||
}));
|
||||
if (updates.length > 0) {
|
||||
updateSkillFileCodeEnvIds(updates).catch((err: unknown) => {
|
||||
logger.warn(
|
||||
'[primeSkillFiles] Failed to persist codeEnvIdentifiers:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { session_id: result.session_id, files };
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[primeSkillFiles] Batch upload failed for skill "${skill.name}":`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PrimeInvokedSkillsDeps {
|
||||
req: ServerRequest;
|
||||
/** Raw message payload (before formatAgentMessages). Used to extract invoked skill names. */
|
||||
payload: Array<Partial<{ role: string; content: unknown }>>;
|
||||
accessibleSkillIds: Types.ObjectId[];
|
||||
/** Pre-resolved code API key. When provided, loadAuthValues is not called (avoids redundant lookups). */
|
||||
codeApiKey?: string;
|
||||
loadAuthValues: (params: {
|
||||
userId: string;
|
||||
authFields: string[];
|
||||
}) => Promise<Record<string, string>>;
|
||||
getSkillByName: (
|
||||
name: string,
|
||||
accessibleIds: Types.ObjectId[],
|
||||
) => Promise<{ body: string; name: string; _id: Types.ObjectId; fileCount: number } | null>;
|
||||
listSkillFiles: (skillId: Types.ObjectId | string) => Promise<SkillFileRecord[]>;
|
||||
getStrategyFunctions: PrimeSkillFilesParams['getStrategyFunctions'];
|
||||
batchUploadCodeEnvFiles: PrimeSkillFilesParams['batchUploadCodeEnvFiles'];
|
||||
getSessionInfo?: PrimeSkillFilesParams['getSessionInfo'];
|
||||
checkIfActive?: PrimeSkillFilesParams['checkIfActive'];
|
||||
updateSkillFileCodeEnvIds?: PrimeSkillFilesParams['updateSkillFileCodeEnvIds'];
|
||||
}
|
||||
|
||||
export interface PrimeInvokedSkillsResult {
|
||||
initialSessions?: ToolSessionMap;
|
||||
/** Pre-resolved skill bodies keyed by skill name. Passed to formatAgentMessages
|
||||
* so it can reconstruct HumanMessages at the right position in the message sequence. */
|
||||
skills?: Map<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts previously invoked skills from message history, resolves their
|
||||
* bodies from DB, and re-primes their files to the code env.
|
||||
*
|
||||
* Returns:
|
||||
* - initialSessions: seeds Graph.sessions so ToolNode injects session_id into bash/code tools
|
||||
* - skills: Map of skillName → body for formatAgentMessages to reconstruct HumanMessages
|
||||
*/
|
||||
export async function primeInvokedSkills(
|
||||
deps: PrimeInvokedSkillsDeps,
|
||||
): Promise<PrimeInvokedSkillsResult> {
|
||||
if (!deps.payload?.length || !deps.accessibleSkillIds?.length) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const invokedSkills = extractInvokedSkillsFromPayload(deps.payload);
|
||||
if (invokedSkills.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let apiKey = deps.codeApiKey ?? '';
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const authValues = await deps.loadAuthValues({
|
||||
userId: deps.req.user?.id ?? '',
|
||||
authFields: [EnvVar.CODE_API_KEY],
|
||||
});
|
||||
apiKey = authValues[EnvVar.CODE_API_KEY] ?? '';
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
'[primeInvokedSkills] loadAuthValues failed:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const skills = new Map<string, string>();
|
||||
|
||||
// Phase 1: Resolve all skills in parallel (DB lookups)
|
||||
const resolveResults = await Promise.allSettled(
|
||||
Array.from(invokedSkills).map(async (skillName) => {
|
||||
const skill = await deps.getSkillByName(skillName, deps.accessibleSkillIds);
|
||||
return skill ?? undefined;
|
||||
}),
|
||||
);
|
||||
|
||||
const resolvedSkills: Array<{
|
||||
body: string;
|
||||
name: string;
|
||||
_id: Types.ObjectId;
|
||||
fileCount: number;
|
||||
}> = [];
|
||||
for (const r of resolveResults) {
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
skills.set(r.value.name, r.value.body);
|
||||
resolvedSkills.push(r.value);
|
||||
} else if (r.status === 'rejected') {
|
||||
logger.warn('[primeInvokedSkills] Skill resolution failed:', r.reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Single batch upload for ALL skills' files (shared session)
|
||||
let sessions: ToolSessionMap | undefined;
|
||||
const skillsWithFiles = resolvedSkills.filter((s) => s.fileCount > 0);
|
||||
|
||||
if (apiKey && skillsWithFiles.length > 0) {
|
||||
// Parallel file list lookups (R2 fix)
|
||||
const fileListResults = await Promise.all(
|
||||
skillsWithFiles.map(async (skill) => ({
|
||||
skill,
|
||||
files: await deps.listSkillFiles(skill._id),
|
||||
})),
|
||||
);
|
||||
|
||||
// Session freshness check: the code env natively handles mixed sessions
|
||||
// (each file carries its own session_id, fetched independently). We check
|
||||
// ALL distinct sessions for freshness. If all are active, return cached
|
||||
// references with zero re-uploads. If any expired, re-upload everything.
|
||||
if (deps.getSessionInfo && deps.checkIfActive) {
|
||||
const allFiles = fileListResults.flatMap((r) => r.files);
|
||||
const allFilesWithIds = allFiles.filter((f) => f.codeEnvIdentifier);
|
||||
|
||||
// Only use cache when ALL files have identifiers (no partial persistence)
|
||||
if (allFilesWithIds.length > 0 && allFilesWithIds.length === allFiles.length) {
|
||||
const sessionIds = new Set(
|
||||
allFilesWithIds.map((f) => f.codeEnvIdentifier!.split('?')[0].split('/')[0]),
|
||||
);
|
||||
|
||||
const checkResults = await Promise.all(
|
||||
Array.from(sessionIds).map(async (sid) => {
|
||||
const representative = allFilesWithIds.find((f) =>
|
||||
f.codeEnvIdentifier!.startsWith(`${sid}/`),
|
||||
);
|
||||
if (!representative) return true;
|
||||
try {
|
||||
const lastModified = await deps.getSessionInfo?.(
|
||||
representative.codeEnvIdentifier!,
|
||||
apiKey,
|
||||
);
|
||||
return !!(lastModified && deps.checkIfActive?.(lastModified));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const allActive = checkResults.every(Boolean);
|
||||
|
||||
if (allActive) {
|
||||
const cachedFiles = fileListResults.flatMap((r) =>
|
||||
r.files
|
||||
.filter((f) => f.codeEnvIdentifier)
|
||||
.map((f) => {
|
||||
const [sid, fid] = (f.codeEnvIdentifier as string).split('?')[0].split('/');
|
||||
return { id: fid, name: `${r.skill.name}/${f.relativePath}`, session_id: sid };
|
||||
}),
|
||||
);
|
||||
if (cachedFiles.length > 0) {
|
||||
logger.debug(
|
||||
`[primeInvokedSkills] All ${sessionIds.size} session(s) active, reusing ${cachedFiles.length} cached files`,
|
||||
);
|
||||
sessions = new Map();
|
||||
// session_id is a representative value. ToolNode uses per-file
|
||||
// session_id from the files array (file.session_id ?? codeSession.session_id).
|
||||
sessions.set(Constants.EXECUTE_CODE, {
|
||||
session_id: cachedFiles[0].session_id,
|
||||
files: cachedFiles,
|
||||
lastUpdated: Date.now(),
|
||||
} satisfies CodeSessionContext);
|
||||
return { initialSessions: sessions, skills: skills.size > 0 ? skills : undefined };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-skill upload: each skill gets its own session with entity_id=skillId.
|
||||
// primeSkillFiles handles freshness caching per-skill, so only expired
|
||||
// skills re-upload. The code env handles mixed session_ids natively.
|
||||
const allPrimedFiles: Array<{ id: string; name: string; session_id: string }> = [];
|
||||
const primeResults = await Promise.allSettled(
|
||||
fileListResults.map(async ({ skill, files }) => {
|
||||
const result = await primeSkillFiles({
|
||||
skill,
|
||||
skillFiles: files,
|
||||
req: deps.req,
|
||||
apiKey,
|
||||
getStrategyFunctions: deps.getStrategyFunctions,
|
||||
batchUploadCodeEnvFiles: deps.batchUploadCodeEnvFiles,
|
||||
getSessionInfo: deps.getSessionInfo,
|
||||
checkIfActive: deps.checkIfActive,
|
||||
updateSkillFileCodeEnvIds: deps.updateSkillFileCodeEnvIds,
|
||||
});
|
||||
return { skill, result };
|
||||
}),
|
||||
);
|
||||
for (const r of primeResults) {
|
||||
if (r.status === 'fulfilled' && r.value.result) {
|
||||
for (const f of r.value.result.files) {
|
||||
allPrimedFiles.push({ id: f.id, name: f.name, session_id: f.session_id });
|
||||
}
|
||||
} else if (r.status === 'rejected') {
|
||||
logger.warn('[primeInvokedSkills] Failed to prime skill files:', r.reason);
|
||||
}
|
||||
}
|
||||
|
||||
if (allPrimedFiles.length > 0) {
|
||||
sessions = new Map();
|
||||
// session_id is a representative value (first skill's session). ToolNode
|
||||
// uses per-file session_id from the files array (file.session_id ??
|
||||
// codeSession.session_id), so mixed sessions work correctly.
|
||||
sessions.set(Constants.EXECUTE_CODE, {
|
||||
session_id: allPrimedFiles[0].session_id,
|
||||
files: allPrimedFiles,
|
||||
lastUpdated: Date.now(),
|
||||
} satisfies CodeSessionContext);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initialSessions: sessions,
|
||||
skills: skills.size > 0 ? skills : undefined,
|
||||
};
|
||||
}
|
||||
131
packages/api/src/agents/skills.ts
Normal file
131
packages/api/src/agents/skills.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import {
|
||||
formatSkillCatalog,
|
||||
SkillToolDefinition,
|
||||
ReadFileToolDefinition,
|
||||
BashExecutionToolDefinition,
|
||||
} from '@librechat/agents';
|
||||
import type { LCToolRegistry, LCTool } from '@librechat/agents';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type { Types } from 'mongoose';
|
||||
import type { Agent } from 'librechat-data-provider';
|
||||
import type { InitializeAgentDbMethods } from './initialize';
|
||||
|
||||
const SKILL_CATALOG_LIMIT = 100;
|
||||
|
||||
export interface InjectSkillCatalogParams {
|
||||
agent: Agent;
|
||||
toolDefinitions: LCTool[] | undefined;
|
||||
toolRegistry: LCToolRegistry | undefined;
|
||||
accessibleSkillIds: Types.ObjectId[];
|
||||
contextWindowTokens: number;
|
||||
listSkillsByAccess: InitializeAgentDbMethods['listSkillsByAccess'];
|
||||
/** When true, registers bash_tool alongside skill + read_file. */
|
||||
codeEnvAvailable?: boolean;
|
||||
}
|
||||
|
||||
export interface InjectSkillCatalogResult {
|
||||
toolDefinitions: LCTool[] | undefined;
|
||||
skillCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries accessible skills, formats a budget-aware catalog, appends it to the
|
||||
* agent's additional_instructions, and registers the SkillTool definition.
|
||||
* Returns updated toolDefinitions and the skill count.
|
||||
*
|
||||
* No tool instance is created — SkillTool is event-driven only. The tool
|
||||
* definition in toolDefinitions is sufficient for the LLM to see and call it;
|
||||
* the host handler intercepts the call via ON_TOOL_EXECUTE.
|
||||
*
|
||||
* The caller is responsible for gating on the skills capability before calling.
|
||||
*/
|
||||
export async function injectSkillCatalog(
|
||||
params: InjectSkillCatalogParams,
|
||||
): Promise<InjectSkillCatalogResult> {
|
||||
const {
|
||||
agent,
|
||||
toolDefinitions: inputDefs,
|
||||
toolRegistry,
|
||||
accessibleSkillIds,
|
||||
contextWindowTokens,
|
||||
listSkillsByAccess,
|
||||
codeEnvAvailable,
|
||||
} = params;
|
||||
|
||||
if (!listSkillsByAccess || accessibleSkillIds.length === 0) {
|
||||
return { toolDefinitions: inputDefs, skillCount: 0 };
|
||||
}
|
||||
|
||||
const { skills } = await listSkillsByAccess({
|
||||
accessibleIds: accessibleSkillIds,
|
||||
limit: SKILL_CATALOG_LIMIT,
|
||||
});
|
||||
|
||||
if (skills.length === SKILL_CATALOG_LIMIT) {
|
||||
logger.warn(
|
||||
`[injectSkillCatalog] Skill catalog reached limit of ${SKILL_CATALOG_LIMIT}. Some skills may be excluded.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (skills.length === 0) {
|
||||
return { toolDefinitions: inputDefs, skillCount: 0 };
|
||||
}
|
||||
|
||||
// Warn on duplicate names — model may invoke the wrong skill
|
||||
const nameCount = new Map<string, number>();
|
||||
for (const s of skills) {
|
||||
nameCount.set(s.name, (nameCount.get(s.name) ?? 0) + 1);
|
||||
}
|
||||
for (const [dupName, count] of nameCount) {
|
||||
if (count > 1) {
|
||||
logger.warn(
|
||||
`[injectSkillCatalog] ${count} accessible skills share name "${dupName}" — model may invoke the wrong one`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const catalog = formatSkillCatalog(
|
||||
skills.map((s) => ({ name: s.name, description: s.description })),
|
||||
{ contextWindowTokens: contextWindowTokens || 200_000 },
|
||||
);
|
||||
|
||||
if (catalog) {
|
||||
agent.additional_instructions = agent.additional_instructions
|
||||
? `${agent.additional_instructions}\n\n${catalog}`
|
||||
: catalog;
|
||||
}
|
||||
|
||||
const skillToolDef: LCTool = {
|
||||
name: SkillToolDefinition.name,
|
||||
description: SkillToolDefinition.description,
|
||||
parameters: SkillToolDefinition.parameters as unknown as LCTool['parameters'],
|
||||
};
|
||||
|
||||
const readFileDef: LCTool = {
|
||||
name: ReadFileToolDefinition.name,
|
||||
description: ReadFileToolDefinition.description,
|
||||
parameters: ReadFileToolDefinition.parameters as unknown as LCTool['parameters'],
|
||||
responseFormat: ReadFileToolDefinition.responseFormat,
|
||||
};
|
||||
|
||||
const bashToolDef: LCTool = {
|
||||
name: BashExecutionToolDefinition.name,
|
||||
description: BashExecutionToolDefinition.description,
|
||||
parameters: BashExecutionToolDefinition.schema as unknown as LCTool['parameters'],
|
||||
};
|
||||
|
||||
// Always register skill + read_file; only register bash_tool when code env is available
|
||||
const defs: LCTool[] = [skillToolDef, readFileDef];
|
||||
if (codeEnvAvailable) {
|
||||
defs.push(bashToolDef);
|
||||
}
|
||||
|
||||
const toolDefinitions = [...(inputDefs ?? []), ...defs];
|
||||
if (toolRegistry) {
|
||||
for (const def of defs) {
|
||||
toolRegistry.set(def.name, def);
|
||||
}
|
||||
}
|
||||
|
||||
return { toolDefinitions, skillCount: skills.length };
|
||||
}
|
||||
|
|
@ -225,6 +225,7 @@ export enum AgentCapabilities {
|
|||
artifacts = 'artifacts',
|
||||
actions = 'actions',
|
||||
context = 'context',
|
||||
skills = 'skills',
|
||||
tools = 'tools',
|
||||
chain = 'chain',
|
||||
ocr = 'ocr',
|
||||
|
|
@ -314,6 +315,7 @@ export const defaultAgentCapabilities = [
|
|||
AgentCapabilities.artifacts,
|
||||
AgentCapabilities.actions,
|
||||
AgentCapabilities.context,
|
||||
AgentCapabilities.skills,
|
||||
AgentCapabilities.tools,
|
||||
AgentCapabilities.chain,
|
||||
AgentCapabilities.ocr,
|
||||
|
|
@ -1966,6 +1968,8 @@ export enum LocalStorageKeys {
|
|||
LAST_FILE_SEARCH_TOGGLE_ = 'LAST_FILE_SEARCH_TOGGLE_',
|
||||
/** Last checked toggle for Artifacts per conversation ID */
|
||||
LAST_ARTIFACTS_TOGGLE_ = 'LAST_ARTIFACTS_TOGGLE_',
|
||||
/** Last checked toggle for Skills per conversation ID */
|
||||
LAST_SKILLS_TOGGLE_ = 'LAST_SKILLS_TOGGLE_',
|
||||
/** Key for the last selected agent provider */
|
||||
LAST_AGENT_PROVIDER = 'lastAgentProvider',
|
||||
/** Key for the last selected agent model */
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ export type TEphemeralAgent = {
|
|||
file_search?: boolean;
|
||||
execute_code?: boolean;
|
||||
artifacts?: string;
|
||||
skills?: boolean;
|
||||
};
|
||||
|
||||
export type TPayload = Partial<TMessage> &
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
ISkillSummary,
|
||||
} from '~/types/skill';
|
||||
import { isValidObjectIdString } from '~/utils/objectId';
|
||||
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
|
||||
import { escapeRegExp } from '~/utils/string';
|
||||
import logger from '~/config/winston';
|
||||
|
||||
|
|
@ -613,6 +614,18 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk
|
|||
return (doc as unknown as (ISkill & { _id: Types.ObjectId }) | null) ?? null;
|
||||
}
|
||||
|
||||
async function getSkillByName(
|
||||
name: string,
|
||||
accessibleIds: Types.ObjectId[],
|
||||
): Promise<(ISkill & { _id: Types.ObjectId }) | null> {
|
||||
const Skill = mongoose.models.Skill as Model<ISkillDocument>;
|
||||
// sort by updatedAt desc for deterministic result when multiple skills share a name
|
||||
const doc = await Skill.findOne({ name, _id: { $in: accessibleIds } })
|
||||
.sort({ updatedAt: -1 })
|
||||
.lean();
|
||||
return (doc as unknown as (ISkill & { _id: Types.ObjectId }) | null) ?? null;
|
||||
}
|
||||
|
||||
async function listSkillsByAccess(
|
||||
params: ListSkillsByAccessParams,
|
||||
): Promise<ListSkillsByAccessResult> {
|
||||
|
|
@ -842,7 +855,7 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk
|
|||
author: row.author,
|
||||
tenantId: row.tenantId,
|
||||
},
|
||||
$unset: { content: '', isBinary: '' },
|
||||
$unset: { content: '', isBinary: '', codeEnvIdentifier: '' },
|
||||
},
|
||||
{ new: false, upsert: true },
|
||||
).lean();
|
||||
|
|
@ -895,11 +908,28 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk
|
|||
await SkillFile.updateOne({ skillId, relativePath }, { $set: update });
|
||||
}
|
||||
|
||||
// deletion controller actually call. The per-skill file cascade on
|
||||
// `deleteSkill` is inlined; there's no need for a separate export.
|
||||
async function updateSkillFileCodeEnvIds(
|
||||
updates: Array<{
|
||||
skillId: Types.ObjectId | string;
|
||||
relativePath: string;
|
||||
codeEnvIdentifier: string;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
if (updates.length === 0) return;
|
||||
const SkillFile = mongoose.models.SkillFile as Model<ISkillFileDocument>;
|
||||
const ops = updates.map((u) => ({
|
||||
updateOne: {
|
||||
filter: { skillId: u.skillId, relativePath: u.relativePath },
|
||||
update: { $set: { codeEnvIdentifier: u.codeEnvIdentifier } },
|
||||
},
|
||||
}));
|
||||
await tenantSafeBulkWrite(SkillFile, ops);
|
||||
}
|
||||
|
||||
return {
|
||||
createSkill,
|
||||
getSkillById,
|
||||
getSkillByName,
|
||||
listSkillsByAccess,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
|
|
@ -909,6 +939,7 @@ export function createSkillMethods(mongoose: typeof import('mongoose'), deps: Sk
|
|||
deleteSkillFile,
|
||||
getSkillFileByPath,
|
||||
updateSkillFileContent,
|
||||
updateSkillFileCodeEnvIds,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,9 @@ const skillFileSchema: Schema<ISkillFileDocument> = new Schema(
|
|||
isBinary: {
|
||||
type: Boolean,
|
||||
},
|
||||
codeEnvIdentifier: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
|
|
|
|||
|
|
@ -94,6 +94,12 @@ export interface ISkillFile {
|
|||
content?: string;
|
||||
/** Set on first read. `true` prevents repeated storage reads for non-text files. */
|
||||
isBinary?: boolean;
|
||||
/**
|
||||
* Code environment file identifier (`session_id/fileId`).
|
||||
* Set after uploading to code env, used to check freshness on subsequent runs.
|
||||
* Cleared when the skill file is re-uploaded to storage.
|
||||
*/
|
||||
codeEnvIdentifier?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue