mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🧰 refactor: Unify code-execution tools (#12767)
* 🛠️ feat: Add registerCodeExecutionTools helper Idempotently registers `bash_tool` + `read_file` in the run's tool registry and tool-definition list via a registry `.has()` dedupe. Sets up the single code-execution tool path shared by: - `initializeAgent` (when an agent has `execute_code` in its tools and the capability is enabled for the run) - `injectSkillCatalog` (when skills are active; unconditional read_file, bash_tool follows `codeEnvAvailable`) Both callers reach the helper in the same initialization sequence, so the second call becomes a no-op and exactly one copy of each tool reaches the LLM — no more double registration for agents that combine `execute_code` capability with active skills. Unit-tested on a fresh run, idempotence (second call, overlap with prior tooldefs, partial overlap), and the no-registry variant. * 🔀 refactor: Route injectSkillCatalog bash_tool + read_file through registerCodeExecutionTools The `skill` tool is still registered inline (it's skill-path-specific), but `bash_tool` + `read_file` now flow through the shared idempotent helper so a prior registration from the execute_code path doesn't produce a duplicate copy later in the same run. Behavior preserved: - `read_file` always registers when any active skill is in scope — manually-primed `disable-model-invocation: true` skills still need it to load `references/*` from storage. - `bash_tool` follows `codeEnvAvailable` exactly as before. Adds a test pinning the cross-call dedupe: when `injectSkillCatalog` runs AFTER `registerCodeExecutionTools` has already seeded the registry + tool definitions with bash_tool/read_file, the resulting `toolDefinitions` still contains exactly one copy of each. * 🪄 feat: Expand `execute_code` tool name into bash_tool + read_file at initialize-time When an agent's `tools` include `execute_code` and the `execute_code` capability is enabled for the run, `initializeAgent` now registers `bash_tool` + `read_file` via `registerCodeExecutionTools` before `injectSkillCatalog`. The legacy `execute_code` tool definition is no longer handed to the LLM — `execute_code` remains on the agent document as a capability-trigger marker, but the runtime expands it into the skill-flavored tool pair. Call ordering matters: the `execute_code` registration runs BEFORE `injectSkillCatalog`, so the skill path's own `registerCodeExecutionTools` call inside `injectSkillCatalog` becomes a no-op via the registry's `.has()` check. Exactly one copy of each tool reaches the LLM whether the agent has: - only `execute_code` (legacy path) - only skills - both No data migration needed — `agent.tools: ['execute_code']` stays in the DB unchanged; the expansion is a runtime operation. Three tests cover the matrix: execute_code + capability on → bash_tool + read_file registered; execute_code + capability off → neither registered; no execute_code + capability on → neither registered. * 🗑️ refactor: Drop CodeExecutionToolDefinition from the builtin registry Removes the legacy `execute_code` entry from `agentToolDefinitions` and the corresponding import. With the initialize-time expansion in place, nothing consults `getToolDefinition('execute_code')` for a tool schema any more — the capability gate still filters on the string `execute_code`, but the actual tool definitions the LLM sees come from `registerCodeExecutionTools` (i.e. `bash_tool` + `read_file`). `loadToolDefinitions` in `packages/api/src/tools/definitions.ts` silently drops `execute_code` when it no longer resolves in the registry — that's the expected path and is now covered by an updated test. No caller of `getToolDefinition('execute_code')` expects a non-undefined result after this change. * 🔌 refactor: Read CODE_API_KEY from env for primeCodeFiles + PTC Finishes the Phase 4 server-env-keyed rollout on the two remaining `loadAuthValues({ authFields: [EnvVar.CODE_API_KEY] })` sites in `ToolService.js`: - `primeCodeFiles` (user-attached file priming on execute_code agents) - Programmatic Tool Calling (`createProgrammaticToolCallingTool`) Both now read `process.env[EnvVar.CODE_API_KEY]` directly, matching `bash_tool`'s pattern. The per-user plugin-auth path is no longer consulted for code-env credentials anywhere in the hot path — the agents library owns the actual tool-call execution and also reads the env var internally. Priming still fires for existing user-file workflows so the legacy `toolContextMap[execute_code]` hint ("files available at /mnt/data/...") stays in the prompt; only the key lookup changed. * 🔧 fix: Type the pre-seeded dedupe-test tools as LCTool CI TypeScript type checks caught `{ parameters: {} }` in the new cross-call dedupe test: `LCTool.parameters` is a `JsonSchemaType`, not `{}`. Use `{ type: 'object', properties: {} }` and type the local registry Map through the parameter-derived shape so the pre-seeded values match what `toolRegistry.set` expects. * 🛡️ fix: Run execute_code expansion before GOOGLE_TOOL_CONFLICT gate Codex review caught a latent regression: the original Phase 8 placement ran `registerCodeExecutionTools` after `hasAgentTools` was computed, so an execute-code-only agent on Google/Vertex with provider-specific `options.tools` populated would no longer trip `GOOGLE_TOOL_CONFLICT` — the legacy `CodeExecutionToolDefinition` used to populate `toolDefinitions` before the guard, but after dropping it from the registry, `toolDefinitions` stayed empty until my expansion ran downstream of the guard. Mixed provider + agent tools would silently flow through to the LLM. Fix moves the `execute_code` expansion to BEFORE `hasAgentTools` computation. `bash_tool` + `read_file` now contribute to the check the same way the legacy `execute_code` def did. Covered by a new test that pins the Google+execute_code+provider-tools scenario — the `rejects.toThrow(/google_tool_conflict/)` path would have silently passed on the prior placement. * 🔗 fix: Thread codeEnvAvailable through handoff sub-agents Round-2 codex review caught the other half of the execute_code expansion gap: `discoverConnectedAgents` omitted `codeEnvAvailable` from its forwarded `initializeAgent` params, so handoff sub-agents with `agent.tools: ['execute_code']` lost the `bash_tool` + `read_file` registration (pre-Phase 8 the legacy `CodeExecutionToolDefinition` would have landed in their `toolDefinitions` via the registry). - Add `codeEnvAvailable?` to `DiscoverConnectedAgentsParams` and forward it verbatim on every sub-agent `initializeAgent` call. - Update the three JS call sites that construct the primary's `codeEnvAvailable` (`services/Endpoints/agents/initialize.js`, `controllers/agents/openai.js`, `controllers/agents/responses.js`) to pass the same flag into `discoverConnectedAgents` — one authoritative source per request. - Two regression tests in `discovery.spec.ts` pin the true/false passthrough so a future refactor that drops the param-forwarding surfaces immediately. Left intentionally unchanged: `packages/api/src/agents/openai/service.ts` (public API helper with no in-repo caller). External consumers of `createAgentChatCompletion` who want code execution should pass a `codeEnvAvailable`-aware `initializeAgent` via `deps` — documenting the full public-API surface is out of scope for this Phase 8 PR. * 🔗 fix: Thread codeEnvAvailable through addedConvo + memory-agent paths Round-3 codex review caught the last two production `initializeAgent` callers missing the Phase-8 capability flag: - `api/server/services/Endpoints/agents/addedConvo.js` (multi-convo parallel agent execution). Added `codeEnvAvailable` to `processAddedConvo`'s destructured params and forwarded it into the per-added-agent `initializeAgent` call. Caller in `api/server/services/Endpoints/agents/initialize.js` passes the same `codeEnvAvailable` it computed for the primary. - `api/server/controllers/agents/client.js` (`useMemory` — memory extraction agent). Computes its own `codeEnvAvailable` from `appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities` and forwards into `initializeAgent`. Memory agents rarely list `execute_code`, but if one does, pre-Phase 8 they got the legacy `execute_code` tool registered unconditionally — the passthrough restores parity. With this, every production caller of `initializeAgent` explicitly resolves the capability: main chat flow (primary + handoff), OpenAI chat completions (primary + handoff), Responses API (primary + handoff), added convo parallel agents, and memory agents. The one remaining caller, `packages/api/src/agents/openai/service.ts::createAgentChatCompletion`, is a public API helper with no in-repo consumer (external callers must pass a capability-aware `initializeAgent` via `deps`). * 🪤 fix: Remove duplicate appConfig declaration causing TDZ ReferenceError The Responses API controller had TWO `const appConfig = req.config;` bindings inside `createResponse`: one at the top of the function (added by the Phase 4 `bash_tool` decouple) and one inside the try block (added by the polish PR #12760). Because `const` is block-scoped with a temporal dead zone, the inner redeclaration put `appConfig` in TDZ for the entire try block, so any earlier reference inside the try — notably `appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders` at line 348 — threw `ReferenceError: Cannot access 'appConfig' before initialization`. The error was silently swallowed by the outer try/catch, leaving `recordCollectedUsage` unreached and the six `responses.unit.spec.js` token-usage tests failing. Removing the inner redeclaration fixes the six failing tests (verified: 11/11 pass locally post-fix, 0 regressions elsewhere). The outer function-scoped binding already provides `appConfig` to every downstream reference. * 🔗 fix: Thread codeEnvAvailable through the OpenAI chat-completion public API Round-4 codex review (legitimate on the type-safety angle, even though the runtime concern was already covered): the `createAgentChatCompletion` helper defines its own narrower `InitializeAgentParams` interface locally, and the type was missing `codeEnvAvailable`. External consumers who supply a capability-aware `deps.initializeAgent` couldn't route `codeEnvAvailable` through without a type-cast workaround. - Widen the local `InitializeAgentParams` interface to include `codeEnvAvailable?: boolean` (matches the real `packages/api/src/agents/initialize.ts` type). - Derive `codeEnvAvailable` inside `createAgentChatCompletion` from `deps.appConfig?.endpoints?.agents?.capabilities` (the same source the in-repo controllers use) and forward to `deps.initializeAgent`. Uses a string literal `'execute_code'` lookup so this file stays free of a `librechat-data-provider` import — keeping the dependency surface of the public helper minimal. With this, external consumers of `createAgentChatCompletion` who pass `appConfig` with the agents capabilities get `bash_tool` + `read_file` registration automatically; consumers who don't pass `appConfig` retain the existing "explicit opt-in" semantics (the flag stays `undefined`, expansion is skipped). * 🧹 chore: Review-driven polish — observability log, JSDoc DRY, test gaps, no-op allocation Addresses the comprehensive review of PR #12767: - **Finding #1** (MINOR, observability): `initializeAgent` now emits a debug log when an agent lists `execute_code` in its tools but the runtime gate is off (`params.codeEnvAvailable` !== true). The event-driven `loadToolDefinitionsWrapper` path doesn't log capability-disabled warnings, so without this the tool silently vanishes from the LLM's definitions with zero trace. Operators debugging "why isn't code interpreter working?" now get a signal at the initialize layer. - **Finding #5** (NIT, allocation): `registerCodeExecutionTools` now returns the input `toolDefinitions` array by reference on the no-op path (both tools already registered by a prior caller in the same run) instead of allocating a fresh spread array every time. The common dual-call scenario — `initializeAgent` then `injectSkillCatalog` — saves one O(n) copy per request. - **Finding #4** (NIT, DRY): Collapsed the duplicated 6-line JSDoc comment in `openai.js`, `responses.js`, and `addedConvo.js` into either a one-line `@see DiscoverConnectedAgentsParams.codeEnvAvailable` pointer (the two JS call sites) or a compact 3-line block referring back to the canonical source (addedConvo's @param). - **Finding #2** (MINOR, test gap): Added `api/server/services/Endpoints/agents/addedConvo.spec.js` with three cases covering `codeEnvAvailable=true`, `codeEnvAvailable=false`, and omitted (undefined) passthrough. A future refactor that drops the param from destructuring now surfaces here instead of silently regressing multi-convo parallel agents with `execute_code`. - **Finding #3** (MINOR, test gap): Added `api/server/controllers/agents/__tests__/client.memory.spec.js` pinning the capability-flag derivation that `AgentClient::useMemory` uses — six cases covering present/absent/null/undefined config shapes plus an enum-literal pin (`'execute_code'` / `'agents'`). Catches enum renames or config-path shifts that would otherwise silently strip `bash_tool` + `read_file` from memory agents. Finding #7 (jest.mock scoping, confidence 40) left as-is: the reviewer's own risk assessment noted `buildToolSet` doesn't touch the mocked exports, and restructuring a file-level `jest.mock` to `jest.doMock` + dynamic `import()` introduces more complexity than the speculative risk justifies. The existing mock is scoped to the test file and contains the same stubs the adjacent `skills.test.ts` already uses. Finding #6 (PR description commit count) addressed out-of-band via PR description update. All existing tests pass, typecheck clean, lint clean across touched files. New tests: 9 cases across 2 new spec files. * 🧽 refactor: Replace hardcoded 'execute_code' string with AgentCapabilities enum in service.ts Follow-up review (conf 55) caught that `openai/service.ts`'s Phase 8 `codeEnvAvailable` derivation used the literal `'execute_code'` while every in-repo controller uses `AgentCapabilities.execute_code` from `librechat-data-provider`. The file deliberately uses local type interfaces to keep the public API helper's type surface small, but that pattern was never a ban on single-value imports from the data provider — `packages/api` already depends on it. Importing the enum value means a future rename of `AgentCapabilities.execute_code` propagates to this file automatically, matching the in-repo controllers' behavior. Other follow-up findings left as-is per the reviewer's own verdict: - #2 (memory spec mirrors the production expression rather than calling `AgentClient::useMemory` directly): reviewer flagged as "not blocking" / "design-philosophy observation." The test file's JSDoc already explicitly documents the tradeoff and pins the enum literals to catch the most likely drift vector. Standing up `AgentClient` + all its mocks for a one-line regression guard is disproportionate. - #3 (`addedConvo.spec.js` mock signature vs. underlying `loadAddedAgent` arity): reviewer's own confidence 25 noted the mock matches the wrapper's actual call pattern in the production file. Not a real gap. - #4 was self-retracted as a false alarm. * 🗑️ refactor: Fully deprecate CODE_API_KEY — remove all LibreChat-side references The code-execution sandbox no longer authenticates via a per-run `CODE_API_KEY` (frontend or backend). Auth moved server-side into the agents library / sandbox service, so LibreChat drops every reference: **Backend plumbing:** - `api/server/services/Files/Code/crud.js`: `getCodeOutputDownloadStream`, `uploadCodeEnvFile`, `batchUploadCodeEnvFiles` no longer accept `apiKey` or send the `X-API-Key` header. - `api/server/services/Files/Code/process.js`: `processCodeOutput`, `getSessionInfo`, `primeFiles` drop the `apiKey` param throughout. - `api/server/services/ToolService.js`: stop reading `process.env[EnvVar.CODE_API_KEY]` for `primeCodeFiles` and PTC; the agents library handles auth internally. Remove the now-dead `loadAuthValues` + `EnvVar` imports. Drop the misleading "LIBRECHAT_CODE_API_KEY" hint from the bash_tool error log. - `api/server/services/Files/process.js`: remove the `loadAuthValues` call around `uploadCodeEnvFile`. - `api/server/routes/files/files.js`: code-env file download no longer fetches a per-user key. - `api/server/controllers/tools.js`: `execute_code` is no longer a tool that needs verifyToolAuth with `[EnvVar.CODE_API_KEY]` — the endpoint always reports system-authenticated so the client skips the key-entry dialog. `processCodeOutput` called without `apiKey`. - `api/server/controllers/agents/callbacks.js`: `processCodeOutput` invoked without the loadAuthValues round trip, for both LegacyHandler and Responses-API handlers. - `api/app/clients/tools/util/handleTools.js`: `createCodeExecutionTool` called with just `user_id` + files. **packages/api:** - `packages/api/src/agents/skillFiles.ts`: `PrimeSkillFilesParams`, `PrimeInvokedSkillsDeps`, `primeSkillFiles`, `primeInvokedSkills` all drop the `apiKey` param; the gate is purely `codeEnvAvailable`. - `packages/api/src/agents/handlers.ts`: `handleSkillToolCall` drops the `process.env[EnvVar.CODE_API_KEY]` read; skill-file priming is now gated solely on `codeEnvAvailable`. `ToolExecuteOptions` signatures drop apiKey from `batchUploadCodeEnvFiles` and `getSessionInfo`. - `packages/api/src/agents/skillConfigurable.ts`: JSDoc no longer references the env var. - `packages/api/src/tools/classification.ts`: PTC creation no longer gated on `loadAuthValues`; `buildToolClassification` drops the `loadAuthValues` dep entirely (no LibreChat-side callers need it for this path anymore). - `packages/api/src/tools/definitions.ts`: `LoadToolDefinitionsDeps` drops the `loadAuthValues` field. **Frontend:** - Delete `client/src/hooks/Plugins/useAuthCodeTool.ts`, `useCodeApiKeyForm.ts`, and `client/src/components/SidePanel/Agents/Code/ApiKeyDialog.tsx` — the install/revoke dialogs for CODE_API_KEY are fully dead. - `BadgeRowContext.tsx`: drop `codeApiKeyForm` from the context type and provider. `codeInterpreter` toggle treated as always authenticated (sandbox auth is server-side). - `ToolsDropdown.tsx`, `ToolDialogs.tsx`, `CodeInterpreter.tsx`, `RunCode.tsx`, `SidePanel/Agents/Code/Action.tsx` +`Form.tsx`: all API-key dialog trigger refs, "Configure code interpreter" gear buttons, and auth-verification plumbing removed. The "Code Interpreter" toggle is now a plain `AgentCapabilities.execute_code` checkbox — no key-entry gate. - `client/src/locales/en/translation.json`: drop the three `com_ui_librechat_code_api*` keys and `com_ui_add_code_interpreter_api_key`. Other locales are externally automated per CLAUDE.md. **Config:** - `.env.example`: remove the `# LIBRECHAT_CODE_API_KEY=your-key` section and its header. **Tests:** - `crud.spec.js`: assertions flipped to pin "no X-API-Key header" and "no apiKey param". - `skillFiles.spec.ts`: removed env-var save/restore; tests now pin that the batch-upload path is gated solely on `codeEnvAvailable` and that no apiKey is threaded through. - `handlers.spec.ts`: same — just the `codeEnvAvailable` gate pins remain. - `classification.spec.ts`: remove the two tests that asserted `loadAuthValues` was (not) called for PTC. - `definitions.spec.ts`: drop every `loadAuthValues: mockLoadAuthValues` entry from the deps shape. - `process.spec.js`: strip the mock of `EnvVar.CODE_API_KEY`. **Comment hygiene:** - `tools.ts`, `initialize.ts`, `registry/definitions.ts`: shortened stale comment references to "legacy `execute_code` tool" without naming the retired env var. Tests verified: 678 packages/api tests pass, 836 backend api tests pass. Typecheck clean, lint clean. Only remaining CODE_API_KEY mentions in the code are two regression-guard assertions: - `crud.spec.js`: pins "no X-API-Key header" stays absent. - `skillConfigurable.spec.ts`: pins `configurable` never grows a `codeApiKey` field. * 🧹 chore: Remove the last two CODE_API_KEY name mentions in LibreChat Follow-up to the prior full deprecation commit: two tests still named the retired identifier in their regression-guard assertions. - `packages/api/src/agents/skillConfigurable.spec.ts`: drop the "does not inject a codeApiKey key" test. The `codeApiKey` field is gone from the production configurable shape, so an absence-assertion naming it re-introduces the retired identifier in code. - `api/server/services/Files/Code/crud.spec.js`: rename the "without an X-API-Key header" case back to "should request stream response from the correct URL" and drop the `expect(headers).not.toHaveProperty('X-API-Key')` assertion. The surrounding request-shape checks (URL, timeout, responseType) still pin the behavior; the explicit header-absence line was named-after the deprecated contract. Result: `grep -rn "CODE_API_KEY\|codeApiKey\|LIBRECHAT_CODE_API_KEY"` against the LibreChat source tree returns zero hits. The only remaining `X-API-Key` strings in this repo are on unrelated OpenAPI Action + MCP server auth configurations, where the string is user-facing config, not a LibreChat-owned identifier. Tests: 677 packages/api pass (2 pre-existing summarization e2e failures unrelated); 126 api-workspace controller/service tests pass. Typecheck and lint clean. * 🎯 fix: Narrow codeEnvAvailable to per-agent (admin cap AND agent.tools) Before this commit, `codeEnvAvailable` was computed in the three JS controllers as the admin-level capability flag only (`enabledCapabilities.has(AgentCapabilities.execute_code)`) and passed through `initializeAgent` → `injectSkillCatalog` / `primeInvokedSkills` / `enrichWithSkillConfigurable` unchanged. A skills-only agent whose `tools` array didn't include `execute_code` still got `bash_tool` registered (via `injectSkillCatalog`) and skill files re-primed to the sandbox on every turn — wrong, because the agent never opted in to code execution. **Fix:** `initializeAgent` now computes the per-agent effective value once as `params.codeEnvAvailable === true && agent.tools.includes(Tools.execute_code)`, reuses the same boolean for: 1. The `execute_code` → `bash_tool + read_file` expansion gate (previously already consulted `agent.tools`; now shares the single `effectiveCodeEnvAvailable` binding). 2. The `injectSkillCatalog` call (previously got the raw admin flag). 3. The returned `InitializedAgent.codeEnvAvailable` field (new, typed as required boolean). **Controllers (initialize.js, openai.js, responses.js):** store `primaryConfig.codeEnvAvailable` in `agentToolContexts.set(primaryId, ...)`, capture `config.codeEnvAvailable` in every handoff `onAgentInitialized` callback, and read it from the per-agent ctx inside the `toolExecuteOptions.loadTools` runtime closure. The hoisted `const codeEnvAvailable = enabledCapabilities.has(...)` locals in the two OpenAI-compat controllers are gone — they were shadowing the narrowed per-agent value. **primeInvokedSkills:** `handlePrimeInvokedSkills` in `services/Endpoints/agents/initialize.js` now uses `primaryConfig.codeEnvAvailable` (per-agent, narrowed) instead of the raw admin flag. A skills-only primary agent won't re-prime historical skill files to the sandbox even when the admin enabled the capability globally. **Efficiency:** one extra `&&` in `initializeAgent`. No runtime hot-path cost — the `includes()` scan on `agent.tools` was already happening for the `execute_code` expansion gate; it's now just bound to a local. Tool execution closures read `ctx.codeEnvAvailable === true` (property access + strict equality, O(1)). **Ephemeral-agent note:** per-agent narrowing is authoritative for both persisted and ephemeral flows. The ephemeral toggle (`ephemeralAgent.execute_code`) is reconciled into `agent.tools` upstream in `packages/api/src/agents/added.ts`, so `agent.tools.includes('execute_code')` is the single source of truth by the time `initializeAgent` runs. **Tests:** two new regression tests pin the narrowing contract: - `initialize.test.ts` — four-quadrant matrix on `InitializedAgent.codeEnvAvailable` (cap on × agent asks, cap on × doesn't ask, cap off × asks, neither). Catches future refactors that drop either half of the AND. - `skills.test.ts` — `injectSkillCatalog` with `codeEnvAvailable: false` against an active skill catalog must NOT register `bash_tool` even though it still registers `read_file` + `skill`. This is the state a skills-only agent gets post-narrowing. All 191 affected packages/api tests pass + 836 backend api tests pass. Typecheck clean, lint clean. * 🧽 refactor: Comprehensive-review polish — hoist tool defs, pin verifyToolAuth contract, doc appConfig Addresses the comprehensive review of Phase 8. Findings mapped: **#1 (MINOR): `verifyToolAuth` unconditional auth for execute_code** - Added doc comment explicitly stating the deployment contract (admin capability → reachable sandbox; no per-check health probe to keep UI-gate queries O(1)). - New `api/server/controllers/__tests__/tools.verifyToolAuth.spec.js` with 4 regression tests pinning the contract: 1. `authenticated: true` + `SYSTEM_DEFINED` for execute_code. 2. 404 for unknown tool IDs. 3. `loadAuthValues` is never consulted (catches a future revert that would resurface the per-user key-entry dialog). 4. Response `message` is never `USER_PROVIDED`. **#2 (MINOR): `openai/service.ts` undocumented `appConfig` dependency** - Expanded the `ChatCompletionDependencies.appConfig` JSDoc to spell out that omitting it silently disables code execution for agents with `execute_code` in their tools. External consumers of `createAgentChatCompletion` now have the contract documented at the type boundary. **#5 (NIT): `registerCodeExecutionTools` re-allocates tool defs** - Hoisted `READ_FILE_DEF` and `BASH_TOOL_DEF` to module-level `Object.freeze`d constants. The shapes derive entirely from static `@librechat/agents` exports, so a single frozen object per tool is safe to share across every agent init. Eliminates the ~4-property allocations on every call (including the common second-call no-op path). **#6 (NIT): Verbose history-priming comment in initialize.js** - Trimmed the 16-line `handlePrimeInvokedSkills` block to a 5-line summary with `@see InitializedAgent.codeEnvAvailable` pointer. The canonical narrowing explanation lives on the type; the controller comment is just the ACL-vs-capability rationale. **Skipped:** - #3 (memory spec tests a mirror function): reviewer self-dismissed as a design tradeoff; the enum-literal pin already catches the highest-risk drift vector. - #4 (cross-repo contract for `createCodeExecutionTool`): user will explicitly install the latest `@librechat/agents` dev version once the companion PR publishes, so the version pin will be authoritative. - #7 (migration/deprecation note for self-hosters): out of scope per user direction — release notes handle this. Tests verified: 679 packages/api + 840 backend api tests pass. Typecheck + lint clean. * 🔧 chore: Update @librechat/agents version to 3.1.68-dev.1 across package-lock and package.json files This commit updates the version of the `@librechat/agents` package from `3.1.68-dev.0` to `3.1.68-dev.1` in the `package-lock.json` and relevant `package.json` files. This change ensures consistency across the project and incorporates any updates or fixes from the new version.
This commit is contained in:
parent
ac913aa886
commit
35bf04b26c
54 changed files with 1237 additions and 928 deletions
|
|
@ -810,13 +810,6 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
|||
#=====================================================#
|
||||
OPENWEATHER_API_KEY=
|
||||
|
||||
#====================================#
|
||||
# LibreChat Code Interpreter API #
|
||||
#====================================#
|
||||
|
||||
# https://code.librechat.ai
|
||||
# LIBRECHAT_CODE_API_KEY=your-key
|
||||
|
||||
#======================#
|
||||
# Web Search #
|
||||
#======================#
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
EnvVar,
|
||||
Calculator,
|
||||
createSearchTool,
|
||||
createCodeExecutionTool,
|
||||
} = require('@librechat/agents');
|
||||
const { Calculator, createSearchTool, createCodeExecutionTool } = require('@librechat/agents');
|
||||
const {
|
||||
checkAccess,
|
||||
toolkitParent,
|
||||
|
|
@ -265,28 +260,14 @@ const loadTools = async ({
|
|||
for (const tool of tools) {
|
||||
if (tool === Tools.execute_code) {
|
||||
requestedTools[tool] = async () => {
|
||||
const authValues = await loadAuthValues({
|
||||
userId: user,
|
||||
authFields: [EnvVar.CODE_API_KEY],
|
||||
const { files, toolContext } = await primeCodeFiles({
|
||||
...options,
|
||||
agentId: agent?.id,
|
||||
});
|
||||
const codeApiKey = authValues[EnvVar.CODE_API_KEY];
|
||||
const { files, toolContext } = await primeCodeFiles(
|
||||
{
|
||||
...options,
|
||||
agentId: agent?.id,
|
||||
},
|
||||
codeApiKey,
|
||||
);
|
||||
if (toolContext) {
|
||||
toolContextMap[tool] = toolContext;
|
||||
}
|
||||
const CodeExecutionTool = createCodeExecutionTool({
|
||||
user_id: user,
|
||||
files,
|
||||
...authValues,
|
||||
});
|
||||
CodeExecutionTool.apiKey = codeApiKey;
|
||||
return CodeExecutionTool;
|
||||
return createCodeExecutionTool({ user_id: user, files });
|
||||
};
|
||||
continue;
|
||||
} else if (tool === Tools.file_search) {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
"@google/genai": "^1.19.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/core": "^0.3.80",
|
||||
"@librechat/agents": "^3.1.68-dev.0",
|
||||
"@librechat/agents": "^3.1.68-dev.1",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
|
|
|
|||
102
api/server/controllers/__tests__/tools.verifyToolAuth.spec.js
Normal file
102
api/server/controllers/__tests__/tools.verifyToolAuth.spec.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { debug: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
checkAccess: jest.fn(),
|
||||
loadWebSearchAuth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
getRoleByName: jest.fn(),
|
||||
createToolCall: jest.fn(),
|
||||
getToolCallsByConvo: jest.fn(),
|
||||
getMessage: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
processFileURL: jest.fn(),
|
||||
uploadImageBuffer: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Code/process', () => ({
|
||||
processCodeOutput: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Tools/credentials', () => ({
|
||||
loadAuthValues: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/app/clients/tools/util', () => ({
|
||||
loadTools: jest.fn(),
|
||||
}));
|
||||
|
||||
const { Tools, AuthType } = require('librechat-data-provider');
|
||||
const { verifyToolAuth } = require('../tools');
|
||||
|
||||
/**
|
||||
* Phase 8 behavioral pin: `verifyToolAuth(execute_code)` unconditionally
|
||||
* returns system-authenticated. Sandbox auth moved server-side into the
|
||||
* agents library, so the per-user `CODE_API_KEY` check that previously
|
||||
* gated this endpoint is gone. The deployment contract is: if the
|
||||
* admin enabled the `execute_code` capability, the sandbox is
|
||||
* reachable. This endpoint does not probe reachability (would be too
|
||||
* expensive per UI-gate query); failures surface at execution time.
|
||||
*
|
||||
* A regression where someone re-adds an auth check here would
|
||||
* resurrect the per-user key-entry dialog on the client, which Phase 8
|
||||
* explicitly removed. Pin the contract.
|
||||
*/
|
||||
describe('verifyToolAuth — execute_code system-auth contract', () => {
|
||||
const makeReq = (toolId) => ({
|
||||
params: { toolId },
|
||||
user: { id: 'user-1' },
|
||||
config: {},
|
||||
});
|
||||
|
||||
const makeRes = () => {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
it('returns authenticated: true with SYSTEM_DEFINED for execute_code', async () => {
|
||||
const res = makeRes();
|
||||
await verifyToolAuth(makeReq(Tools.execute_code), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
authenticated: true,
|
||||
message: AuthType.SYSTEM_DEFINED,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 404 for unknown tool ids (not in directCallableTools)', async () => {
|
||||
const res = makeRes();
|
||||
await verifyToolAuth(makeReq('not_a_real_tool'), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Tool not found' });
|
||||
});
|
||||
|
||||
it('does NOT invoke loadAuthValues for execute_code (no per-user credential check)', async () => {
|
||||
/* Regression guard: a future refactor that threads per-user auth back
|
||||
in would resurface the key-entry dialog on the client. Pin that
|
||||
the auth path is never consulted. */
|
||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
loadAuthValues.mockClear();
|
||||
|
||||
await verifyToolAuth(makeReq(Tools.execute_code), makeRes());
|
||||
|
||||
expect(loadAuthValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT reference AuthType.USER_PROVIDED in the response (Phase 8 removed the path)', async () => {
|
||||
const res = makeRes();
|
||||
await verifyToolAuth(makeReq(Tools.execute_code), res);
|
||||
|
||||
const payload = res.json.mock.calls[0][0];
|
||||
expect(payload.message).not.toBe(AuthType.USER_PROVIDED);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
const { EModelEndpoint, AgentCapabilities } = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
* Pins the capability-flag derivation that `AgentClient::useMemory` uses when
|
||||
* it calls `initializeAgent` for the memory-extraction agent. The expression
|
||||
* is trivial but lives in a controller path that's otherwise hard to unit-
|
||||
* test, so a focused regression guard at the pure-logic layer ensures any
|
||||
* drift in config-key names (`agents`, `capabilities`) or capability enum
|
||||
* values (`execute_code`) surfaces here instead of silently stripping
|
||||
* `bash_tool` + `read_file` from memory agents in production.
|
||||
*
|
||||
* The expression mirrored below is the one in
|
||||
* `api/server/controllers/agents/client.js::useMemory`:
|
||||
*
|
||||
* new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities)
|
||||
* .has(AgentCapabilities.execute_code)
|
||||
*/
|
||||
function deriveMemoryCodeEnvAvailable(appConfig) {
|
||||
return new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities).has(
|
||||
AgentCapabilities.execute_code,
|
||||
);
|
||||
}
|
||||
|
||||
describe('AgentClient::useMemory — codeEnvAvailable derivation', () => {
|
||||
it('returns true when appConfig lists execute_code under the agents endpoint capabilities', () => {
|
||||
expect(
|
||||
deriveMemoryCodeEnvAvailable({
|
||||
endpoints: {
|
||||
[EModelEndpoint.agents]: {
|
||||
capabilities: [AgentCapabilities.execute_code, AgentCapabilities.file_search],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the agents endpoint omits execute_code', () => {
|
||||
expect(
|
||||
deriveMemoryCodeEnvAvailable({
|
||||
endpoints: {
|
||||
[EModelEndpoint.agents]: {
|
||||
capabilities: [AgentCapabilities.file_search, AgentCapabilities.web_search],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the capabilities array is absent', () => {
|
||||
expect(deriveMemoryCodeEnvAvailable({ endpoints: { [EModelEndpoint.agents]: {} } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when the agents endpoint config is absent', () => {
|
||||
expect(deriveMemoryCodeEnvAvailable({ endpoints: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when appConfig is null / undefined', () => {
|
||||
/* Defensive — `req.config` can be unset in edge-case test harnesses and
|
||||
ephemeral-agent flows; the memory path must not throw on access. */
|
||||
expect(deriveMemoryCodeEnvAvailable(null)).toBe(false);
|
||||
expect(deriveMemoryCodeEnvAvailable(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches the literal string "execute_code" — catches enum rename drift', () => {
|
||||
/* Pins the capability enum value so a rename of `AgentCapabilities.execute_code`
|
||||
that doesn't propagate to the controllers surfaces here. If this test breaks,
|
||||
update the underlying expression in `useMemory` and the helpers in
|
||||
`initialize.js` / `openai.js` / `responses.js` to match. */
|
||||
expect(AgentCapabilities.execute_code).toBe('execute_code');
|
||||
expect(EModelEndpoint.agents).toBe('agents');
|
||||
});
|
||||
});
|
||||
|
|
@ -2,8 +2,6 @@ const { nanoid } = require('nanoid');
|
|||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Tools, StepTypes, FileContext, ErrorTypes } = require('librechat-data-provider');
|
||||
const {
|
||||
EnvVar,
|
||||
Constants,
|
||||
GraphEvents,
|
||||
GraphNodeKeys,
|
||||
ToolEndHandler,
|
||||
|
|
@ -17,7 +15,6 @@ const {
|
|||
} = require('@librechat/api');
|
||||
const { processFileCitations } = require('~/server/services/Files/Citations');
|
||||
const { processCodeOutput } = require('~/server/services/Files/Code/process');
|
||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
const { saveBase64Image } = require('~/server/services/Files/process');
|
||||
|
||||
class ModelEndHandler {
|
||||
|
|
@ -456,15 +453,10 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null })
|
|||
const { id, name } = file;
|
||||
artifactPromises.push(
|
||||
(async () => {
|
||||
const result = await loadAuthValues({
|
||||
userId: req.user.id,
|
||||
authFields: [EnvVar.CODE_API_KEY],
|
||||
});
|
||||
const fileMetadata = await processCodeOutput({
|
||||
req,
|
||||
id,
|
||||
name,
|
||||
apiKey: result[EnvVar.CODE_API_KEY],
|
||||
messageId: metadata.run_id,
|
||||
toolCallId: output.tool_call_id,
|
||||
conversationId: metadata.thread_id,
|
||||
|
|
@ -662,15 +654,10 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises })
|
|||
const { id, name } = file;
|
||||
artifactPromises.push(
|
||||
(async () => {
|
||||
const result = await loadAuthValues({
|
||||
userId: req.user.id,
|
||||
authFields: [EnvVar.CODE_API_KEY],
|
||||
});
|
||||
const fileMetadata = await processCodeOutput({
|
||||
req,
|
||||
id,
|
||||
name,
|
||||
apiKey: result[EnvVar.CODE_API_KEY],
|
||||
messageId: metadata.run_id,
|
||||
toolCallId: output.tool_call_id,
|
||||
conversationId: metadata.thread_id,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ const {
|
|||
ContentTypes,
|
||||
EModelEndpoint,
|
||||
PermissionTypes,
|
||||
AgentCapabilities,
|
||||
isAgentsEndpoint,
|
||||
isEphemeralAgentId,
|
||||
removeNullishValues,
|
||||
|
|
@ -489,6 +490,13 @@ class AgentClient extends BaseClient {
|
|||
return;
|
||||
}
|
||||
|
||||
/** Forward the same `execute_code` capability gate the chat flow uses —
|
||||
* memory agents are unlikely to list `execute_code`, but if one does,
|
||||
* Phase 8 relies on this flag to expand the string into
|
||||
* `bash_tool` + `read_file` (pre-Phase 8 the legacy `execute_code`
|
||||
* tool registered unconditionally; without this passthrough the
|
||||
* memory path would silently lose code-execution tooling). */
|
||||
const memoryCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities);
|
||||
const agent = await initializeAgent(
|
||||
{
|
||||
req: this.options.req,
|
||||
|
|
@ -500,6 +508,7 @@ class AgentClient extends BaseClient {
|
|||
? EModelEndpoint.agents
|
||||
: memoryConfig.agent?.provider,
|
||||
},
|
||||
codeEnvAvailable: memoryCapabilities.has(AgentCapabilities.execute_code),
|
||||
},
|
||||
{
|
||||
getFiles: db.getFiles,
|
||||
|
|
|
|||
|
|
@ -314,6 +314,7 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
userMCPAuthMap: primaryConfig.userMCPAuthMap,
|
||||
tool_resources: primaryConfig.tool_resources,
|
||||
actionsEnabled: primaryConfig.actionsEnabled,
|
||||
codeEnvAvailable: primaryConfig.codeEnvAvailable,
|
||||
});
|
||||
|
||||
// Only run BFS discovery (and pay `getModelsConfig` upfront) when the
|
||||
|
|
@ -343,6 +344,8 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
// sub-agent must clear the same sharing boundary, not the looser
|
||||
// in-app AGENT one.
|
||||
resourceType: ResourceType.REMOTE_AGENT,
|
||||
/** @see DiscoverConnectedAgentsParams.codeEnvAvailable */
|
||||
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
|
||||
},
|
||||
{
|
||||
getAgent: db.getAgent,
|
||||
|
|
@ -369,6 +372,7 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
userMCPAuthMap: config.userMCPAuthMap,
|
||||
tool_resources: config.tool_resources,
|
||||
actionsEnabled: config.actionsEnabled,
|
||||
codeEnvAvailable: config.codeEnvAvailable,
|
||||
});
|
||||
},
|
||||
initializeAgent,
|
||||
|
|
@ -414,11 +418,13 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId: null });
|
||||
|
||||
/* Stable for the turn: the capability set is the admin config, and
|
||||
the prime lists are fixed once `initializeAgent` resolves. Hoisting
|
||||
these out of `loadTools` avoids recomputing them on every tool
|
||||
execution (and keeps the call-site lean). */
|
||||
const codeEnvAvailable = enabledCapabilities.has(AgentCapabilities.execute_code);
|
||||
/* Stable for the turn: the prime lists are fixed once
|
||||
`initializeAgent` resolves. Hoisted out of `loadTools` so tool
|
||||
execution doesn't recompute them. `codeEnvAvailable` is read
|
||||
per-agent from the stored tool context (admin cap AND that
|
||||
agent's `tools` list includes `execute_code`) — a skills-only
|
||||
agent never gains sandbox access even if the admin enabled the
|
||||
capability globally. */
|
||||
const skillPrimedIdsByName = buildSkillPrimedIdsByName(
|
||||
primaryConfig.manualSkillPrimes,
|
||||
primaryConfig.alwaysApplySkillPrimes,
|
||||
|
|
@ -442,7 +448,7 @@ const OpenAIChatCompletionController = async (req, res) => {
|
|||
result,
|
||||
req,
|
||||
primaryConfig.accessibleSkillIds,
|
||||
codeEnvAvailable,
|
||||
ctx.codeEnvAvailable === true,
|
||||
skillPrimedIdsByName,
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -444,6 +444,7 @@ const createResponse = async (req, res) => {
|
|||
userMCPAuthMap: primaryConfig.userMCPAuthMap,
|
||||
tool_resources: primaryConfig.tool_resources,
|
||||
actionsEnabled: primaryConfig.actionsEnabled,
|
||||
codeEnvAvailable: primaryConfig.codeEnvAvailable,
|
||||
});
|
||||
|
||||
// Only run BFS discovery (and pay `getModelsConfig` upfront) when the
|
||||
|
|
@ -473,6 +474,8 @@ const createResponse = async (req, res) => {
|
|||
// sub-agent must clear the same sharing boundary, not the looser
|
||||
// in-app AGENT one.
|
||||
resourceType: ResourceType.REMOTE_AGENT,
|
||||
/** @see DiscoverConnectedAgentsParams.codeEnvAvailable */
|
||||
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
|
||||
},
|
||||
{
|
||||
getAgent: db.getAgent,
|
||||
|
|
@ -499,6 +502,7 @@ const createResponse = async (req, res) => {
|
|||
userMCPAuthMap: config.userMCPAuthMap,
|
||||
tool_resources: config.tool_resources,
|
||||
actionsEnabled: config.actionsEnabled,
|
||||
codeEnvAvailable: config.codeEnvAvailable,
|
||||
});
|
||||
},
|
||||
initializeAgent,
|
||||
|
|
@ -567,11 +571,14 @@ const createResponse = async (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
/* Stable for the turn: the capability set is the admin config, and
|
||||
the prime lists are fixed once `initializeAgent` resolves. Hoisted
|
||||
here so both the streaming and non-streaming `loadTools` closures
|
||||
below read the same values without recomputing per tool execution. */
|
||||
const codeEnvAvailable = enabledCapabilities.has(AgentCapabilities.execute_code);
|
||||
/* Stable for the turn: the prime lists are fixed once
|
||||
`initializeAgent` resolves. Hoisted here so both the streaming
|
||||
and non-streaming `loadTools` closures below reuse it without
|
||||
recomputing per tool execution. `codeEnvAvailable` is read
|
||||
per-agent from the stored tool context (admin cap AND that
|
||||
agent's `tools` list includes `execute_code`) — a skills-only
|
||||
agent never gains sandbox access even if the admin enabled the
|
||||
capability globally. */
|
||||
const skillPrimedIdsByName = buildSkillPrimedIdsByName(
|
||||
manualSkillPrimes,
|
||||
alwaysApplySkillPrimes,
|
||||
|
|
@ -634,7 +641,7 @@ const createResponse = async (req, res) => {
|
|||
result,
|
||||
req,
|
||||
primaryConfig.accessibleSkillIds,
|
||||
codeEnvAvailable,
|
||||
ctx.codeEnvAvailable === true,
|
||||
skillPrimedIdsByName,
|
||||
);
|
||||
},
|
||||
|
|
@ -810,7 +817,7 @@ const createResponse = async (req, res) => {
|
|||
result,
|
||||
req,
|
||||
primaryConfig.accessibleSkillIds,
|
||||
codeEnvAvailable,
|
||||
ctx.codeEnvAvailable === true,
|
||||
skillPrimedIdsByName,
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
const { nanoid } = require('nanoid');
|
||||
const { EnvVar } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { checkAccess, loadWebSearchAuth } = require('@librechat/api');
|
||||
const {
|
||||
|
|
@ -15,9 +14,12 @@ const { processCodeOutput } = require('~/server/services/Files/Code/process');
|
|||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
const { loadTools } = require('~/app/clients/tools/util');
|
||||
|
||||
const fieldsMap = {
|
||||
[Tools.execute_code]: [EnvVar.CODE_API_KEY],
|
||||
};
|
||||
/**
|
||||
* Tools that are callable directly via `POST /tools/:toolId/call`.
|
||||
* `execute_code` is the only entry today; the tool runs server-side via
|
||||
* the agents library / sandbox service without any per-user credential.
|
||||
*/
|
||||
const directCallableTools = new Set([Tools.execute_code]);
|
||||
|
||||
const toolAccessPermType = {
|
||||
[Tools.execute_code]: PermissionTypes.RUN_CODE,
|
||||
|
|
@ -65,37 +67,23 @@ const verifyToolAuth = async (req, res) => {
|
|||
if (toolId === Tools.web_search) {
|
||||
return await verifyWebSearchAuth(req, res);
|
||||
}
|
||||
const authFields = fieldsMap[toolId];
|
||||
if (!authFields) {
|
||||
if (!directCallableTools.has(toolId)) {
|
||||
res.status(404).json({ message: 'Tool not found' });
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await loadAuthValues({
|
||||
userId: req.user.id,
|
||||
authFields,
|
||||
throwError: false,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error loading auth values', error);
|
||||
res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED });
|
||||
return;
|
||||
}
|
||||
let isUserProvided = false;
|
||||
for (const field of authFields) {
|
||||
if (!result[field]) {
|
||||
res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED });
|
||||
return;
|
||||
}
|
||||
if (!isUserProvided && process.env[field] !== result[field]) {
|
||||
isUserProvided = true;
|
||||
}
|
||||
}
|
||||
res.status(200).json({
|
||||
authenticated: true,
|
||||
message: isUserProvided ? AuthType.USER_PROVIDED : AuthType.SYSTEM_DEFINED,
|
||||
});
|
||||
/**
|
||||
* `execute_code` no longer requires a per-user credential — sandbox
|
||||
* auth is handled server-side by the agents library. Always report
|
||||
* system-authenticated so the client proceeds straight to the call
|
||||
* without a key-entry dialog.
|
||||
*
|
||||
* Deployment contract: reachability of the sandbox service is the
|
||||
* admin's responsibility. This endpoint does not probe the service
|
||||
* (a per-auth-check network hop would be too expensive for what is
|
||||
* a UI-gate query). If the sandbox is unreachable, the call path
|
||||
* surfaces the error at execution time instead of here.
|
||||
*/
|
||||
res.status(200).json({ authenticated: true, message: AuthType.SYSTEM_DEFINED });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
|
|
@ -111,7 +99,7 @@ const callTool = async (req, res) => {
|
|||
try {
|
||||
const appConfig = req.config;
|
||||
const { toolId = '' } = req.params;
|
||||
if (!fieldsMap[toolId]) {
|
||||
if (!directCallableTools.has(toolId)) {
|
||||
logger.warn(`[${toolId}/call] User ${req.user.id} attempted call to invalid tool`);
|
||||
res.status(404).json({ message: 'Tool not found' });
|
||||
return;
|
||||
|
|
@ -199,7 +187,6 @@ const callTool = async (req, res) => {
|
|||
req,
|
||||
id,
|
||||
name,
|
||||
apiKey: tool.apiKey,
|
||||
messageId,
|
||||
toolCallId,
|
||||
conversationId,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
const fs = require('fs').promises;
|
||||
const express = require('express');
|
||||
const { EnvVar } = require('@librechat/agents');
|
||||
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const {
|
||||
refreshS3FileUrls,
|
||||
|
|
@ -29,7 +28,6 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
|||
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
|
||||
const { hasCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const { checkPermission } = require('~/server/services/PermissionService');
|
||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
const { hasAccessToFilesViaAgent } = require('~/server/services/Files');
|
||||
const { cleanFileName } = require('~/server/utils/files');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
|
@ -287,13 +285,8 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => {
|
|||
return res.status(501).send('Not Implemented');
|
||||
}
|
||||
|
||||
const result = await loadAuthValues({ userId: req.user.id, authFields: [EnvVar.CODE_API_KEY] });
|
||||
|
||||
/** @type {AxiosResponse<ReadableStream> | undefined} */
|
||||
const response = await getDownloadStream(
|
||||
`${session_id}/${fileId}`,
|
||||
result[EnvVar.CODE_API_KEY],
|
||||
);
|
||||
const response = await getDownloadStream(`${session_id}/${fileId}`);
|
||||
res.set(response.headers);
|
||||
response.data.pipe(res);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ const loadAddedAgent = (params) =>
|
|||
* @param {Map} params.agentConfigs - Map of agent configs to add to
|
||||
* @param {string} params.primaryAgentId - The primary agent ID
|
||||
* @param {Object|undefined} params.userMCPAuthMap - User MCP auth map to merge into
|
||||
* @param {boolean} [params.codeEnvAvailable] - `execute_code` capability flag;
|
||||
* forwarded verbatim to the added agent's `initializeAgent`. @see
|
||||
* InitializeAgentParams.codeEnvAvailable for full semantics.
|
||||
* @returns {Promise<{userMCPAuthMap: Object|undefined}>} The updated userMCPAuthMap
|
||||
*/
|
||||
const processAddedConvo = async ({
|
||||
|
|
@ -57,6 +60,7 @@ const processAddedConvo = async ({
|
|||
primaryAgentId,
|
||||
primaryAgent,
|
||||
userMCPAuthMap,
|
||||
codeEnvAvailable,
|
||||
}) => {
|
||||
const addedConvo = endpointOption.addedConvo;
|
||||
if (addedConvo == null) {
|
||||
|
|
@ -101,6 +105,7 @@ const processAddedConvo = async ({
|
|||
agent: addedAgent,
|
||||
endpointOption,
|
||||
allowedProviders,
|
||||
codeEnvAvailable,
|
||||
},
|
||||
{
|
||||
getFiles: db.getFiles,
|
||||
|
|
|
|||
108
api/server/services/Endpoints/agents/addedConvo.spec.js
Normal file
108
api/server/services/Endpoints/agents/addedConvo.spec.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
const mockInitializeAgent = jest.fn();
|
||||
const mockValidateAgentModel = jest.fn();
|
||||
const mockLoadAddedAgent = jest.fn();
|
||||
const mockGetAgent = jest.fn();
|
||||
const mockGetMCPServerTools = jest.fn();
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
ADDED_AGENT_ID: '__added_agent__',
|
||||
initializeAgent: (...args) => mockInitializeAgent(...args),
|
||||
validateAgentModel: (...args) => mockValidateAgentModel(...args),
|
||||
loadAddedAgent: (params) => mockLoadAddedAgent(params),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/permissions', () => ({
|
||||
filterFilesByAgentAccess: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getMCPServerTools: (...args) => mockGetMCPServerTools(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
getAgent: (...args) => mockGetAgent(...args),
|
||||
}));
|
||||
|
||||
const { processAddedConvo } = require('./addedConvo');
|
||||
|
||||
const makeReq = () => ({ user: { id: 'u1', role: 'USER' } });
|
||||
|
||||
/**
|
||||
* Phase 8 pins `processAddedConvo` forwarding the run's `codeEnvAvailable` to
|
||||
* the added-convo `initializeAgent` call. Without this, parallel multi-convo
|
||||
* agents with `tools: ['execute_code']` silently drop `bash_tool` + `read_file`
|
||||
* even though the primary had them — pre-Phase-8 the legacy
|
||||
* `CodeExecutionToolDefinition` landed in their `toolDefinitions` via the
|
||||
* registry regardless of any explicit flag.
|
||||
*/
|
||||
describe('processAddedConvo — codeEnvAvailable passthrough', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockValidateAgentModel.mockResolvedValue({ isValid: true });
|
||||
mockInitializeAgent.mockResolvedValue({
|
||||
id: 'added-agent',
|
||||
userMCPAuthMap: undefined,
|
||||
});
|
||||
mockLoadAddedAgent.mockResolvedValue({ id: 'added-agent', provider: 'openai' });
|
||||
});
|
||||
|
||||
const baseParams = (overrides = {}) => ({
|
||||
req: makeReq(),
|
||||
res: {},
|
||||
endpointOption: { addedConvo: { model: 'gpt-4o', agent_id: 'added-agent' } },
|
||||
modelsConfig: { openai: ['gpt-4o'] },
|
||||
logViolation: jest.fn(),
|
||||
loadTools: jest.fn(),
|
||||
requestFiles: [],
|
||||
conversationId: 'conv-1',
|
||||
parentMessageId: null,
|
||||
allowedProviders: new Set(['openai']),
|
||||
agentConfigs: new Map(),
|
||||
primaryAgentId: 'primary-id',
|
||||
primaryAgent: { id: 'primary-id' },
|
||||
userMCPAuthMap: undefined,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('forwards codeEnvAvailable=true to the added-convo initializeAgent call', async () => {
|
||||
await processAddedConvo(baseParams({ codeEnvAvailable: true }));
|
||||
|
||||
expect(mockInitializeAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ codeEnvAvailable: true }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards codeEnvAvailable=false verbatim (not coerced to undefined)', async () => {
|
||||
/* Symmetric coverage: if the runtime gate is off for the primary, the
|
||||
parallel agent must not accidentally re-enable code execution via a
|
||||
defaulting bug in the destructuring. */
|
||||
await processAddedConvo(baseParams({ codeEnvAvailable: false }));
|
||||
|
||||
expect(mockInitializeAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ codeEnvAvailable: false }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards codeEnvAvailable=undefined when caller omits it (no silent default)', async () => {
|
||||
/* Backstop for the "caller didn't update after Phase 8" case — the
|
||||
added-convo path must not invent a truthy value out of thin air.
|
||||
Matches `initializeAgent`'s own "explicit opt-in" semantics. */
|
||||
await processAddedConvo(baseParams());
|
||||
|
||||
expect(mockInitializeAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ codeEnvAvailable: undefined }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -172,11 +172,16 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
});
|
||||
|
||||
logger.debug(`[ON_TOOL_EXECUTE] loaded ${result.loadedTools?.length ?? 0} tools`);
|
||||
/** Per-agent narrowed flag (admin capability AND agent.tools
|
||||
* includes execute_code), captured in `agentToolContexts` when
|
||||
* the agent initialized. Falls back to `false` on any stray
|
||||
* ctx miss so a skills-only agent never gains sandbox access
|
||||
* even if capability lookup somehow skips. */
|
||||
return enrichWithSkillConfigurable(
|
||||
result,
|
||||
req,
|
||||
ctx.accessibleSkillIds,
|
||||
codeEnvAvailable,
|
||||
ctx.codeEnvAvailable === true,
|
||||
ctx.skillPrimedIdsByName,
|
||||
);
|
||||
},
|
||||
|
|
@ -299,6 +304,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
tool_resources: primaryConfig.tool_resources,
|
||||
actionsEnabled: primaryConfig.actionsEnabled,
|
||||
accessibleSkillIds: primaryConfig.accessibleSkillIds,
|
||||
codeEnvAvailable: primaryConfig.codeEnvAvailable,
|
||||
skillPrimedIdsByName,
|
||||
});
|
||||
|
||||
|
|
@ -323,6 +329,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
scopeSkillIds(accessibleSkillIds, ephemeralSkillsToggle ? undefined : agent.skills),
|
||||
skillStates,
|
||||
defaultActiveOnShare,
|
||||
codeEnvAvailable,
|
||||
},
|
||||
{
|
||||
getAgent: db.getAgent,
|
||||
|
|
@ -364,6 +371,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
tool_resources: config.tool_resources,
|
||||
actionsEnabled: config.actionsEnabled,
|
||||
accessibleSkillIds: config.accessibleSkillIds,
|
||||
codeEnvAvailable: config.codeEnvAvailable,
|
||||
skillPrimedIdsByName: buildSkillPrimedIdsByName(
|
||||
config.manualSkillPrimes,
|
||||
config.alwaysApplySkillPrimes,
|
||||
|
|
@ -404,6 +412,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
parentMessageId,
|
||||
allowedProviders,
|
||||
primaryAgentId: primaryConfig.id,
|
||||
codeEnvAvailable,
|
||||
});
|
||||
|
||||
if (updatedMCPAuthMap) {
|
||||
|
|
@ -421,6 +430,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
tool_resources: config.tool_resources,
|
||||
actionsEnabled: config.actionsEnabled,
|
||||
accessibleSkillIds: config.accessibleSkillIds,
|
||||
codeEnvAvailable: config.codeEnvAvailable,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -452,22 +462,18 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
modelLabel: endpointOption.model_parameters.modelLabel,
|
||||
});
|
||||
|
||||
/** primeInvokedSkills reconstructs bodies of skills invoked in prior turns so
|
||||
* formatAgentMessages can rebuild HumanMessages and re-prime code-env files.
|
||||
* Unlike catalog injection and runtime invocation (both scoped per-agent),
|
||||
* history priming must use the user's full ACL-accessible set: historical
|
||||
* skill calls can reference skills no longer in any active agent's scope
|
||||
* (agent.skills edited, ephemeral toggle flipped), and scoping those out
|
||||
* would drop prior skill context and break file references in follow-up
|
||||
* turns. The ACL check remains the security gate; handleSkillToolCall is
|
||||
* where per-agent scoping prevents NEW invocations. */
|
||||
/** History priming uses the user's full ACL-accessible skill set (not
|
||||
* per-agent scoped) because prior turns may reference skills no longer
|
||||
* in any active agent's scope; the ACL check is the security gate.
|
||||
* `codeEnvAvailable` comes from `primaryConfig` — @see
|
||||
* `InitializedAgent.codeEnvAvailable` for the per-agent narrowing. */
|
||||
const handlePrimeInvokedSkills = skillsCapabilityEnabled
|
||||
? (payload) =>
|
||||
primeInvokedSkills({
|
||||
req,
|
||||
payload,
|
||||
accessibleSkillIds,
|
||||
codeEnvAvailable,
|
||||
codeEnvAvailable: primaryConfig.codeEnvAvailable === true,
|
||||
...getSkillToolDeps(),
|
||||
})
|
||||
: undefined;
|
||||
|
|
|
|||
|
|
@ -15,11 +15,10 @@ const MAX_FILE_SIZE = 150 * 1024 * 1024;
|
|||
/**
|
||||
* Retrieves a download stream for a specified file.
|
||||
* @param {string} fileIdentifier - The identifier for the file (e.g., "session_id/fileId").
|
||||
* @param {string} apiKey - The API key for authentication.
|
||||
* @returns {Promise<AxiosResponse>} A promise that resolves to a readable stream of the file content.
|
||||
* @throws {Error} If there's an error during the download process.
|
||||
*/
|
||||
async function getCodeOutputDownloadStream(fileIdentifier, apiKey) {
|
||||
async function getCodeOutputDownloadStream(fileIdentifier) {
|
||||
try {
|
||||
const baseURL = getCodeBaseURL();
|
||||
/** @type {import('axios').AxiosRequestConfig} */
|
||||
|
|
@ -29,7 +28,6 @@ async function getCodeOutputDownloadStream(fileIdentifier, apiKey) {
|
|||
responseType: 'stream',
|
||||
headers: {
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -54,12 +52,11 @@ async function getCodeOutputDownloadStream(fileIdentifier, apiKey) {
|
|||
* @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` representing the user
|
||||
* @param {import('fs').ReadStream | import('stream').Readable} params.stream - The read stream for the file.
|
||||
* @param {string} params.filename - The name of the file.
|
||||
* @param {string} params.apiKey - The API key for authentication.
|
||||
* @param {string} [params.entity_id] - Optional entity ID for the file.
|
||||
* @returns {Promise<string>}
|
||||
* @throws {Error} If there's an error during the upload process.
|
||||
*/
|
||||
async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = '' }) {
|
||||
async function uploadCodeEnvFile({ req, stream, filename, entity_id = '' }) {
|
||||
try {
|
||||
const form = new FormData();
|
||||
if (entity_id.length > 0) {
|
||||
|
|
@ -75,7 +72,6 @@ async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = ''
|
|||
'Content-Type': 'multipart/form-data',
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
'User-Id': req.user.id,
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -115,12 +111,11 @@ async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = ''
|
|||
* @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 = '' }) {
|
||||
async function batchUploadCodeEnvFiles({ req, files, entity_id = '' }) {
|
||||
try {
|
||||
const form = new FormData();
|
||||
if (entity_id.length > 0) {
|
||||
|
|
@ -138,7 +133,6 @@ async function batchUploadCodeEnvFiles({ req, files, apiKey, entity_id = '' }) {
|
|||
'Content-Type': 'multipart/form-data',
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
'User-Id': req.user.id,
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ describe('Code CRUD', () => {
|
|||
const mockResponse = { data: Readable.from(['chunk']) };
|
||||
mockAxios.mockResolvedValue(mockResponse);
|
||||
|
||||
await getCodeOutputDownloadStream('session-1/file-1', 'test-key');
|
||||
await getCodeOutputDownloadStream('session-1/file-1');
|
||||
|
||||
const callConfig = mockAxios.mock.calls[0][0];
|
||||
expect(callConfig.httpAgent).toBe(codeServerHttpAgent);
|
||||
|
|
@ -47,19 +47,18 @@ describe('Code CRUD', () => {
|
|||
it('should request stream response from the correct URL', async () => {
|
||||
mockAxios.mockResolvedValue({ data: Readable.from(['chunk']) });
|
||||
|
||||
await getCodeOutputDownloadStream('session-1/file-1', 'test-key');
|
||||
await getCodeOutputDownloadStream('session-1/file-1');
|
||||
|
||||
const callConfig = mockAxios.mock.calls[0][0];
|
||||
expect(callConfig.url).toBe('https://code-api.example.com/download/session-1/file-1');
|
||||
expect(callConfig.responseType).toBe('stream');
|
||||
expect(callConfig.timeout).toBe(15000);
|
||||
expect(callConfig.headers['X-API-Key']).toBe('test-key');
|
||||
});
|
||||
|
||||
it('should throw on network error', async () => {
|
||||
mockAxios.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
await expect(getCodeOutputDownloadStream('s/f', 'key')).rejects.toThrow();
|
||||
await expect(getCodeOutputDownloadStream('s/f')).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -68,7 +67,6 @@ describe('Code CRUD', () => {
|
|||
req: { user: { id: 'user-123' } },
|
||||
stream: Readable.from(['file-content']),
|
||||
filename: 'data.csv',
|
||||
apiKey: 'test-key',
|
||||
};
|
||||
|
||||
it('should pass dedicated keepAlive:false agents to axios', async () => {
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ const createDownloadFallback = ({
|
|||
* @param {ServerRequest} params.req - The Express request object.
|
||||
* @param {string} params.id - The file ID from the code environment.
|
||||
* @param {string} params.name - The filename.
|
||||
* @param {string} params.apiKey - The code execution API key.
|
||||
* @param {string} params.toolCallId - The tool call ID that generated the file.
|
||||
* @param {string} params.session_id - The code execution session ID.
|
||||
* @param {string} params.conversationId - The current conversation ID.
|
||||
|
|
@ -81,7 +80,6 @@ const processCodeOutput = async ({
|
|||
req,
|
||||
id,
|
||||
name,
|
||||
apiKey,
|
||||
toolCallId,
|
||||
conversationId,
|
||||
messageId,
|
||||
|
|
@ -108,7 +106,6 @@ const processCodeOutput = async ({
|
|||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -280,15 +277,13 @@ function checkIfActive(dateString) {
|
|||
/**
|
||||
* Retrieves the `lastModified` time string for a specified file from Code Execution Server.
|
||||
*
|
||||
* @param {Object} params - The parameters object.
|
||||
* @param {string} params.fileIdentifier - The identifier for the file (e.g., "session_id/fileId").
|
||||
* @param {string} params.apiKey - The API key for authentication.
|
||||
* @param {string} fileIdentifier - The identifier for the file (e.g., "session_id/fileId").
|
||||
*
|
||||
* @returns {Promise<string|null>}
|
||||
* A promise that resolves to the `lastModified` time string of the file if successful, or null if there is an
|
||||
* error in initialization or fetching the info.
|
||||
*/
|
||||
async function getSessionInfo(fileIdentifier, apiKey) {
|
||||
async function getSessionInfo(fileIdentifier) {
|
||||
try {
|
||||
const baseURL = getCodeBaseURL();
|
||||
const [path, queryString] = fileIdentifier.split('?');
|
||||
|
|
@ -304,7 +299,6 @@ async function getSessionInfo(fileIdentifier, apiKey) {
|
|||
params: queryParams,
|
||||
headers: {
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -327,13 +321,12 @@ async function getSessionInfo(fileIdentifier, apiKey) {
|
|||
* @param {ServerRequest} options.req
|
||||
* @param {Agent['tool_resources']} options.tool_resources
|
||||
* @param {string} [options.agentId] - The agent ID for file access control
|
||||
* @param {string} apiKey
|
||||
* @returns {Promise<{
|
||||
* files: Array<{ id: string; session_id: string; name: string }>,
|
||||
* toolContext: string,
|
||||
* }>}
|
||||
*/
|
||||
const primeFiles = async (options, apiKey) => {
|
||||
const primeFiles = async (options) => {
|
||||
const { tool_resources, req, agentId } = options;
|
||||
const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? [];
|
||||
const agentResourceIds = new Set(file_ids);
|
||||
|
|
@ -414,7 +407,6 @@ const primeFiles = async (options, apiKey) => {
|
|||
stream,
|
||||
filename: file.filename,
|
||||
entity_id: queryParams.entity_id,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
// Preserve existing metadata when adding fileIdentifier
|
||||
|
|
@ -436,7 +428,7 @@ const primeFiles = async (options, apiKey) => {
|
|||
);
|
||||
}
|
||||
};
|
||||
const uploadTime = await getSessionInfo(file.metadata.fileIdentifier, apiKey);
|
||||
const uploadTime = await getSessionInfo(file.metadata.fileIdentifier);
|
||||
if (!uploadTime) {
|
||||
logger.warn(`Failed to get upload time for file ${id} in session ${session_id}`);
|
||||
await reuploadFile();
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ const {
|
|||
getEndpointFileConfig,
|
||||
documentParserMimeTypes,
|
||||
} = require('librechat-data-provider');
|
||||
const { EnvVar } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { sanitizeFilename, parseText, processAudioFile } = require('@librechat/api');
|
||||
const {
|
||||
|
|
@ -503,13 +502,11 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
throw new Error('Code execution is not enabled for Agents');
|
||||
}
|
||||
const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(FileSources.execute_code);
|
||||
const result = await loadAuthValues({ userId: req.user.id, authFields: [EnvVar.CODE_API_KEY] });
|
||||
const stream = fs.createReadStream(file.path);
|
||||
const fileIdentifier = await uploadCodeEnvFile({
|
||||
req,
|
||||
stream,
|
||||
filename: file.originalname,
|
||||
apiKey: result[EnvVar.CODE_API_KEY],
|
||||
entity_id,
|
||||
});
|
||||
fileInfoMetadata = { fileIdentifier };
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ jest.mock('@librechat/data-schemas', () => ({
|
|||
logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/agents', () => ({
|
||||
EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
|
||||
}));
|
||||
jest.mock('@librechat/agents', () => ({}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
sanitizeFilename: jest.fn((n) => n),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ const { logger } = require('@librechat/data-schemas');
|
|||
const { tool: toolFn, DynamicStructuredTool } = require('@langchain/core/tools');
|
||||
const {
|
||||
sleep,
|
||||
EnvVar,
|
||||
StepTypes,
|
||||
GraphEvents,
|
||||
createToolSearch,
|
||||
|
|
@ -60,7 +59,6 @@ const { primeFiles: primeSearchFiles } = require('~/app/clients/tools/util/fileS
|
|||
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
||||
const { manifestToolMap, toolkits } = require('~/app/clients/tools/manifest');
|
||||
const { createOnSearchResults } = require('~/server/services/Tools/search');
|
||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
|
||||
const { resolveConfigServers } = require('~/server/services/MCP');
|
||||
const { recordUsage } = require('~/server/services/Threads');
|
||||
|
|
@ -715,7 +713,6 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
|
|||
},
|
||||
{
|
||||
isBuiltInTool,
|
||||
loadAuthValues,
|
||||
getOrFetchMCPServerTools,
|
||||
getActionToolDefinitions,
|
||||
},
|
||||
|
|
@ -770,7 +767,6 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
|
|||
},
|
||||
{
|
||||
isBuiltInTool,
|
||||
loadAuthValues,
|
||||
getOrFetchMCPServerTools,
|
||||
getActionToolDefinitions,
|
||||
},
|
||||
|
|
@ -793,20 +789,9 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
|
|||
|
||||
if (hasExecuteCode && tool_resources) {
|
||||
try {
|
||||
const authValues = await loadAuthValues({
|
||||
userId: req.user.id,
|
||||
authFields: [EnvVar.CODE_API_KEY],
|
||||
});
|
||||
const codeApiKey = authValues[EnvVar.CODE_API_KEY];
|
||||
|
||||
if (codeApiKey) {
|
||||
const { toolContext } = await primeCodeFiles(
|
||||
{ req, tool_resources, agentId: agent.id },
|
||||
codeApiKey,
|
||||
);
|
||||
if (toolContext) {
|
||||
toolContextMap[Tools.execute_code] = toolContext;
|
||||
}
|
||||
const { toolContext } = await primeCodeFiles({ req, tool_resources, agentId: agent.id });
|
||||
if (toolContext) {
|
||||
toolContextMap[Tools.execute_code] = toolContext;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[loadToolDefinitionsWrapper] Error priming code files:', error);
|
||||
|
|
@ -992,7 +977,6 @@ async function loadAgentTools({
|
|||
agentId: agent.id,
|
||||
agentToolOptions: agent.tool_options,
|
||||
deferredToolsEnabled,
|
||||
loadAuthValues,
|
||||
});
|
||||
|
||||
const agentTools = [];
|
||||
|
|
@ -1253,18 +1237,12 @@ async function loadToolsForExecution({
|
|||
if (isPTC && toolRegistry) {
|
||||
configurable.toolRegistry = toolRegistry;
|
||||
try {
|
||||
const authValues = await loadAuthValues({
|
||||
userId: req.user.id,
|
||||
authFields: [EnvVar.CODE_API_KEY],
|
||||
});
|
||||
const codeApiKey = authValues[EnvVar.CODE_API_KEY];
|
||||
|
||||
if (codeApiKey) {
|
||||
const ptcTool = createProgrammaticToolCallingTool({ apiKey: codeApiKey });
|
||||
allLoadedTools.push(ptcTool);
|
||||
} else {
|
||||
logger.warn('[loadToolsForExecution] PTC requested but CODE_API_KEY not available');
|
||||
}
|
||||
/**
|
||||
* PTC auth is handled by the agents library / sandbox service
|
||||
* directly; LibreChat no longer threads a per-run credential.
|
||||
*/
|
||||
const ptcTool = createProgrammaticToolCallingTool({});
|
||||
allLoadedTools.push(ptcTool);
|
||||
} catch (error) {
|
||||
logger.error('[loadToolsForExecution] Error creating PTC tool:', error);
|
||||
}
|
||||
|
|
@ -1276,10 +1254,7 @@ async function loadToolsForExecution({
|
|||
const bashTool = createBashExecutionTool({});
|
||||
allLoadedTools.push(bashTool);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'[loadToolsForExecution] Failed to create bash_tool — is LIBRECHAT_CODE_API_KEY set in the server environment?',
|
||||
error,
|
||||
);
|
||||
logger.error('[loadToolsForExecution] Failed to create bash_tool', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
useMCPServerManager,
|
||||
useSearchApiKeyForm,
|
||||
useGetAgentsConfig,
|
||||
useCodeApiKeyForm,
|
||||
useToolToggle,
|
||||
} from '~/hooks';
|
||||
import { getTimestampedValue } from '~/utils/timestamps';
|
||||
|
|
@ -22,7 +21,6 @@ interface BadgeRowContextType {
|
|||
artifacts: ReturnType<typeof useToolToggle>;
|
||||
fileSearch: ReturnType<typeof useToolToggle>;
|
||||
codeInterpreter: ReturnType<typeof useToolToggle>;
|
||||
codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>;
|
||||
searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>;
|
||||
mcpServerManager: ReturnType<typeof useMCPServerManager>;
|
||||
}
|
||||
|
|
@ -199,20 +197,14 @@ export default function BadgeRowProvider({
|
|||
}
|
||||
}, [storageSuffix, specName, isSubmitting, setEphemeralAgent]);
|
||||
|
||||
/** CodeInterpreter hooks */
|
||||
const codeApiKeyForm = useCodeApiKeyForm({});
|
||||
const { setIsDialogOpen: setCodeDialogOpen } = codeApiKeyForm;
|
||||
|
||||
/** CodeInterpreter hook — sandbox auth is handled server-side by the
|
||||
* agents library, so the toggle no longer has an auth dialog gate. */
|
||||
const codeInterpreter = useToolToggle({
|
||||
conversationId,
|
||||
storageContextKey,
|
||||
setIsDialogOpen: setCodeDialogOpen,
|
||||
toolKey: Tools.execute_code,
|
||||
localStorageKey: LocalStorageKeys.LAST_CODE_TOGGLE_,
|
||||
authConfig: {
|
||||
toolId: Tools.execute_code,
|
||||
queryOptions: { retry: 1 },
|
||||
},
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
/** WebSearch hooks */
|
||||
|
|
@ -268,7 +260,6 @@ export default function BadgeRowProvider({
|
|||
agentsConfig,
|
||||
conversationId,
|
||||
storageContextKey,
|
||||
codeApiKeyForm,
|
||||
codeInterpreter,
|
||||
searchApiKeyForm,
|
||||
mcpServerManager,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ function CodeInterpreter() {
|
|||
const localize = useLocalize();
|
||||
const context = useBadgeRowContext();
|
||||
const { toggleState: runCode, debouncedChange, isPinned } = context?.codeInterpreter ?? {};
|
||||
const { badgeTriggerRef } = context?.codeApiKeyForm ?? {};
|
||||
|
||||
const canRunCode = useHasAccess({
|
||||
permissionType: PermissionTypes.RUN_CODE,
|
||||
|
|
@ -23,7 +22,6 @@ function CodeInterpreter() {
|
|||
return (
|
||||
(runCode || isPinned) && (
|
||||
<CheckboxButton
|
||||
ref={badgeTriggerRef}
|
||||
className="max-w-fit"
|
||||
checked={runCode}
|
||||
setValue={debouncedChange}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,17 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { AuthType } from 'librechat-data-provider';
|
||||
import SearchApiKeyDialog from '~/components/SidePanel/Agents/Search/ApiKeyDialog';
|
||||
import CodeApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
|
||||
function ToolDialogs() {
|
||||
const context = useBadgeRowContext();
|
||||
const { webSearch, codeInterpreter, searchApiKeyForm, codeApiKeyForm } = context ?? {};
|
||||
const { webSearch, searchApiKeyForm } = context ?? {};
|
||||
const { authData: webSearchAuthData } = webSearch ?? {};
|
||||
const { authData: codeAuthData } = codeInterpreter ?? {};
|
||||
const searchAuthTypes = useMemo(
|
||||
() => webSearchAuthData?.authTypes ?? [],
|
||||
[webSearchAuthData?.authTypes],
|
||||
);
|
||||
const codeAuthType = useMemo(() => codeAuthData?.message ?? false, [codeAuthData?.message]);
|
||||
|
||||
if (!searchApiKeyForm || !codeApiKeyForm) {
|
||||
if (!searchApiKeyForm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -29,41 +25,18 @@ function ToolDialogs() {
|
|||
menuTriggerRef: searchMenuTriggerRef,
|
||||
} = searchApiKeyForm;
|
||||
|
||||
const {
|
||||
methods: codeMethods,
|
||||
onSubmit: codeOnSubmit,
|
||||
isDialogOpen: codeDialogOpen,
|
||||
setIsDialogOpen: setCodeDialogOpen,
|
||||
handleRevokeApiKey: codeHandleRevoke,
|
||||
badgeTriggerRef: codeBadgeTriggerRef,
|
||||
menuTriggerRef: codeMenuTriggerRef,
|
||||
} = codeApiKeyForm;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchApiKeyDialog
|
||||
onSubmit={searchOnSubmit}
|
||||
authTypes={searchAuthTypes}
|
||||
isOpen={searchDialogOpen}
|
||||
onRevoke={searchHandleRevoke}
|
||||
register={searchMethods.register}
|
||||
onOpenChange={setSearchDialogOpen}
|
||||
handleSubmit={searchMethods.handleSubmit}
|
||||
triggerRefs={[searchMenuTriggerRef, searchBadgeTriggerRef]}
|
||||
isToolAuthenticated={webSearchAuthData?.authenticated ?? false}
|
||||
/>
|
||||
<CodeApiKeyDialog
|
||||
onSubmit={codeOnSubmit}
|
||||
isOpen={codeDialogOpen}
|
||||
onRevoke={codeHandleRevoke}
|
||||
register={codeMethods.register}
|
||||
onOpenChange={setCodeDialogOpen}
|
||||
handleSubmit={codeMethods.handleSubmit}
|
||||
triggerRefs={[codeMenuTriggerRef, codeBadgeTriggerRef]}
|
||||
isUserProvided={codeAuthType === AuthType.USER_PROVIDED}
|
||||
isToolAuthenticated={codeAuthData?.authenticated ?? false}
|
||||
/>
|
||||
</>
|
||||
<SearchApiKeyDialog
|
||||
onSubmit={searchOnSubmit}
|
||||
authTypes={searchAuthTypes}
|
||||
isOpen={searchDialogOpen}
|
||||
onRevoke={searchHandleRevoke}
|
||||
register={searchMethods.register}
|
||||
onOpenChange={setSearchDialogOpen}
|
||||
handleSubmit={searchMethods.handleSubmit}
|
||||
triggerRefs={[searchMenuTriggerRef, searchBadgeTriggerRef]}
|
||||
isToolAuthenticated={webSearchAuthData?.authenticated ?? false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,13 +62,10 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
artifacts,
|
||||
fileSearch,
|
||||
mcpServerManager,
|
||||
codeApiKeyForm,
|
||||
codeInterpreter,
|
||||
searchApiKeyForm,
|
||||
} = context ?? {};
|
||||
|
||||
const { setIsDialogOpen: setIsCodeDialogOpen, menuTriggerRef: codeMenuTriggerRef } =
|
||||
codeApiKeyForm ?? {};
|
||||
const { setIsDialogOpen: setIsSearchDialogOpen, menuTriggerRef: searchMenuTriggerRef } =
|
||||
searchApiKeyForm ?? {};
|
||||
const {
|
||||
|
|
@ -76,11 +73,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
setIsPinned: setIsSearchPinned,
|
||||
authData: webSearchAuthData,
|
||||
} = webSearch ?? {};
|
||||
const {
|
||||
isPinned: isCodePinned,
|
||||
setIsPinned: setIsCodePinned,
|
||||
authData: codeAuthData,
|
||||
} = codeInterpreter ?? {};
|
||||
const { isPinned: isCodePinned, setIsPinned: setIsCodePinned } = codeInterpreter ?? {};
|
||||
const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch ?? {};
|
||||
const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts ?? {};
|
||||
const { isPinned: isSkillsPinned, setIsPinned: setIsSkillsPinned } = skills ?? {};
|
||||
|
|
@ -91,11 +84,6 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
return !authTypes.every(([, authType]) => authType === AuthType.SYSTEM_DEFINED);
|
||||
}, [webSearchAuthData?.authTypes]);
|
||||
|
||||
const showCodeSettings = useMemo(
|
||||
() => codeAuthData?.message !== AuthType.SYSTEM_DEFINED,
|
||||
[codeAuthData?.message],
|
||||
);
|
||||
|
||||
const handleWebSearchToggle = useCallback(() => {
|
||||
const newValue = !webSearch?.toggleState;
|
||||
webSearch?.debouncedChange({ value: newValue });
|
||||
|
|
@ -276,26 +264,6 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
<span>{localize('com_assistants_code_interpreter')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{showCodeSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsCodeDialogOpen?.(true);
|
||||
}}
|
||||
ref={codeMenuTriggerRef}
|
||||
className={cn(
|
||||
'rounded p-1 transition-all duration-200',
|
||||
'hover:bg-surface-secondary hover:shadow-sm',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
aria-label="Configure code interpreter"
|
||||
>
|
||||
<div className="h-4 w-4">
|
||||
<Settings className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { Tools, AuthType } from 'librechat-data-provider';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import { TerminalSquareIcon, Check, X } from 'lucide-react';
|
||||
import { Spinner, TooltipAnchor, useToastContext } from '@librechat/client';
|
||||
import type { CodeBarProps } from '~/common';
|
||||
import { useVerifyAgentToolAuth, useToolCallMutation } from '~/data-provider';
|
||||
import ApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
|
||||
import { useLocalize, useCodeApiKeyForm } from '~/hooks';
|
||||
import { useToolCallMutation } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn, normalizeLanguage } from '~/utils';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
|
||||
|
|
@ -24,22 +23,8 @@ const RunCode: React.FC<CodeBarProps & { iconOnly?: boolean }> = React.memo(
|
|||
|
||||
const { messageId, conversationId, partIndex } = useMessageContext();
|
||||
const normalizedLang = useMemo(() => normalizeLanguage(lang), [lang]);
|
||||
const { data } = useVerifyAgentToolAuth(
|
||||
{ toolId: Tools.execute_code },
|
||||
{
|
||||
retry: 1,
|
||||
},
|
||||
);
|
||||
const authType = useMemo(() => data?.message ?? false, [data?.message]);
|
||||
const isAuthenticated = useMemo(() => data?.authenticated ?? false, [data?.authenticated]);
|
||||
const { methods, onSubmit, isDialogOpen, setIsDialogOpen, handleRevokeApiKey } =
|
||||
useCodeApiKeyForm({});
|
||||
|
||||
const handleExecute = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
setIsDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
const codeString: string = codeRef.current?.textContent ?? '';
|
||||
if (
|
||||
typeof codeString !== 'string' ||
|
||||
|
|
@ -58,17 +43,7 @@ const RunCode: React.FC<CodeBarProps & { iconOnly?: boolean }> = React.memo(
|
|||
lang: normalizedLang,
|
||||
code: codeString,
|
||||
});
|
||||
}, [
|
||||
codeRef,
|
||||
execute,
|
||||
partIndex,
|
||||
messageId,
|
||||
blockIndex,
|
||||
conversationId,
|
||||
normalizedLang,
|
||||
setIsDialogOpen,
|
||||
isAuthenticated,
|
||||
]);
|
||||
}, [codeRef, execute, partIndex, messageId, blockIndex, conversationId, normalizedLang]);
|
||||
|
||||
const debouncedExecute = useMemo(
|
||||
() => debounce(handleExecute, 1000, { leading: true }),
|
||||
|
|
@ -180,21 +155,7 @@ const RunCode: React.FC<CodeBarProps & { iconOnly?: boolean }> = React.memo(
|
|||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{iconOnly ? <TooltipAnchor description={label} render={button} /> : button}
|
||||
<ApiKeyDialog
|
||||
onSubmit={onSubmit}
|
||||
isOpen={isDialogOpen}
|
||||
register={methods.register}
|
||||
onRevoke={handleRevokeApiKey}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
handleSubmit={methods.handleSubmit}
|
||||
isToolAuthenticated={isAuthenticated}
|
||||
isUserProvided={authType === AuthType.USER_PROVIDED}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return iconOnly ? <TooltipAnchor description={label} render={button} /> : button;
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { useRef } from 'react';
|
||||
import { KeyRoundIcon } from 'lucide-react';
|
||||
import { AuthType, AgentCapabilities } from 'librechat-data-provider';
|
||||
import { useFormContext, Controller, useWatch } from 'react-hook-form';
|
||||
import { AgentCapabilities } from 'librechat-data-provider';
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import {
|
||||
Checkbox,
|
||||
HoverCard,
|
||||
|
|
@ -11,121 +9,62 @@ import {
|
|||
HoverCardTrigger,
|
||||
} from '@librechat/client';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { useLocalize, useCodeApiKeyForm } from '~/hooks';
|
||||
import ApiKeyDialog from './ApiKeyDialog';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { ESide } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function Action({ authType = '', isToolAuthenticated = false }) {
|
||||
export default function Action() {
|
||||
const localize = useLocalize();
|
||||
const methods = useFormContext<AgentForm>();
|
||||
const { control, setValue } = methods;
|
||||
const apiKeyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const {
|
||||
onSubmit,
|
||||
isDialogOpen,
|
||||
setIsDialogOpen,
|
||||
handleRevokeApiKey,
|
||||
methods: keyFormMethods,
|
||||
} = useCodeApiKeyForm({
|
||||
onSubmit: () => {
|
||||
setValue(AgentCapabilities.execute_code, true, { shouldDirty: true });
|
||||
setTimeout(() => apiKeyButtonRef.current?.focus(), 100);
|
||||
},
|
||||
onRevoke: () => {
|
||||
setValue(AgentCapabilities.execute_code, false, { shouldDirty: true });
|
||||
setTimeout(() => apiKeyButtonRef.current?.focus(), 100);
|
||||
},
|
||||
});
|
||||
|
||||
const runCodeIsEnabled = useWatch({ control, name: AgentCapabilities.execute_code });
|
||||
const isUserProvided = authType === AuthType.USER_PROVIDED;
|
||||
|
||||
const handleCheckboxChange = (checked: boolean) => {
|
||||
if (isToolAuthenticated) {
|
||||
setValue(AgentCapabilities.execute_code, checked, { shouldDirty: true });
|
||||
} else if (runCodeIsEnabled) {
|
||||
setValue(AgentCapabilities.execute_code, false, { shouldDirty: true });
|
||||
} else {
|
||||
setIsDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HoverCard openDelay={50}>
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
name={AgentCapabilities.execute_code}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
id="execute-code-checkbox"
|
||||
checked={runCodeIsEnabled ? runCodeIsEnabled : isToolAuthenticated && field.value}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
value={field.value.toString()}
|
||||
disabled={runCodeIsEnabled ? false : !isToolAuthenticated}
|
||||
aria-labelledby="execute-code-label"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<label
|
||||
id="execute-code-label"
|
||||
htmlFor="execute-code-checkbox"
|
||||
className={cn(
|
||||
'form-check-label text-token-text-primary text-sm',
|
||||
(runCodeIsEnabled || isToolAuthenticated) && 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{localize('com_ui_run_code')}
|
||||
</label>
|
||||
<div className="ml-2 flex gap-2">
|
||||
{isUserProvided && (
|
||||
<button
|
||||
ref={apiKeyButtonRef}
|
||||
type="button"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
aria-label={localize('com_ui_add_code_interpreter_api_key')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isDialogOpen}
|
||||
>
|
||||
<KeyRoundIcon className="h-5 w-5 text-text-primary" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center"
|
||||
aria-label={localize('com_agents_code_interpreter')}
|
||||
>
|
||||
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
</div>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={ESide.Top} className="w-80">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{localize('com_agents_code_interpreter')}
|
||||
</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
<HoverCard openDelay={50}>
|
||||
<div className="flex items-center">
|
||||
<Controller
|
||||
name={AgentCapabilities.execute_code}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
id="execute-code-checkbox"
|
||||
checked={!!field.value}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue(AgentCapabilities.execute_code, checked === true, { shouldDirty: true })
|
||||
}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
value={field.value.toString()}
|
||||
aria-labelledby="execute-code-label"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<label
|
||||
id="execute-code-label"
|
||||
htmlFor="execute-code-checkbox"
|
||||
className="form-check-label text-token-text-primary cursor-pointer text-sm"
|
||||
>
|
||||
{localize('com_ui_run_code')}
|
||||
</label>
|
||||
<div className="ml-2 flex gap-2">
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center"
|
||||
aria-label={localize('com_agents_code_interpreter')}
|
||||
>
|
||||
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
</div>
|
||||
</HoverCard>
|
||||
<ApiKeyDialog
|
||||
isOpen={isDialogOpen}
|
||||
onSubmit={onSubmit}
|
||||
onRevoke={handleRevokeApiKey}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
register={keyFormMethods.register}
|
||||
isToolAuthenticated={isToolAuthenticated}
|
||||
handleSubmit={keyFormMethods.handleSubmit}
|
||||
isUserProvided={authType === AuthType.USER_PROVIDED}
|
||||
triggerRef={apiKeyButtonRef}
|
||||
/>
|
||||
</>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={ESide.Top} className="w-80">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{localize('com_agents_code_interpreter')}
|
||||
</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</div>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
import { OGDialogTemplate, Input, Button, OGDialog } from '@librechat/client';
|
||||
import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form';
|
||||
import type { ApiKeyFormData } from '~/common';
|
||||
import type { RefObject } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function ApiKeyDialog({
|
||||
isOpen,
|
||||
onSubmit,
|
||||
onRevoke,
|
||||
onOpenChange,
|
||||
isUserProvided,
|
||||
isToolAuthenticated,
|
||||
register,
|
||||
handleSubmit,
|
||||
triggerRef,
|
||||
triggerRefs,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: { apiKey: string }) => void;
|
||||
onRevoke: () => void;
|
||||
isUserProvided: boolean;
|
||||
isToolAuthenticated: boolean;
|
||||
register: UseFormRegister<ApiKeyFormData>;
|
||||
handleSubmit: UseFormHandleSubmit<ApiKeyFormData>;
|
||||
triggerRef?: RefObject<HTMLInputElement | HTMLButtonElement>;
|
||||
triggerRefs?: RefObject<HTMLInputElement | HTMLButtonElement>[];
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const languageIcons = [
|
||||
'python.svg',
|
||||
'nodedotjs.svg',
|
||||
'tsnode.svg',
|
||||
'rust.svg',
|
||||
'go.svg',
|
||||
'c.svg',
|
||||
'cplusplus.svg',
|
||||
'php.svg',
|
||||
'fortran.svg',
|
||||
'r.svg',
|
||||
];
|
||||
|
||||
return (
|
||||
<OGDialog
|
||||
open={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
triggerRef={triggerRef}
|
||||
triggerRefs={triggerRefs}
|
||||
>
|
||||
<OGDialogTemplate
|
||||
className="w-11/12 sm:w-[450px]"
|
||||
title=""
|
||||
main={
|
||||
<>
|
||||
<div className="mb-4 text-center font-medium">
|
||||
{localize('com_ui_librechat_code_api_title')}
|
||||
</div>
|
||||
<div className="mb-4 text-center text-sm">
|
||||
{localize('com_ui_librechat_code_api_subtitle')}
|
||||
</div>
|
||||
{/* Language Icons Stack */}
|
||||
<div className="mb-6">
|
||||
<div className="mx-auto mb-4 flex max-w-[400px] flex-wrap justify-center gap-3">
|
||||
{languageIcons.map((icon) => (
|
||||
<div key={icon} className="h-6 w-6">
|
||||
<img
|
||||
src={`assets/${icon}`}
|
||||
alt=""
|
||||
className="h-full w-full object-contain opacity-[0.85] dark:invert"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href="https://code.librechat.ai/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-center text-[15px] font-medium text-blue-500 underline decoration-1 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
{localize('com_ui_librechat_code_api_key')}
|
||||
</a>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={localize('com_ui_enter_api_key')}
|
||||
autoComplete="one-time-code"
|
||||
readOnly={true}
|
||||
onFocus={(e) => (e.target.readOnly = false)}
|
||||
{...register('apiKey', { required: true })}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: handleSubmit(onSubmit),
|
||||
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
|
||||
selectText: localize('com_ui_save'),
|
||||
}}
|
||||
buttons={
|
||||
isUserProvided &&
|
||||
isToolAuthenticated && (
|
||||
<Button
|
||||
onClick={onRevoke}
|
||||
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
|
||||
aria-label={localize('com_ui_revoke')}
|
||||
>
|
||||
{localize('com_ui_revoke')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
showCancelButton={true}
|
||||
/>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import { Tools } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import { useVerifyAgentToolAuth } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Action from './Action';
|
||||
import Files from './Files';
|
||||
|
|
@ -13,7 +11,6 @@ export default function CodeForm({
|
|||
files?: [string, ExtendedFile][];
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { data } = useVerifyAgentToolAuth({ toolId: Tools.execute_code });
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
|
@ -30,7 +27,7 @@ export default function CodeForm({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Action authType={data?.message} isToolAuthenticated={data?.authenticated} />
|
||||
<Action />
|
||||
<Files agent_id={agent_id} files={files} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
export * from './useToolToggle';
|
||||
export { default as useAuthCodeTool } from './useAuthCodeTool';
|
||||
export { default as useCodeApiKeyForm } from './useCodeApiKeyForm';
|
||||
export { default as useSearchApiKeyForm } from './useSearchApiKeyForm';
|
||||
export { default as usePluginDialogHelpers } from './usePluginDialogHelpers';
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AuthType, Tools, QueryKeys } from 'librechat-data-provider';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
|
||||
const useAuthCodeTool = (options?: { isEntityTool: boolean }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const isEntityTool = options?.isEntityTool ?? true;
|
||||
const updateUserPlugins = useUpdateUserPluginsMutation({
|
||||
onMutate: (vars) => {
|
||||
queryClient.setQueryData([QueryKeys.toolAuth, Tools.execute_code], () => ({
|
||||
authenticated: vars.action === 'install',
|
||||
message: AuthType.USER_PROVIDED,
|
||||
}));
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries([QueryKeys.toolAuth, Tools.execute_code]);
|
||||
},
|
||||
onError: () => {
|
||||
queryClient.invalidateQueries([QueryKeys.toolAuth, Tools.execute_code]);
|
||||
},
|
||||
});
|
||||
|
||||
const installTool = useCallback(
|
||||
(apiKey: string) => {
|
||||
updateUserPlugins.mutate({
|
||||
pluginKey: Tools.execute_code,
|
||||
action: 'install',
|
||||
auth: { LIBRECHAT_CODE_API_KEY: apiKey },
|
||||
isEntityTool,
|
||||
});
|
||||
},
|
||||
[updateUserPlugins, isEntityTool],
|
||||
);
|
||||
|
||||
const removeTool = useCallback(() => {
|
||||
updateUserPlugins.mutate({
|
||||
pluginKey: Tools.execute_code,
|
||||
action: 'uninstall',
|
||||
auth: { LIBRECHAT_CODE_API_KEY: null },
|
||||
isEntityTool,
|
||||
});
|
||||
}, [updateUserPlugins, isEntityTool]);
|
||||
|
||||
return {
|
||||
removeTool,
|
||||
installTool,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAuthCodeTool;
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
// client/src/hooks/Plugins/useCodeApiKeyForm.ts
|
||||
import { useRef, useState, useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { ApiKeyFormData } from '~/common';
|
||||
import useAuthCodeTool from '~/hooks/Plugins/useAuthCodeTool';
|
||||
|
||||
export default function useCodeApiKeyForm({
|
||||
onSubmit,
|
||||
onRevoke,
|
||||
}: {
|
||||
onSubmit?: () => void;
|
||||
onRevoke?: () => void;
|
||||
}) {
|
||||
const methods = useForm<ApiKeyFormData>();
|
||||
const menuTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const badgeTriggerRef = useRef<HTMLInputElement>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const { installTool, removeTool } = useAuthCodeTool({ isEntityTool: true });
|
||||
const { reset } = methods;
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
(data: { apiKey: string }) => {
|
||||
reset();
|
||||
installTool(data.apiKey);
|
||||
setIsDialogOpen(false);
|
||||
onSubmit?.();
|
||||
},
|
||||
[onSubmit, reset, installTool],
|
||||
);
|
||||
|
||||
const handleRevokeApiKey = useCallback(() => {
|
||||
reset();
|
||||
removeTool();
|
||||
setIsDialogOpen(false);
|
||||
onRevoke?.();
|
||||
}, [reset, onRevoke, removeTool]);
|
||||
|
||||
return {
|
||||
methods,
|
||||
isDialogOpen,
|
||||
setIsDialogOpen,
|
||||
handleRevokeApiKey,
|
||||
onSubmit: onSubmitHandler,
|
||||
badgeTriggerRef,
|
||||
menuTriggerRef,
|
||||
};
|
||||
}
|
||||
|
|
@ -651,7 +651,6 @@
|
|||
"com_ui_action_button": "Action Button",
|
||||
"com_ui_active": "Active",
|
||||
"com_ui_add": "Add",
|
||||
"com_ui_add_code_interpreter_api_key": "Add Code Interpreter API Key",
|
||||
"com_ui_add_first_bookmark": "Click on a chat to add one",
|
||||
"com_ui_add_first_mcp_server": "Create your first MCP server to get started",
|
||||
"com_ui_add_first_prompt": "Create your first prompt to get started",
|
||||
|
|
@ -1120,9 +1119,6 @@
|
|||
"com_ui_latest_footer": "Every AI for Everyone.",
|
||||
"com_ui_latest_version": "Latest version",
|
||||
"com_ui_leave_blank_to_keep": "Leave blank to keep existing",
|
||||
"com_ui_librechat_code_api_key": "Get your LibreChat Code Interpreter API key",
|
||||
"com_ui_librechat_code_api_subtitle": "Secure. Multi-language. Input/Output Files.",
|
||||
"com_ui_librechat_code_api_title": "Run AI Code",
|
||||
"com_ui_light_theme_enabled": "Light theme enabled",
|
||||
"com_ui_link_copied": "Link copied",
|
||||
"com_ui_link_refreshed": "Link refreshed",
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -59,7 +59,7 @@
|
|||
"@google/genai": "^1.19.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/core": "^0.3.80",
|
||||
"@librechat/agents": "^3.1.68-dev.0",
|
||||
"@librechat/agents": "^3.1.68-dev.1",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
|
|
@ -11894,9 +11894,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@librechat/agents": {
|
||||
"version": "3.1.68-dev.0",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.68-dev.0.tgz",
|
||||
"integrity": "sha512-xpPU5kEYe8/vUcZKAHUxJz6sHxHP0iqLb9kyziioGAzP1v5Bd8OTKOdasUQlDF3P0trkRhVgIs3ga6trGJuz6Q==",
|
||||
"version": "3.1.68-dev.1",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.68-dev.1.tgz",
|
||||
"integrity": "sha512-AYQB20CrqwC9VXyFkXXEBRfLH5WTkSJzLyOHs9Rt++BT8lzjc+1wxJ9aDDu9gO4SQQNE+dvBlWeuY+lyh1utqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.73.0",
|
||||
|
|
@ -44232,7 +44232,7 @@
|
|||
"@google/genai": "^1.19.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/core": "^0.3.80",
|
||||
"@librechat/agents": "^3.1.68-dev.0",
|
||||
"@librechat/agents": "^3.1.68-dev.1",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@smithy/node-http-handler": "^4.4.5",
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@
|
|||
"@google/genai": "^1.19.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/core": "^0.3.80",
|
||||
"@librechat/agents": "^3.1.68-dev.0",
|
||||
"@librechat/agents": "^3.1.68-dev.1",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@smithy/node-http-handler": "^4.4.5",
|
||||
|
|
|
|||
|
|
@ -940,3 +940,201 @@ describe('initializeAgent — skill `allowed-tools` union (Phase 6)', () => {
|
|||
expect(loadTools.mock.calls[0][0].tools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeAgent — execute_code capability expansion', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('expands execute_code into bash_tool + read_file when codeEnvAvailable=true', async () => {
|
||||
const { agent, req, res, loadTools, db } = createMocks();
|
||||
agent.tools = ['execute_code'];
|
||||
|
||||
const result = await initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.OPENAI]),
|
||||
isInitialAgent: true,
|
||||
codeEnvAvailable: true,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const names = (result.toolDefinitions ?? []).map((d) => d.name);
|
||||
expect(names).toContain('bash_tool');
|
||||
expect(names).toContain('read_file');
|
||||
/* The legacy `execute_code` tool def is no longer registered by this
|
||||
path — the string stays in `agent.tools` as the capability trigger
|
||||
but never appears in the tool definitions the LLM sees. */
|
||||
expect(names).not.toContain('execute_code');
|
||||
});
|
||||
|
||||
it('does not register bash_tool + read_file when codeEnvAvailable=false', async () => {
|
||||
const { agent, req, res, loadTools, db } = createMocks();
|
||||
agent.tools = ['execute_code'];
|
||||
|
||||
const result = await initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.OPENAI]),
|
||||
isInitialAgent: true,
|
||||
codeEnvAvailable: false,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const names = (result.toolDefinitions ?? []).map((d) => d.name);
|
||||
expect(names).not.toContain('bash_tool');
|
||||
expect(names).not.toContain('read_file');
|
||||
});
|
||||
|
||||
it('does not register bash_tool + read_file when agent does not request execute_code', async () => {
|
||||
const { agent, req, res, loadTools, db } = createMocks();
|
||||
agent.tools = ['web_search'];
|
||||
|
||||
const result = await initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.OPENAI]),
|
||||
isInitialAgent: true,
|
||||
codeEnvAvailable: true,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
||||
const names = (result.toolDefinitions ?? []).map((d) => d.name);
|
||||
expect(names).not.toContain('bash_tool');
|
||||
expect(names).not.toContain('read_file');
|
||||
});
|
||||
|
||||
it('narrows codeEnvAvailable on InitializedAgent to the per-agent effective value', async () => {
|
||||
/* The admin-level `params.codeEnvAvailable` is AND-ed with
|
||||
`agent.tools.includes('execute_code')` and stored on the returned
|
||||
agent. Downstream runtime code (JS controllers, `primeInvokedSkills`)
|
||||
reads the narrowed value from the stored context so skills-only
|
||||
agents never accidentally trip sandbox-side logic. */
|
||||
const { agent, req, res, loadTools, db } = createMocks();
|
||||
|
||||
// Admin cap on, agent asks for execute_code → effective true.
|
||||
agent.tools = ['execute_code'];
|
||||
const execAgent = await initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.OPENAI]),
|
||||
isInitialAgent: true,
|
||||
codeEnvAvailable: true,
|
||||
},
|
||||
db,
|
||||
);
|
||||
expect(execAgent.codeEnvAvailable).toBe(true);
|
||||
|
||||
// Admin cap on, agent does NOT ask for execute_code → effective false.
|
||||
agent.tools = ['web_search'];
|
||||
const skillsOnlyAgent = await initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.OPENAI]),
|
||||
isInitialAgent: true,
|
||||
codeEnvAvailable: true,
|
||||
},
|
||||
db,
|
||||
);
|
||||
expect(skillsOnlyAgent.codeEnvAvailable).toBe(false);
|
||||
|
||||
// Admin cap off, agent asks for execute_code → still effective false.
|
||||
agent.tools = ['execute_code'];
|
||||
const capOffAgent = await initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.OPENAI]),
|
||||
isInitialAgent: true,
|
||||
codeEnvAvailable: false,
|
||||
},
|
||||
db,
|
||||
);
|
||||
expect(capOffAgent.codeEnvAvailable).toBe(false);
|
||||
|
||||
// Neither → effective false.
|
||||
agent.tools = ['web_search'];
|
||||
const neitherAgent = await initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.OPENAI]),
|
||||
isInitialAgent: true,
|
||||
codeEnvAvailable: false,
|
||||
},
|
||||
db,
|
||||
);
|
||||
expect(neitherAgent.codeEnvAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('trips GOOGLE_TOOL_CONFLICT on Google/Vertex when execute_code expands alongside provider tools', async () => {
|
||||
/* Pre-Phase 8, an `execute_code`-only agent on Google/Vertex with
|
||||
`options.tools` populated would throw GOOGLE_TOOL_CONFLICT because
|
||||
`CodeExecutionToolDefinition` populated `toolDefinitions` and
|
||||
`hasAgentTools` was true. After dropping that registry entry, the
|
||||
check is now gated on the runtime-expanded `bash_tool` + `read_file`
|
||||
pair — so the expansion MUST happen before `hasAgentTools` is
|
||||
computed or the guard silently goes away for this scenario. */
|
||||
const { agent, req, res, loadTools, db } = createMocks({
|
||||
provider: Providers.GOOGLE,
|
||||
overrideProvider: Providers.GOOGLE,
|
||||
});
|
||||
agent.tools = ['execute_code'];
|
||||
|
||||
/* Surface an options.tools array from the provider config — this is
|
||||
the `google_search` / `url_context` built-in LLM tooling that
|
||||
Google/Vertex exposes via provider options. */
|
||||
mockGetProviderConfig.mockReturnValue({
|
||||
getOptions: jest.fn().mockResolvedValue({
|
||||
llmConfig: { model: 'test-model', maxTokens: 4096 },
|
||||
tools: [{ google_search: {} }],
|
||||
} satisfies InitializeResultBase),
|
||||
overrideProvider: Providers.GOOGLE,
|
||||
});
|
||||
|
||||
await expect(
|
||||
initializeAgent(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
agent,
|
||||
loadTools,
|
||||
endpointOption: { endpoint: EModelEndpoint.agents },
|
||||
allowedProviders: new Set([Providers.GOOGLE]),
|
||||
isInitialAgent: true,
|
||||
codeEnvAvailable: true,
|
||||
},
|
||||
db,
|
||||
),
|
||||
).rejects.toThrow(/google_tool_conflict/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -697,6 +697,66 @@ describe('injectSkillCatalog', () => {
|
|||
expect(definedNames).toContain('bash_tool');
|
||||
expect(definedNames).not.toContain('skill');
|
||||
});
|
||||
|
||||
it('does NOT register bash_tool when codeEnvAvailable is false (skills-only agent)', async () => {
|
||||
/* Narrowing regression: `initializeAgent` now passes the per-agent
|
||||
effective flag (admin cap AND `agent.tools.includes('execute_code')`).
|
||||
A skills-only agent passes `false` here, and `bash_tool` must stay
|
||||
out of the registered toolDefinitions even with an active skill
|
||||
catalog. `read_file` still registers — manually-primed skills
|
||||
read their `references/*` from storage without a sandbox. */
|
||||
const owned = makeSkill('owned-skill', userObjectId);
|
||||
const listSkillsByAccess = buildPager([[owned]]);
|
||||
const result = await injectSkillCatalog(
|
||||
baseParams({ listSkillsByAccess, codeEnvAvailable: false }),
|
||||
);
|
||||
const definedNames = (result.toolDefinitions ?? []).map((d) => d.name);
|
||||
expect(definedNames).toContain('read_file');
|
||||
expect(definedNames).toContain('skill');
|
||||
expect(definedNames).not.toContain('bash_tool');
|
||||
});
|
||||
|
||||
it('does not duplicate bash_tool/read_file already registered by the execute_code path', async () => {
|
||||
/* Simulates the Phase 8 dedupe: when an agent has both the
|
||||
`execute_code` capability (registers bash_tool+read_file via
|
||||
`registerCodeExecutionTools` before catalog injection) AND skills
|
||||
active, `injectSkillCatalog` must see the existing entries in the
|
||||
registry and skip re-adding. One copy of each reaches the LLM. */
|
||||
const owned = makeSkill('owned-skill', userObjectId);
|
||||
const listSkillsByAccess = buildPager([[owned]]);
|
||||
type ToolRegistryArg = NonNullable<Parameters<typeof injectSkillCatalog>[0]['toolRegistry']>;
|
||||
type ToolDef = Parameters<ToolRegistryArg['set']>[1];
|
||||
const preBash: ToolDef = {
|
||||
name: 'bash_tool',
|
||||
description: 'pre',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
};
|
||||
const preRead: ToolDef = {
|
||||
name: 'read_file',
|
||||
description: 'pre',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
responseFormat: 'content',
|
||||
};
|
||||
const preRegistry = new Map<string, ToolDef>() as unknown as ToolRegistryArg;
|
||||
preRegistry.set('bash_tool', preBash);
|
||||
preRegistry.set('read_file', preRead);
|
||||
const result = await injectSkillCatalog(
|
||||
baseParams({
|
||||
listSkillsByAccess,
|
||||
codeEnvAvailable: true,
|
||||
toolRegistry: preRegistry,
|
||||
toolDefinitions: [preBash, preRead],
|
||||
}),
|
||||
);
|
||||
const names = (result.toolDefinitions ?? []).map((d) => d.name);
|
||||
const bashOccurrences = names.filter((n) => n === 'bash_tool').length;
|
||||
const readOccurrences = names.filter((n) => n === 'read_file').length;
|
||||
expect(bashOccurrences).toBe(1);
|
||||
expect(readOccurrences).toBe(1);
|
||||
/* Skill tool still gets registered because there is at least one
|
||||
catalog-visible skill. */
|
||||
expect(names).toContain('skill');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSkillPrimeMessage', () => {
|
||||
|
|
|
|||
|
|
@ -236,6 +236,75 @@ describe('discoverConnectedAgents', () => {
|
|||
expect(initArgs.endpointOption.endpoint).toBe(EModelEndpoint.agents);
|
||||
});
|
||||
|
||||
it('forwards codeEnvAvailable to every handoff initializeAgent call', async () => {
|
||||
/* Pre-Phase 8, a handoff sub-agent with `tools: ['execute_code']`
|
||||
got `CodeExecutionToolDefinition` registered unconditionally via
|
||||
the legacy registry path. Phase 8 replaced that with a
|
||||
`params.codeEnvAvailable`-gated expansion inside `initializeAgent`;
|
||||
if discovery forgets to forward the primary's capability flag,
|
||||
handoff agents lose `bash_tool` + `read_file` even though the
|
||||
primary had them. Pin the pass-through so regressions surface. */
|
||||
const primaryConfig = makeConfig('A', [{ from: 'A', to: 'B', edgeType: 'handoff' }]);
|
||||
const getAgent = jest.fn(async () => makeAgent('B', []));
|
||||
const checkPermission = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await discoverConnectedAgents(
|
||||
{
|
||||
req: makeReq(),
|
||||
res: makeRes(),
|
||||
primaryConfig,
|
||||
allowedProviders: new Set(),
|
||||
modelsConfig: { openai: ['gpt-4o'] },
|
||||
loadTools: jest.fn(),
|
||||
codeEnvAvailable: true,
|
||||
},
|
||||
{
|
||||
getAgent,
|
||||
checkPermission,
|
||||
logViolation: jest.fn(),
|
||||
db: {} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockInitializeAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ codeEnvAvailable: true }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards codeEnvAvailable=false verbatim so handoff agents respect disabled capability', async () => {
|
||||
/* Symmetric to the "true" case: when the primary resolved
|
||||
`codeEnvAvailable = false`, handoffs must NOT accidentally
|
||||
re-enable code execution. The passthrough must preserve `false`
|
||||
distinctly from `undefined`. */
|
||||
const primaryConfig = makeConfig('A', [{ from: 'A', to: 'B', edgeType: 'handoff' }]);
|
||||
const getAgent = jest.fn(async () => makeAgent('B', []));
|
||||
const checkPermission = jest.fn().mockResolvedValue(true);
|
||||
|
||||
await discoverConnectedAgents(
|
||||
{
|
||||
req: makeReq(),
|
||||
res: makeRes(),
|
||||
primaryConfig,
|
||||
allowedProviders: new Set(),
|
||||
modelsConfig: { openai: ['gpt-4o'] },
|
||||
loadTools: jest.fn(),
|
||||
codeEnvAvailable: false,
|
||||
},
|
||||
{
|
||||
getAgent,
|
||||
checkPermission,
|
||||
logViolation: jest.fn(),
|
||||
db: {} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockInitializeAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ codeEnvAvailable: false }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the configured resourceType (e.g. REMOTE_AGENT) to checkPermission', async () => {
|
||||
const primaryConfig = makeConfig('A', [{ from: 'A', to: 'B', edgeType: 'handoff' }]);
|
||||
const getAgent = jest.fn(async () => makeAgent('B', []));
|
||||
|
|
|
|||
|
|
@ -73,6 +73,16 @@ export interface DiscoverConnectedAgentsParams {
|
|||
skillStates?: InitializeAgentParams['skillStates'];
|
||||
/** Default active-on-share flag, forwarded to each sub-agent. */
|
||||
defaultActiveOnShare?: InitializeAgentParams['defaultActiveOnShare'];
|
||||
/**
|
||||
* Whether the `execute_code` capability is enabled for the run. Forwarded
|
||||
* verbatim to each handoff sub-agent so `registerCodeExecutionTools` can
|
||||
* expand `agent.tools: ['execute_code']` into the `bash_tool` + `read_file`
|
||||
* pair. Omitted (or `undefined`) → the expansion is skipped, matching the
|
||||
* primary-agent gate; callers that already resolved the capability set
|
||||
* for the primary SHOULD forward the same value here or sub-agents lose
|
||||
* code-execution tooling even though their parent had it.
|
||||
*/
|
||||
codeEnvAvailable?: InitializeAgentParams['codeEnvAvailable'];
|
||||
}
|
||||
|
||||
export interface DiscoverConnectedAgentsDeps {
|
||||
|
|
@ -140,6 +150,7 @@ export async function discoverConnectedAgents(
|
|||
computeAccessibleSkillIds,
|
||||
skillStates,
|
||||
defaultActiveOnShare,
|
||||
codeEnvAvailable,
|
||||
} = params;
|
||||
|
||||
const {
|
||||
|
|
@ -240,6 +251,7 @@ export async function discoverConnectedAgents(
|
|||
accessibleSkillIds: computeAccessibleSkillIds?.(agent),
|
||||
skillStates,
|
||||
defaultActiveOnShare,
|
||||
codeEnvAvailable,
|
||||
},
|
||||
db,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -606,17 +606,7 @@ describe('createToolExecuteHandler', () => {
|
|||
});
|
||||
}
|
||||
|
||||
const ORIGINAL_KEY = process.env.LIBRECHAT_CODE_API_KEY;
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_KEY === undefined) {
|
||||
delete process.env.LIBRECHAT_CODE_API_KEY;
|
||||
} else {
|
||||
process.env.LIBRECHAT_CODE_API_KEY = ORIGINAL_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT call listSkillFiles when codeEnvAvailable is false (even when env key is set)', async () => {
|
||||
process.env.LIBRECHAT_CODE_API_KEY = 'present';
|
||||
it('does NOT call listSkillFiles when codeEnvAvailable is false', async () => {
|
||||
const listSkillFiles = jest.fn().mockResolvedValue([]);
|
||||
const handler = makeSkillHandlerWithFiles({
|
||||
codeEnvAvailable: false,
|
||||
|
|
@ -635,8 +625,7 @@ describe('createToolExecuteHandler', () => {
|
|||
expect(listSkillFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls listSkillFiles when codeEnvAvailable is true AND the env key is set', async () => {
|
||||
process.env.LIBRECHAT_CODE_API_KEY = 'present';
|
||||
it('calls listSkillFiles when codeEnvAvailable is true', async () => {
|
||||
const listSkillFiles = jest.fn().mockResolvedValue([]);
|
||||
const handler = makeSkillHandlerWithFiles({
|
||||
codeEnvAvailable: true,
|
||||
|
|
@ -649,21 +638,5 @@ describe('createToolExecuteHandler', () => {
|
|||
|
||||
expect(listSkillFiles).toHaveBeenCalledWith(SKILL_ID);
|
||||
});
|
||||
|
||||
it('does NOT call listSkillFiles when codeEnvAvailable is true but env key is unset (admin misconfig)', async () => {
|
||||
delete process.env.LIBRECHAT_CODE_API_KEY;
|
||||
const listSkillFiles = jest.fn().mockResolvedValue([]);
|
||||
const handler = makeSkillHandlerWithFiles({
|
||||
codeEnvAvailable: true,
|
||||
listSkillFiles,
|
||||
});
|
||||
|
||||
const [result] = await invokeHandler(handler, [
|
||||
{ id: 'call_no_env', name: Constants.SKILL_TOOL, args: { skillName: 'brand-guidelines' } },
|
||||
]);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(listSkillFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { EnvVar, GraphEvents, Constants, CODE_EXECUTION_TOOLS } from '@librechat/agents';
|
||||
import { GraphEvents, Constants, CODE_EXECUTION_TOOLS } from '@librechat/agents';
|
||||
import type {
|
||||
LCTool,
|
||||
EventHandler,
|
||||
|
|
@ -86,11 +86,10 @@ export interface ToolExecuteOptions {
|
|||
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>;
|
||||
getSessionInfo?: (fileIdentifier: string) => Promise<string | null>;
|
||||
/** 23-hour freshness check */
|
||||
checkIfActive?: (dateString: string) => boolean;
|
||||
/** Persists codeEnvIdentifiers on skill files after upload */
|
||||
|
|
@ -532,7 +531,7 @@ async function handleSkillToolCall(
|
|||
|
||||
// Prime skill files to code env — only when the `execute_code` capability
|
||||
// is enabled for this run. The flag is threaded via configurable upstream
|
||||
// so this gate cannot be bypassed by a stray env var.
|
||||
// so this gate cannot be bypassed.
|
||||
const codeEnvAvailable = mergedConfigurable?.codeEnvAvailable === true;
|
||||
if (
|
||||
codeEnvAvailable &&
|
||||
|
|
@ -542,30 +541,26 @@ async function handleSkillToolCall(
|
|||
getStrategyFunctions &&
|
||||
batchUploadCodeEnvFiles
|
||||
) {
|
||||
const codeApiKey = process.env[EnvVar.CODE_API_KEY] ?? '';
|
||||
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,
|
||||
);
|
||||
try {
|
||||
const skillFiles = await listSkillFiles(skill._id);
|
||||
const primeResult = await primeSkillFiles({
|
||||
skill,
|
||||
skillFiles,
|
||||
req,
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Providers } from '@librechat/agents';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import {
|
||||
Tools,
|
||||
Constants,
|
||||
ErrorTypes,
|
||||
EModelEndpoint,
|
||||
|
|
@ -38,6 +39,7 @@ import {
|
|||
unionPrimeAllowedTools,
|
||||
MAX_PRIMED_SKILLS_PER_TURN,
|
||||
} from './skills';
|
||||
import { registerCodeExecutionTools } from './tools';
|
||||
import { primeResources } from './resources';
|
||||
import type { ResolvedManualSkill, ResolvedAlwaysApplySkill } from './skills';
|
||||
import type { TFilterFilesByAgentAccess } from './resources';
|
||||
|
|
@ -75,6 +77,18 @@ export type InitializedAgent = Agent & {
|
|||
actionsEnabled?: boolean;
|
||||
/** Maximum characters allowed in a single tool result before truncation. */
|
||||
maxToolResultChars?: number;
|
||||
/**
|
||||
* Whether the code-execution environment is available *for this agent*.
|
||||
* Narrower than the incoming `params.codeEnvAvailable` admin flag — this
|
||||
* is `admin_capability_enabled && agent.tools.includes('execute_code')`,
|
||||
* computed once here so downstream code (`injectSkillCatalog`,
|
||||
* `enrichWithSkillConfigurable`, `primeInvokedSkills`) doesn't have to
|
||||
* re-scan the tool list on every runtime handler invocation.
|
||||
* Authoritative for both persisted and ephemeral agents: the
|
||||
* ephemeral-agent toggle is reconciled into `agent.tools` upstream
|
||||
* (`packages/api/src/agents/added.ts`), so the check is uniform.
|
||||
*/
|
||||
codeEnvAvailable: boolean;
|
||||
/** 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) */
|
||||
|
|
@ -696,6 +710,54 @@ export async function initializeAgent(
|
|||
agent.provider = options.provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unify code-execution tools around `bash_tool` + `read_file` when the
|
||||
* agent explicitly lists `execute_code` in its tools and the admin
|
||||
* capability is enabled for the run. The legacy `execute_code` tool
|
||||
* (backed by `CodeExecutionToolDefinition` + `primeCodeFiles`) is no
|
||||
* longer registered; the string `execute_code` on the agent document
|
||||
* stays as the capability-trigger marker but expands into the
|
||||
* skill-flavored tool pair here.
|
||||
*
|
||||
* `effectiveCodeEnvAvailable` is the per-agent truth: the admin-level
|
||||
* `params.codeEnvAvailable` AND the agent actually asking for code
|
||||
* execution. Computed once and reused by the expansion block below,
|
||||
* the `injectSkillCatalog` call, and the returned `InitializedAgent`.
|
||||
* Downstream handlers (runtime `configurable`, `primeInvokedSkills`)
|
||||
* read it from the stored per-agent value so a skills-only agent
|
||||
* never accidentally registers `bash_tool` or primes sandbox files
|
||||
* just because the admin globally enabled code execution.
|
||||
*
|
||||
* Done BEFORE the `hasAgentTools` / GOOGLE_TOOL_CONFLICT gate so
|
||||
* execute-code-only agents on Google/Vertex still trip the conflict
|
||||
* guard when provider-specific tools are also configured. Also before
|
||||
* `injectSkillCatalog` so the skill path's own
|
||||
* `registerCodeExecutionTools` call becomes a no-op via the registry
|
||||
* `.has()` dedupe — exactly one copy of each tool reaches the LLM.
|
||||
*/
|
||||
const agentRequestsCodeExec = (agent.tools ?? []).includes(Tools.execute_code);
|
||||
const effectiveCodeEnvAvailable = params.codeEnvAvailable === true && agentRequestsCodeExec;
|
||||
if (effectiveCodeEnvAvailable) {
|
||||
const codeExecResult = registerCodeExecutionTools({
|
||||
toolRegistry,
|
||||
toolDefinitions,
|
||||
includeBash: true,
|
||||
});
|
||||
toolDefinitions = codeExecResult.toolDefinitions;
|
||||
} else if (agentRequestsCodeExec) {
|
||||
/**
|
||||
* Agent asked for `execute_code` but the admin-level gate is off —
|
||||
* surface a debug log so operators tracing "why isn't code
|
||||
* interpreter working?" get a clear signal. The event-driven tool
|
||||
* loader (`loadToolDefinitionsWrapper`) doesn't log capability-
|
||||
* disabled warnings for the definitions-only path, so without this,
|
||||
* the tool silently vanishes from the LLM's definitions with no trace.
|
||||
*/
|
||||
logger.debug(
|
||||
`[initializeAgent] Agent "${agent.id}" requests execute_code but codeEnvAvailable=${String(params.codeEnvAvailable)}; skipping bash_tool + read_file registration.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Check for tool presence from either full instances or definitions (event-driven mode) */
|
||||
const hasAgentTools = (structuredTools?.length ?? 0) > 0 || (toolDefinitions?.length ?? 0) > 0;
|
||||
|
||||
|
|
@ -756,7 +818,7 @@ export async function initializeAgent(
|
|||
accessibleSkillIds,
|
||||
contextWindowTokens: Number(agentMaxContextTokens) || 200_000,
|
||||
listSkillsByAccess: db?.listSkillsByAccess,
|
||||
codeEnvAvailable: params.codeEnvAvailable,
|
||||
codeEnvAvailable: effectiveCodeEnvAvailable,
|
||||
userId: req.user?.id,
|
||||
skillStates: params.skillStates,
|
||||
defaultActiveOnShare: params.defaultActiveOnShare,
|
||||
|
|
@ -797,6 +859,7 @@ export async function initializeAgent(
|
|||
hasDeferredTools,
|
||||
actionsEnabled,
|
||||
baseContextTokens,
|
||||
codeEnvAvailable: effectiveCodeEnvAvailable,
|
||||
skillCount,
|
||||
accessibleSkillIds: executableSkillIds,
|
||||
manualSkillPrimes,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
* ```
|
||||
*/
|
||||
import { nanoid } from 'nanoid';
|
||||
import { AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { Response as ServerResponse, Request } from 'express';
|
||||
import type {
|
||||
ChatCompletionResponse,
|
||||
|
|
@ -66,7 +67,17 @@ export interface ChatCompletionDependencies {
|
|||
) => Promise<void>;
|
||||
/** Create agent run */
|
||||
createRun?: CreateRunFn;
|
||||
/** App config */
|
||||
/**
|
||||
* App config. Optional, but required for agents with `execute_code` in
|
||||
* their tools: the helper derives `codeEnvAvailable` from
|
||||
* `appConfig?.endpoints?.agents?.capabilities` and forwards it into
|
||||
* `deps.initializeAgent`. When `appConfig` is omitted, the resolved
|
||||
* `codeEnvAvailable` is `undefined`, so `initializeAgent` skips the
|
||||
* `execute_code` → `bash_tool` + `read_file` expansion entirely and
|
||||
* code-requesting agents silently lose sandbox tools. Pass `appConfig`
|
||||
* (even a minimal shape with just `endpoints.agents.capabilities`) to
|
||||
* keep code execution working.
|
||||
*/
|
||||
appConfig?: AppConfig;
|
||||
/** Tool execute options for event-driven tool execution */
|
||||
toolExecuteOptions?: ToolExecuteOptions;
|
||||
|
|
@ -123,6 +134,15 @@ interface InitializeAgentParams {
|
|||
endpointOption?: Record<string, unknown>;
|
||||
allowedProviders: Set<string>;
|
||||
isInitialAgent?: boolean;
|
||||
/**
|
||||
* Whether the `execute_code` capability is enabled for the run.
|
||||
* `initializeAgent` uses this to expand `agent.tools: ['execute_code']`
|
||||
* into the `bash_tool` + `read_file` pair — if the caller's injected
|
||||
* `initializeAgent` implementation consults this flag, agents configured
|
||||
* for code execution will keep working post-Phase-8. Absent / `undefined`
|
||||
* skips the expansion (same semantics as the in-repo controllers).
|
||||
*/
|
||||
codeEnvAvailable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -400,6 +420,26 @@ export async function createAgentChatCompletion(
|
|||
// Build allowed providers set (empty = all allowed)
|
||||
const allowedProviders = new Set<string>();
|
||||
|
||||
/**
|
||||
* Derive `codeEnvAvailable` from the caller-supplied `appConfig` so
|
||||
* `agent.tools: ['execute_code']` still produces `bash_tool` +
|
||||
* `read_file` in the initialized agent's `toolDefinitions` (Phase 8
|
||||
* removed the legacy `execute_code` tool definition, so the
|
||||
* capability flag is the sole gate). Uses the
|
||||
* `AgentCapabilities.execute_code` enum value rather than a string
|
||||
* literal so an enum rename propagates here automatically. Falls
|
||||
* back to `undefined` when the caller doesn't provide `appConfig` —
|
||||
* matching the "explicit opt-in" semantics the in-repo controllers
|
||||
* use.
|
||||
*/
|
||||
const agentsConfig = (deps.appConfig?.endpoints as Record<string, unknown> | undefined)?.agents;
|
||||
const codeEnvAvailable =
|
||||
agentsConfig != null && typeof agentsConfig === 'object'
|
||||
? ((agentsConfig as { capabilities?: string[] }).capabilities ?? []).includes(
|
||||
AgentCapabilities.execute_code,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Initialize the agent first to check for disableStreaming
|
||||
const initializedAgent = await deps.initializeAgent({
|
||||
req,
|
||||
|
|
@ -414,6 +454,7 @@ export async function createAgentChatCompletion(
|
|||
},
|
||||
allowedProviders,
|
||||
isInitialAgent: true,
|
||||
codeEnvAvailable,
|
||||
});
|
||||
|
||||
// Determine if streaming is enabled (check both request and agent config)
|
||||
|
|
|
|||
|
|
@ -32,17 +32,6 @@ describe('enrichWithSkillConfigurable', () => {
|
|||
expect(result.configurable.codeEnvAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('does not inject a codeApiKey key (per-user lookup removed)', () => {
|
||||
const result = enrichWithSkillConfigurable(
|
||||
{ loadedTools: [], configurable: {} },
|
||||
req,
|
||||
accessibleSkillIds,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result.configurable).not.toHaveProperty('codeApiKey');
|
||||
});
|
||||
|
||||
it('threads skillPrimedIdsByName through unchanged', () => {
|
||||
const primed = { 'brand-guidelines': 'abc123' };
|
||||
const result = enrichWithSkillConfigurable(
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@
|
|||
* `codeEnvAvailable` is threaded as a boolean (true when the agent's
|
||||
* `execute_code` capability is enabled). Downstream skill consumers —
|
||||
* the skill-tool handler (for file priming) and `primeInvokedSkills`
|
||||
* (for history re-priming) — gate sandbox uploads on this flag rather
|
||||
* than on API-key presence, so no sandbox traffic occurs for agents
|
||||
* that lack code-execution capability even if
|
||||
* `process.env.LIBRECHAT_CODE_API_KEY` happens to be set.
|
||||
* (for history re-priming) — gate sandbox uploads on this flag so no
|
||||
* sandbox traffic occurs for agents that lack code-execution capability.
|
||||
*
|
||||
* `skillPrimedIdsByName` maps each primed skill name (manual `$` or
|
||||
* always-apply) to the `_id` of the exact doc whose body was primed
|
||||
|
|
|
|||
|
|
@ -44,23 +44,12 @@ function makeDeps(overrides: Partial<PrimeInvokedSkillsDeps> = {}): PrimeInvoked
|
|||
}
|
||||
|
||||
describe('primeInvokedSkills — execute_code capability gate', () => {
|
||||
const ORIGINAL_KEY = process.env.LIBRECHAT_CODE_API_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockExtract.mockReturnValue(new Set(['brand-guidelines']));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_KEY === undefined) {
|
||||
delete process.env.LIBRECHAT_CODE_API_KEY;
|
||||
} else {
|
||||
process.env.LIBRECHAT_CODE_API_KEY = ORIGINAL_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('skips the batch-upload path when codeEnvAvailable is false (even if env key is set)', async () => {
|
||||
process.env.LIBRECHAT_CODE_API_KEY = 'present';
|
||||
it('skips the batch-upload path when codeEnvAvailable is false', async () => {
|
||||
const deps = makeDeps({ codeEnvAvailable: false });
|
||||
|
||||
const result = await primeInvokedSkills(deps);
|
||||
|
|
@ -70,19 +59,7 @@ describe('primeInvokedSkills — execute_code capability gate', () => {
|
|||
expect(deps.batchUploadCodeEnvFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the batch-upload path when codeEnvAvailable is true but env key is unset', async () => {
|
||||
delete process.env.LIBRECHAT_CODE_API_KEY;
|
||||
const deps = makeDeps({ codeEnvAvailable: true });
|
||||
|
||||
const result = await primeInvokedSkills(deps);
|
||||
|
||||
expect(result.skills?.get('brand-guidelines')).toBe('skill body');
|
||||
expect(deps.listSkillFiles).not.toHaveBeenCalled();
|
||||
expect(deps.batchUploadCodeEnvFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enters the batch-upload path when codeEnvAvailable is true and env key is set', async () => {
|
||||
process.env.LIBRECHAT_CODE_API_KEY = 'present';
|
||||
it('enters the batch-upload path when codeEnvAvailable is true', async () => {
|
||||
const deps = makeDeps({ codeEnvAvailable: true });
|
||||
|
||||
await primeInvokedSkills(deps);
|
||||
|
|
@ -90,8 +67,7 @@ describe('primeInvokedSkills — execute_code capability gate', () => {
|
|||
expect(deps.listSkillFiles).toHaveBeenCalledWith(SKILL_ID);
|
||||
});
|
||||
|
||||
it('actually calls batchUploadCodeEnvFiles with the env-sourced apiKey when files are returned', async () => {
|
||||
process.env.LIBRECHAT_CODE_API_KEY = 'sk-from-env';
|
||||
it('calls batchUploadCodeEnvFiles without an apiKey when files are returned', async () => {
|
||||
const fileRecords = [
|
||||
{
|
||||
relativePath: 'references/style.md',
|
||||
|
|
@ -125,7 +101,10 @@ describe('primeInvokedSkills — execute_code capability gate', () => {
|
|||
|
||||
expect(batchUploadCodeEnvFiles).toHaveBeenCalledTimes(1);
|
||||
const [uploadArgs] = batchUploadCodeEnvFiles.mock.calls[0];
|
||||
expect(uploadArgs.apiKey).toBe('sk-from-env');
|
||||
/* Phase 8 deprecation: LibreChat no longer threads an apiKey through
|
||||
the sandbox upload path. The agents library / sandbox service owns
|
||||
auth internally. */
|
||||
expect(uploadArgs).not.toHaveProperty('apiKey');
|
||||
expect(uploadArgs.entity_id).toBe(SKILL_ID.toString());
|
||||
/* One uploaded file per `fileRecords` entry plus the synthetic
|
||||
SKILL.md that `primeSkillFiles` always prepends. */
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Readable } from 'stream';
|
||||
import { Constants, EnvVar } from '@librechat/agents';
|
||||
import { Constants } from '@librechat/agents';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type { ToolSessionMap, CodeSessionContext } from '@librechat/agents';
|
||||
import type { Types } from 'mongoose';
|
||||
|
|
@ -19,7 +19,6 @@ 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;
|
||||
|
|
@ -27,14 +26,13 @@ export interface PrimeSkillFilesParams {
|
|||
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>;
|
||||
getSessionInfo?: (fileIdentifier: string) => Promise<string | null>;
|
||||
/** 23-hour freshness check */
|
||||
checkIfActive?: (dateString: string) => boolean;
|
||||
/** Persists codeEnvIdentifier on skill files after upload */
|
||||
|
|
@ -69,7 +67,6 @@ export async function primeSkillFiles(
|
|||
skill,
|
||||
skillFiles,
|
||||
req,
|
||||
apiKey,
|
||||
getStrategyFunctions,
|
||||
batchUploadCodeEnvFiles,
|
||||
getSessionInfo,
|
||||
|
|
@ -99,7 +96,7 @@ export async function primeSkillFiles(
|
|||
if (!representative) {
|
||||
return false;
|
||||
}
|
||||
const lastModified = await getSessionInfo(representative.codeEnvIdentifier!, apiKey);
|
||||
const lastModified = await getSessionInfo(representative.codeEnvIdentifier!);
|
||||
return !!(lastModified && checkIfActive(lastModified));
|
||||
}),
|
||||
);
|
||||
|
|
@ -163,7 +160,6 @@ export async function primeSkillFiles(
|
|||
const result = await batchUploadCodeEnvFiles({
|
||||
req,
|
||||
files: filesToUpload,
|
||||
apiKey,
|
||||
entity_id: entityId,
|
||||
});
|
||||
// Exclude SKILL.md from the returned files array — it is uploaded to disk
|
||||
|
|
@ -268,8 +264,6 @@ export async function primeInvokedSkills(
|
|||
return {};
|
||||
}
|
||||
|
||||
const apiKey = deps.codeEnvAvailable ? (process.env[EnvVar.CODE_API_KEY] ?? '') : '';
|
||||
|
||||
const skills = new Map<string, string>();
|
||||
|
||||
// Phase 1: Resolve all skills in parallel (DB lookups)
|
||||
|
|
@ -299,7 +293,7 @@ export async function primeInvokedSkills(
|
|||
let sessions: ToolSessionMap | undefined;
|
||||
const skillsWithFiles = resolvedSkills.filter((s) => s.fileCount > 0);
|
||||
|
||||
if (apiKey && skillsWithFiles.length > 0) {
|
||||
if (deps.codeEnvAvailable && skillsWithFiles.length > 0) {
|
||||
// Parallel file list lookups (R2 fix)
|
||||
const fileListResults = await Promise.all(
|
||||
skillsWithFiles.map(async (skill) => ({
|
||||
|
|
@ -329,10 +323,7 @@ export async function primeInvokedSkills(
|
|||
);
|
||||
if (!representative) return true;
|
||||
try {
|
||||
const lastModified = await deps.getSessionInfo?.(
|
||||
representative.codeEnvIdentifier!,
|
||||
apiKey,
|
||||
);
|
||||
const lastModified = await deps.getSessionInfo?.(representative.codeEnvIdentifier!);
|
||||
return !!(lastModified && deps.checkIfActive?.(lastModified));
|
||||
} catch {
|
||||
return false;
|
||||
|
|
@ -378,7 +369,6 @@ export async function primeInvokedSkills(
|
|||
skill,
|
||||
skillFiles: files,
|
||||
req: deps.req,
|
||||
apiKey,
|
||||
getStrategyFunctions: deps.getStrategyFunctions,
|
||||
batchUploadCodeEnvFiles: deps.batchUploadCodeEnvFiles,
|
||||
getSessionInfo: deps.getSessionInfo,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import {
|
||||
formatSkillCatalog,
|
||||
SkillToolDefinition,
|
||||
ReadFileToolDefinition,
|
||||
BashExecutionToolDefinition,
|
||||
} from '@librechat/agents';
|
||||
import { formatSkillCatalog, SkillToolDefinition } from '@librechat/agents';
|
||||
import type { LCToolRegistry, LCTool, InjectedMessage } from '@librechat/agents';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import type { Agent } from 'librechat-data-provider';
|
||||
import type { Types } from 'mongoose';
|
||||
import type { InitializeAgentDbMethods } from './initialize';
|
||||
import { registerCodeExecutionTools } from './tools';
|
||||
|
||||
const SKILL_CATALOG_LIMIT = 100;
|
||||
/** Max pages scanned per run when filtering out inactive skills. */
|
||||
|
|
@ -355,43 +351,30 @@ export async function injectSkillCatalog(
|
|||
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'],
|
||||
};
|
||||
|
||||
/**
|
||||
* `skill` tool is conditional on having anything for the model to invoke;
|
||||
* `read_file` is always registered when any active skill is in scope
|
||||
* (manually-primed disabled skills still need it); `bash_tool` follows
|
||||
* code-env availability as before.
|
||||
* `skill` tool is conditional on having anything for the model to invoke.
|
||||
* `read_file` + `bash_tool` go through `registerCodeExecutionTools` so
|
||||
* a prior registration from `initializeAgent` (for the `execute_code`
|
||||
* capability) doesn't produce a duplicate copy. `read_file` is always
|
||||
* included — manually-primed `disable-model-invocation: true` skills
|
||||
* still need it to load their `references/*` from storage. `bash_tool`
|
||||
* follows `codeEnvAvailable` as before.
|
||||
*/
|
||||
const defs: LCTool[] = [];
|
||||
let workingDefs: LCTool[] = [...(inputDefs ?? [])];
|
||||
if (catalogVisibleSkills.length > 0) {
|
||||
defs.push(skillToolDef);
|
||||
}
|
||||
defs.push(readFileDef);
|
||||
if (codeEnvAvailable) {
|
||||
defs.push(bashToolDef);
|
||||
workingDefs.push(skillToolDef);
|
||||
toolRegistry?.set(skillToolDef.name, skillToolDef);
|
||||
}
|
||||
|
||||
const toolDefinitions = [...(inputDefs ?? []), ...defs];
|
||||
if (toolRegistry) {
|
||||
for (const def of defs) {
|
||||
toolRegistry.set(def.name, def);
|
||||
}
|
||||
}
|
||||
const codeExecResult = registerCodeExecutionTools({
|
||||
toolRegistry,
|
||||
toolDefinitions: workingDefs,
|
||||
includeBash: codeEnvAvailable === true,
|
||||
});
|
||||
workingDefs = codeExecResult.toolDefinitions;
|
||||
|
||||
return {
|
||||
toolDefinitions,
|
||||
toolDefinitions: workingDefs,
|
||||
skillCount: catalogVisibleSkills.length,
|
||||
activeSkillIds: executableSkills.map((s) => s._id),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,26 @@
|
|||
import { buildToolSet, BuildToolSetConfig } from './tools';
|
||||
/**
|
||||
* `@librechat/agents` may ship without the skill-flavored tool definitions on
|
||||
* older installed versions. Stub them so `registerCodeExecutionTools` (which
|
||||
* consumes only the three exports below) can be exercised deterministically.
|
||||
* Mirrors the same pattern used in `__tests__/skills.test.ts`.
|
||||
*/
|
||||
jest.mock('@librechat/agents', () => ({
|
||||
...jest.requireActual('@librechat/agents'),
|
||||
ReadFileToolDefinition: {
|
||||
name: 'read_file',
|
||||
description: 'read file',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
responseFormat: 'content',
|
||||
},
|
||||
BashExecutionToolDefinition: {
|
||||
name: 'bash_tool',
|
||||
description: 'bash',
|
||||
schema: { type: 'object', properties: {} },
|
||||
},
|
||||
}));
|
||||
|
||||
import type { LCTool, LCToolRegistry } from '@librechat/agents';
|
||||
import { buildToolSet, BuildToolSetConfig, registerCodeExecutionTools } from './tools';
|
||||
|
||||
describe('buildToolSet', () => {
|
||||
describe('event-driven mode (toolDefinitions)', () => {
|
||||
|
|
@ -124,3 +146,124 @@ describe('buildToolSet', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerCodeExecutionTools', () => {
|
||||
const makeRegistry = (): LCToolRegistry => new Map() as unknown as LCToolRegistry;
|
||||
|
||||
describe('fresh run (no pre-existing defs or registry entries)', () => {
|
||||
it('registers read_file + bash_tool when includeBash=true', () => {
|
||||
const toolRegistry = makeRegistry();
|
||||
const result = registerCodeExecutionTools({
|
||||
toolRegistry,
|
||||
toolDefinitions: [],
|
||||
includeBash: true,
|
||||
});
|
||||
|
||||
const names = result.toolDefinitions.map((d) => d.name).sort();
|
||||
expect(names).toEqual(['bash_tool', 'read_file']);
|
||||
expect(result.registered.sort()).toEqual(['bash_tool', 'read_file']);
|
||||
expect(toolRegistry.has('read_file')).toBe(true);
|
||||
expect(toolRegistry.has('bash_tool')).toBe(true);
|
||||
});
|
||||
|
||||
it('registers read_file only when includeBash=false', () => {
|
||||
const toolRegistry = makeRegistry();
|
||||
const result = registerCodeExecutionTools({
|
||||
toolRegistry,
|
||||
toolDefinitions: [],
|
||||
includeBash: false,
|
||||
});
|
||||
|
||||
expect(result.toolDefinitions.map((d) => d.name)).toEqual(['read_file']);
|
||||
expect(result.registered).toEqual(['read_file']);
|
||||
expect(toolRegistry.has('read_file')).toBe(true);
|
||||
expect(toolRegistry.has('bash_tool')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves pre-existing unrelated tool definitions', () => {
|
||||
const toolRegistry = makeRegistry();
|
||||
const existing: LCTool[] = [
|
||||
{ name: 'calculator', description: 'calc', parameters: undefined } as LCTool,
|
||||
];
|
||||
const result = registerCodeExecutionTools({
|
||||
toolRegistry,
|
||||
toolDefinitions: existing,
|
||||
includeBash: true,
|
||||
});
|
||||
|
||||
const names = result.toolDefinitions.map((d) => d.name);
|
||||
expect(names).toEqual(['calculator', 'read_file', 'bash_tool']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotence (second call in same run)', () => {
|
||||
it('is a no-op when both tools already live in the registry', () => {
|
||||
const toolRegistry = makeRegistry();
|
||||
const first = registerCodeExecutionTools({
|
||||
toolRegistry,
|
||||
toolDefinitions: [],
|
||||
includeBash: true,
|
||||
});
|
||||
/* Second call simulates skills-path + execute_code-path overlap. */
|
||||
const second = registerCodeExecutionTools({
|
||||
toolRegistry,
|
||||
toolDefinitions: first.toolDefinitions,
|
||||
includeBash: true,
|
||||
});
|
||||
|
||||
expect(second.registered).toEqual([]);
|
||||
expect(second.toolDefinitions).toHaveLength(2);
|
||||
const names = second.toolDefinitions.map((d) => d.name).sort();
|
||||
expect(names).toEqual(['bash_tool', 'read_file']);
|
||||
});
|
||||
|
||||
it('is a no-op when tools already live in toolDefinitions (no registry available)', () => {
|
||||
const existing: LCTool[] = [
|
||||
{ name: 'read_file', description: 'pre', parameters: undefined } as LCTool,
|
||||
{ name: 'bash_tool', description: 'pre', parameters: undefined } as LCTool,
|
||||
];
|
||||
const result = registerCodeExecutionTools({
|
||||
toolRegistry: undefined,
|
||||
toolDefinitions: existing,
|
||||
includeBash: true,
|
||||
});
|
||||
|
||||
expect(result.registered).toEqual([]);
|
||||
expect(result.toolDefinitions).toEqual(existing);
|
||||
});
|
||||
|
||||
it('only adds the missing half when one is already registered', () => {
|
||||
const toolRegistry = makeRegistry();
|
||||
toolRegistry.set('read_file', {
|
||||
name: 'read_file',
|
||||
description: 'prev',
|
||||
parameters: undefined,
|
||||
} as LCTool);
|
||||
const result = registerCodeExecutionTools({
|
||||
toolRegistry,
|
||||
toolDefinitions: [],
|
||||
includeBash: true,
|
||||
});
|
||||
|
||||
expect(result.registered).toEqual(['bash_tool']);
|
||||
const names = result.toolDefinitions.map((d) => d.name);
|
||||
expect(names).toEqual(['bash_tool']);
|
||||
expect(toolRegistry.has('read_file')).toBe(true);
|
||||
expect(toolRegistry.has('bash_tool')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no-registry variant', () => {
|
||||
it('still returns merged toolDefinitions when toolRegistry is undefined', () => {
|
||||
const result = registerCodeExecutionTools({
|
||||
toolRegistry: undefined,
|
||||
toolDefinitions: [],
|
||||
includeBash: true,
|
||||
});
|
||||
|
||||
const names = result.toolDefinitions.map((d) => d.name).sort();
|
||||
expect(names).toEqual(['bash_tool', 'read_file']);
|
||||
expect(result.registered.sort()).toEqual(['bash_tool', 'read_file']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { BashExecutionToolDefinition, ReadFileToolDefinition } from '@librechat/agents';
|
||||
import type { LCTool, LCToolRegistry } from '@librechat/agents';
|
||||
|
||||
interface ToolDefLike {
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
|
|
@ -37,3 +40,98 @@ export function buildToolSet(agentConfig: BuildToolSetConfig | null | undefined)
|
|||
|
||||
return new Set(toolNames.filter((name): name is string => Boolean(name)));
|
||||
}
|
||||
|
||||
export interface RegisterCodeExecutionToolsParams {
|
||||
toolRegistry: LCToolRegistry | undefined;
|
||||
toolDefinitions: LCTool[] | undefined;
|
||||
/**
|
||||
* When `true`, register `bash_tool` alongside `read_file`. When `false`,
|
||||
* register `read_file` only — manually-primed skills still need it to
|
||||
* load `references/*` files from storage even without a sandbox.
|
||||
*
|
||||
* Callers:
|
||||
* - `initializeAgent` passes `true` iff the `execute_code` capability
|
||||
* is enabled for the run.
|
||||
* - `injectSkillCatalog` passes whatever `codeEnvAvailable` resolved to
|
||||
* for the run.
|
||||
*
|
||||
* Both callers reach this helper in the same `initializeAgent` run
|
||||
* sequentially; the registry `.has()` check keeps the second call a
|
||||
* no-op so there is exactly one copy of each tool in `toolDefinitions`.
|
||||
*/
|
||||
includeBash: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterCodeExecutionToolsResult {
|
||||
toolDefinitions: LCTool[];
|
||||
/** Tool names newly registered (skipped names that already existed). */
|
||||
registered: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoisted module-level definitions so `registerCodeExecutionTools` doesn't
|
||||
* re-allocate on every call (including the common no-op second call in the
|
||||
* same run). The shapes are derived entirely from static
|
||||
* `@librechat/agents` exports — no per-request state — so a single frozen
|
||||
* object per tool is safe to share across every agent init.
|
||||
*/
|
||||
const READ_FILE_DEF: LCTool = Object.freeze({
|
||||
name: ReadFileToolDefinition.name,
|
||||
description: ReadFileToolDefinition.description,
|
||||
parameters: ReadFileToolDefinition.parameters as unknown as LCTool['parameters'],
|
||||
responseFormat: ReadFileToolDefinition.responseFormat,
|
||||
}) as LCTool;
|
||||
|
||||
const BASH_TOOL_DEF: LCTool = Object.freeze({
|
||||
name: BashExecutionToolDefinition.name,
|
||||
description: BashExecutionToolDefinition.description,
|
||||
parameters: BashExecutionToolDefinition.schema as unknown as LCTool['parameters'],
|
||||
}) as LCTool;
|
||||
|
||||
/**
|
||||
* Idempotently registers the skill-flavored code-execution tool pair
|
||||
* (`bash_tool` + `read_file`) into the run's tool registry and
|
||||
* tool-definition list.
|
||||
*
|
||||
* Replaces the legacy `CodeExecutionToolDefinition` / `execute_code`
|
||||
* registration. `execute_code` as a capability name and as an
|
||||
* `agent.tools` entry is preserved — it just expands into this tool
|
||||
* pair at load time so there is only one code-execution tool path
|
||||
* end-to-end (no same-run dedupe surprises for agents with both
|
||||
* `execute_code` capability AND skills active).
|
||||
*/
|
||||
export function registerCodeExecutionTools(
|
||||
params: RegisterCodeExecutionToolsParams,
|
||||
): RegisterCodeExecutionToolsResult {
|
||||
const { toolRegistry, toolDefinitions, includeBash } = params;
|
||||
|
||||
const candidates: LCTool[] = includeBash ? [READ_FILE_DEF, BASH_TOOL_DEF] : [READ_FILE_DEF];
|
||||
|
||||
const existingNames = new Set((toolDefinitions ?? []).map((d) => d.name));
|
||||
|
||||
const registered: string[] = [];
|
||||
const newDefs: LCTool[] = [];
|
||||
for (const def of candidates) {
|
||||
const inRegistry = toolRegistry?.has(def.name) === true;
|
||||
const inDefs = existingNames.has(def.name);
|
||||
if (inRegistry || inDefs) {
|
||||
continue;
|
||||
}
|
||||
toolRegistry?.set(def.name, def);
|
||||
newDefs.push(def);
|
||||
registered.push(def.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip the array spread on the common second-call no-op path (both tools
|
||||
* already registered by the first caller in the same run). Returns the
|
||||
* input array by reference; callers treat the return value as immutable.
|
||||
*/
|
||||
if (newDefs.length === 0) {
|
||||
return { toolDefinitions: toolDefinitions ?? [], registered };
|
||||
}
|
||||
return {
|
||||
toolDefinitions: [...(toolDefinitions ?? []), ...newDefs],
|
||||
registered,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,8 +135,6 @@ describe('classification.ts', () => {
|
|||
});
|
||||
|
||||
describe('buildToolClassification with deferredToolsEnabled', () => {
|
||||
const mockLoadAuthValues = jest.fn().mockResolvedValue({});
|
||||
|
||||
const createMCPTool = (name: string, description?: string) =>
|
||||
({
|
||||
name,
|
||||
|
|
@ -163,7 +161,6 @@ describe('classification.ts', () => {
|
|||
agentId: 'agent1',
|
||||
agentToolOptions,
|
||||
deferredToolsEnabled: false,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
||||
expect(result.hasDeferredTools).toBe(false);
|
||||
|
|
@ -184,7 +181,6 @@ describe('classification.ts', () => {
|
|||
agentId: 'agent1',
|
||||
agentToolOptions,
|
||||
deferredToolsEnabled: false,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
||||
expect(result.toolRegistry).toBeDefined();
|
||||
|
|
@ -206,7 +202,6 @@ describe('classification.ts', () => {
|
|||
agentId: 'agent1',
|
||||
agentToolOptions,
|
||||
deferredToolsEnabled: true,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
||||
expect(result.hasDeferredTools).toBe(true);
|
||||
|
|
@ -227,7 +222,6 @@ describe('classification.ts', () => {
|
|||
agentId: 'agent1',
|
||||
agentToolOptions,
|
||||
deferredToolsEnabled: true,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
||||
expect(result.hasDeferredTools).toBe(true);
|
||||
|
|
@ -247,7 +241,6 @@ describe('classification.ts', () => {
|
|||
agentId: 'agent1',
|
||||
agentToolOptions,
|
||||
deferredToolsEnabled: false,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
||||
expect(result.hasDeferredTools).toBe(false);
|
||||
|
|
@ -266,7 +259,6 @@ describe('classification.ts', () => {
|
|||
userId: 'user1',
|
||||
agentId: 'agent1',
|
||||
agentToolOptions,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
||||
expect(result.hasDeferredTools).toBe(true);
|
||||
|
|
@ -282,7 +274,6 @@ describe('classification.ts', () => {
|
|||
userId: 'user1',
|
||||
agentId: 'agent1',
|
||||
deferredToolsEnabled: true,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
||||
expect(result.toolRegistry).toBeUndefined();
|
||||
|
|
@ -292,8 +283,6 @@ describe('classification.ts', () => {
|
|||
});
|
||||
|
||||
describe('buildToolClassification with definitionsOnly', () => {
|
||||
const mockLoadAuthValues = jest.fn().mockResolvedValue({ CODE_API_KEY: 'test-key' });
|
||||
|
||||
const createMCPTool = (name: string, description?: string) =>
|
||||
({
|
||||
name,
|
||||
|
|
@ -320,7 +309,6 @@ describe('classification.ts', () => {
|
|||
agentToolOptions,
|
||||
deferredToolsEnabled: true,
|
||||
definitionsOnly: true,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
||||
expect(result.additionalTools.length).toBe(0);
|
||||
|
|
@ -340,7 +328,6 @@ describe('classification.ts', () => {
|
|||
agentToolOptions,
|
||||
deferredToolsEnabled: true,
|
||||
definitionsOnly: true,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
||||
expect(result.toolDefinitions.some((d) => d.name === 'tool_search')).toBe(true);
|
||||
|
|
@ -361,7 +348,6 @@ describe('classification.ts', () => {
|
|||
agentToolOptions,
|
||||
deferredToolsEnabled: true,
|
||||
definitionsOnly: true,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
||||
expect(result.toolDefinitions.some((d) => d.name === 'run_tools_with_code')).toBe(true);
|
||||
|
|
@ -369,46 +355,6 @@ describe('classification.ts', () => {
|
|||
expect(result.additionalTools.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should NOT call loadAuthValues for PTC when definitionsOnly=true', async () => {
|
||||
const loadedTools: GenericTool[] = [createMCPTool('tool1')];
|
||||
|
||||
const agentToolOptions: AgentToolOptions = {
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
};
|
||||
|
||||
await buildToolClassification({
|
||||
loadedTools,
|
||||
userId: 'user1',
|
||||
agentId: 'agent1',
|
||||
agentToolOptions,
|
||||
deferredToolsEnabled: true,
|
||||
definitionsOnly: true,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
||||
expect(mockLoadAuthValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call loadAuthValues for PTC when definitionsOnly=false', async () => {
|
||||
const loadedTools: GenericTool[] = [createMCPTool('tool1')];
|
||||
|
||||
const agentToolOptions: AgentToolOptions = {
|
||||
tool1: { allowed_callers: ['code_execution'] },
|
||||
};
|
||||
|
||||
await buildToolClassification({
|
||||
loadedTools,
|
||||
userId: 'user1',
|
||||
agentId: 'agent1',
|
||||
agentToolOptions,
|
||||
deferredToolsEnabled: true,
|
||||
definitionsOnly: false,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
||||
expect(mockLoadAuthValues).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create tool instances when definitionsOnly=false (default)', async () => {
|
||||
const loadedTools: GenericTool[] = [createMCPTool('tool1')];
|
||||
|
||||
|
|
@ -422,7 +368,6 @@ describe('classification.ts', () => {
|
|||
agentId: 'agent1',
|
||||
agentToolOptions,
|
||||
deferredToolsEnabled: true,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
});
|
||||
|
||||
expect(result.additionalTools.some((t) => t.name === 'tool_search')).toBe(true);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import {
|
||||
EnvVar,
|
||||
createToolSearch,
|
||||
ToolSearchToolDefinition,
|
||||
createProgrammaticToolCallingTool,
|
||||
|
|
@ -188,11 +187,6 @@ export interface BuildToolClassificationParams {
|
|||
deferredToolsEnabled?: boolean;
|
||||
/** When true, skip creating tool instances (for event-driven mode) */
|
||||
definitionsOnly?: boolean;
|
||||
/** Function to load auth values (dependency injection) */
|
||||
loadAuthValues: (params: {
|
||||
userId: string;
|
||||
authFields: string[];
|
||||
}) => Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
/** Result from building tool classification */
|
||||
|
|
@ -252,13 +246,11 @@ export async function buildToolClassification(
|
|||
params: BuildToolClassificationParams,
|
||||
): Promise<BuildToolClassificationResult> {
|
||||
const {
|
||||
userId,
|
||||
agentId,
|
||||
loadedTools,
|
||||
agentToolOptions,
|
||||
definitionsOnly = false,
|
||||
deferredToolsEnabled = true,
|
||||
loadAuthValues,
|
||||
} = params;
|
||||
const additionalTools: GenericTool[] = [];
|
||||
|
||||
|
|
@ -331,7 +323,6 @@ export async function buildToolClassification(
|
|||
logger.debug(`[buildToolClassification] Tool Search enabled for agent ${agentId}`);
|
||||
}
|
||||
|
||||
/** PTC requires CODE_API_KEY for sandbox execution */
|
||||
if (!hasProgrammaticTools) {
|
||||
return { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools };
|
||||
}
|
||||
|
|
@ -354,18 +345,7 @@ export async function buildToolClassification(
|
|||
}
|
||||
|
||||
try {
|
||||
const authValues = await loadAuthValues({
|
||||
userId,
|
||||
authFields: [EnvVar.CODE_API_KEY],
|
||||
});
|
||||
const codeApiKey = authValues[EnvVar.CODE_API_KEY];
|
||||
|
||||
if (!codeApiKey) {
|
||||
logger.warn('[buildToolClassification] PTC configured but CODE_API_KEY not available');
|
||||
return { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools };
|
||||
}
|
||||
|
||||
const ptcTool = createProgrammaticToolCallingTool({ apiKey: codeApiKey });
|
||||
const ptcTool = createProgrammaticToolCallingTool({});
|
||||
additionalTools.push(ptcTool);
|
||||
|
||||
/** Add PTC definition for event-driven mode */
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import type {
|
|||
} from './definitions';
|
||||
|
||||
describe('definitions.ts', () => {
|
||||
const mockLoadAuthValues = jest.fn().mockResolvedValue({});
|
||||
const mockGetOrFetchMCPServerTools = jest.fn().mockResolvedValue(null);
|
||||
const mockIsBuiltInTool = jest.fn().mockReturnValue(false);
|
||||
|
||||
|
|
@ -27,7 +26,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
|
@ -65,7 +63,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
getActionToolDefinitions: mockGetActionToolDefinitions,
|
||||
};
|
||||
|
||||
|
|
@ -106,7 +103,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
getActionToolDefinitions: mockGetActionToolDefinitions,
|
||||
};
|
||||
|
||||
|
|
@ -142,7 +138,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
getActionToolDefinitions: mockGetActionToolDefinitions,
|
||||
};
|
||||
|
||||
|
|
@ -165,7 +160,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
getActionToolDefinitions: mockGetActionToolDefinitions,
|
||||
};
|
||||
|
||||
|
|
@ -188,7 +182,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
|
@ -198,7 +191,13 @@ describe('definitions.ts', () => {
|
|||
expect(calcDef?.parameters).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include parameters for execute_code native tool', async () => {
|
||||
it('does not resolve `execute_code` as a builtin tool definition (registered by initializeAgent instead)', async () => {
|
||||
/* Phase 8: the legacy `CodeExecutionToolDefinition` is no longer in
|
||||
the registry. `execute_code` stays in `agent.tools` as the
|
||||
capability-trigger marker, but its tool definitions (`bash_tool`
|
||||
+ `read_file`) are added by `registerCodeExecutionTools` during
|
||||
`initializeAgent` — not here. `loadToolDefinitions` must silently
|
||||
drop the name so nothing shadows that path. */
|
||||
mockIsBuiltInTool.mockImplementation((name) => name === 'execute_code');
|
||||
|
||||
const params: LoadToolDefinitionsParams = {
|
||||
|
|
@ -210,18 +209,13 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
||||
const execCodeDef = result.toolDefinitions.find((d) => d.name === 'execute_code');
|
||||
expect(execCodeDef).toBeDefined();
|
||||
expect(execCodeDef?.parameters).toBeDefined();
|
||||
expect(execCodeDef?.parameters?.properties).toHaveProperty('lang');
|
||||
expect(execCodeDef?.parameters?.properties).toHaveProperty('code');
|
||||
expect(execCodeDef?.parameters?.required).toContain('lang');
|
||||
expect(execCodeDef?.parameters?.required).toContain('code');
|
||||
expect(execCodeDef).toBeUndefined();
|
||||
expect(result.toolRegistry.has('execute_code')).toBe(false);
|
||||
});
|
||||
|
||||
it('should include parameters for web_search native tool', async () => {
|
||||
|
|
@ -236,7 +230,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
|
@ -260,7 +253,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
|
@ -284,7 +276,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
|
@ -306,7 +297,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
|
@ -360,7 +350,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
|
@ -419,7 +408,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
|
@ -463,7 +451,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
|
@ -495,7 +482,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
|
@ -522,7 +508,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
|
@ -548,7 +533,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
|
@ -603,7 +587,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
getActionToolDefinitions: mockGetActionToolDefinitions,
|
||||
};
|
||||
|
||||
|
|
@ -636,7 +619,6 @@ describe('definitions.ts', () => {
|
|||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
loadAuthValues: mockLoadAuthValues,
|
||||
getActionToolDefinitions: mockGetActionToolDefinitions,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -48,11 +48,6 @@ export interface LoadToolDefinitionsDeps {
|
|||
getOrFetchMCPServerTools: (userId: string, serverName: string) => Promise<MCPServerTools | null>;
|
||||
/** Checks if a tool name is a known built-in tool */
|
||||
isBuiltInTool: (toolName: string) => boolean;
|
||||
/** Loads auth values for tool search (passed to buildToolClassification) */
|
||||
loadAuthValues: (params: {
|
||||
userId: string;
|
||||
authFields: string[];
|
||||
}) => Promise<Record<string, string>>;
|
||||
/** Loads action tool definitions (schemas) from OpenAPI specs */
|
||||
getActionToolDefinitions?: (
|
||||
agentId: string,
|
||||
|
|
@ -77,8 +72,7 @@ export async function loadToolDefinitions(
|
|||
deps: LoadToolDefinitionsDeps,
|
||||
): Promise<LoadToolDefinitionsResult> {
|
||||
const { userId, agentId, tools, toolOptions = {}, deferredToolsEnabled = false } = params;
|
||||
const { getOrFetchMCPServerTools, isBuiltInTool, loadAuthValues, getActionToolDefinitions } =
|
||||
deps;
|
||||
const { getOrFetchMCPServerTools, isBuiltInTool, getActionToolDefinitions } = deps;
|
||||
|
||||
const emptyResult: LoadToolDefinitionsResult = {
|
||||
toolDefinitions: [],
|
||||
|
|
@ -196,7 +190,6 @@ export async function loadToolDefinitions(
|
|||
userId,
|
||||
agentId,
|
||||
loadedTools,
|
||||
loadAuthValues,
|
||||
deferredToolsEnabled,
|
||||
definitionsOnly: true,
|
||||
agentToolOptions: toolOptions,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
WebSearchToolDefinition,
|
||||
CalculatorToolDefinition,
|
||||
CodeExecutionToolDefinition,
|
||||
} from '@librechat/agents';
|
||||
import { WebSearchToolDefinition, CalculatorToolDefinition } from '@librechat/agents';
|
||||
import { geminiToolkit } from '~/tools/toolkits/gemini';
|
||||
import { oaiToolkit } from '~/tools/toolkits/oai';
|
||||
|
||||
|
|
@ -451,7 +447,17 @@ export const toolDefinitions: Record<string, ToolRegistryDefinition> = {
|
|||
},
|
||||
};
|
||||
|
||||
/** Tool definitions from @librechat/agents */
|
||||
/**
|
||||
* Tool definitions from @librechat/agents.
|
||||
*
|
||||
* `CodeExecutionToolDefinition` (the legacy `execute_code` tool) is
|
||||
* intentionally absent — the `execute_code` capability now expands into
|
||||
* the skill-flavored `bash_tool` + `read_file` pair, registered at
|
||||
* initialize-time by `registerCodeExecutionTools`. Agents whose `tools`
|
||||
* array contains the literal string `execute_code` continue to work:
|
||||
* the capability gate still filters on that string, and the runtime
|
||||
* registers the tool pair on match.
|
||||
*/
|
||||
const agentToolDefinitions: Record<string, ToolRegistryDefinition> = {
|
||||
[CalculatorToolDefinition.name]: {
|
||||
name: CalculatorToolDefinition.name,
|
||||
|
|
@ -459,12 +465,6 @@ const agentToolDefinitions: Record<string, ToolRegistryDefinition> = {
|
|||
schema: CalculatorToolDefinition.schema as unknown as ExtendedJsonSchema,
|
||||
toolType: 'builtin',
|
||||
},
|
||||
[CodeExecutionToolDefinition.name]: {
|
||||
name: CodeExecutionToolDefinition.name,
|
||||
description: CodeExecutionToolDefinition.description,
|
||||
schema: CodeExecutionToolDefinition.schema as unknown as ExtendedJsonSchema,
|
||||
toolType: 'builtin',
|
||||
},
|
||||
[WebSearchToolDefinition.name]: {
|
||||
name: WebSearchToolDefinition.name,
|
||||
description: WebSearchToolDefinition.description,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue