🪢 feat: Enable Tool-Output References for Bash Tool (#12830)

* chore: Update `@librechat/agents` to v3.1.71-dev.0 across package-lock and package.json files

This commit updates the version of the `@librechat/agents` package from `3.1.70` to `3.1.71-dev.0` in the `package-lock.json` and relevant `package.json` files. Additionally, it marks several dependencies as peer dependencies, ensuring better compatibility and integration across the project.

* 🔗 feat: Enable Tool-Output References for bash_tool when codeenv is on

Wires `@librechat/agents`' `RunConfig.toolOutputReferences` into
`createRun()` and the bash tool's LLM-facing description, gated by the
per-agent `effectiveCodeEnvAvailable` flag. The feature auto-activates
for any run where the bash tool is actually registered; SDK defaults
(~400 KB per output, 5 MB total) match the shell-safe budget. No new
env var or yaml capability — piggybacks on the existing `execute_code`
gate.

- `tools.ts`: replace the module-level `BASH_TOOL_DEF` constant with a
  per-call `buildBashToolDef` that wraps `buildBashExecutionToolDescription`.
  Description now includes the `{{tool<idx>turn<turn>}}` reference syntax
  guide iff the new `enableToolOutputReferences` param is true.
- `initialize.ts`: pass `enableToolOutputReferences: effectiveCodeEnvAvailable`
  into `registerCodeExecutionTools`.
- `run.ts`: add `codeEnvAvailable?: boolean` to `RunAgent`, compute the
  flag from `agents[*].codeEnvAvailable`, and conditionally spread
  `toolOutputReferences: { enabled: true }` into `Run.create`.

* 🧪 test: Cover tool-output references gating end-to-end

- `tools.spec.ts`: 3 new cases asserting `bash_tool.description`
  contains `{{tool<idx>turn<turn>}}` iff `enableToolOutputReferences` is
  true (and unset → false).
- `run-summarization.test.ts`: 4 new cases asserting `Run.create` is
  invoked with `toolOutputReferences: { enabled: true }` iff at least
  one `RunAgent.codeEnvAvailable === true`. Covers the present /
  absent / unset / multi-agent-OR cases.
- `initialize.test.ts` + `skills.test.ts`: extend the existing
  `@librechat/agents` jest mocks with a `buildBashExecutionToolDescription`
  stub so suites stay green when the on-disk SDK lags the published
  3.1.71-dev.0 export.

* chore: Update `@librechat/agents` version to `3.1.71-dev.1` in package-lock and package.json files

This commit updates the version of the `@librechat/agents` package from `3.1.71-dev.0` to `3.1.71-dev.1` across the relevant package files. This change ensures consistency and incorporates any updates or fixes from the new version.

* 🪢 fix: Walk Subagents in toolOutputReferences run-level gate

Codex P2 review on PR #12830: the run-level
`enableToolOutputReferences` flag only inspected the top-level
`agents` array. A parent agent without `execute_code` that spawns a
subagent that *does* have it left the SDK's tool-output reference
registry inactive for the run, so the subagent's `bash_tool` calls
saw `{{tool<idx>turn<turn>}}` placeholders pass through to the
shell unsubstituted.

Replace `agents.some(a => a.codeEnvAvailable === true)` with a
recursive `anyAgentHasCodeEnv` helper that walks
`subagentAgentConfigs` transitively. Cycle-safe via a `visited` set,
mirroring the existing `buildSubagentConfigs.ancestors` pattern in
the same module. The bash tool *description* stays per-agent in
`initializeAgent` (only agents with bash actually registered learn
the `{{…}}` syntax), so broadening the run-level gate doesn't
broaden the model-facing surface — it just lets the SDK's shared
registry serve every `ToolNode` the run compiles, which is exactly
the contract the SDK already implements.

Tests cover three new cases: parent-off / subagent-on, parent-off /
child-off / grandchild-on (transitive descent past one level), and
a cyclic A↔B tree with neither codeenv-enabled (asserts both
termination and absence of `toolOutputReferences`). Existing
single-agent and multi-agent tests stay valid since the new helper
returns `true` whenever the previous `.some(...)` did.

* chore: Update `@librechat/agents` version to `3.1.71` in package-lock and package.json files

This commit updates the version of the `@librechat/agents` package from `3.1.71-dev.1` to `3.1.71` across the relevant package files. This change ensures consistency and incorporates any updates or fixes from the stable release.

* review: address audit findings on tool-output references PR

Two findings from comprehensive PR review on #12830:

#1 (MINOR) — `injectSkillCatalog` omitted `enableToolOutputReferences`
when calling `registerCodeExecutionTools`, so its resulting
`bash_tool` description always lacked the `{{tool<idx>turn<turn>}}`
guide. Today this is a no-op because `initializeAgent` registers
first and the registry `.has()` check makes the skills-path call a
dedupe-only operation. But if call order ever flips (skills-first),
the missing flag would silently ship a `bash_tool` without the
syntax guide, and the `initializeAgent` pass would itself become
the no-op — the feature would silently break with no visible error.
Forward `enableToolOutputReferences: codeEnvAvailable === true` so
both call sites produce identical tool definitions regardless of
firing order. Defense-in-depth, not a current bug. Added a test in
`skills.test.ts` that asserts the bash description contains the
`{{tool<idx>turn<turn>}}` marker when `codeEnvAvailable` is on,
exercising the skills caller end-to-end.

#2 (NIT) — `buildBashToolDef` allocated + froze a fresh object on
every agent init. Replaced with two module-level frozen singletons
(`BASH_TOOL_DEF_WITH_OUTPUT_REFS`, `BASH_TOOL_DEF_WITHOUT_OUTPUT_REFS`)
built once at module load via a `createBashToolDef` helper. The
factory now picks the right cached reference instead of building.
Restores the no-allocation intent of the original `BASH_TOOL_DEF`
constant while keeping the per-agent gate behavior. Two new tests
in `tools.spec.ts` pin the contract: identical-flag calls return
reference-equal `bash_tool` defs across registries; opposite-flag
calls return distinct frozen objects with the expected description
content.
This commit is contained in:
Danny Avila 2026-04-26 02:06:23 -07:00 committed by GitHub
parent 8c073b4400
commit f7e47f6012
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 432 additions and 20 deletions

View file

@ -44,7 +44,7 @@
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.80",
"@librechat/agents": "^3.1.70",
"@librechat/agents": "^3.1.71",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",

10
package-lock.json generated
View file

@ -59,7 +59,7 @@
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.80",
"@librechat/agents": "^3.1.70",
"@librechat/agents": "^3.1.71",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
@ -11894,9 +11894,9 @@
}
},
"node_modules/@librechat/agents": {
"version": "3.1.70",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.70.tgz",
"integrity": "sha512-+eZbU4hmPDHqo+1as+BLEEKFA3HcZkhTowSngJ5YwVi4v1sn9OyHhkxNz/svTKDZLHYzU8ONGPf1fHqpvWVw5A==",
"version": "3.1.71",
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.71.tgz",
"integrity": "sha512-xAZQDlEfDJhPluBMugRoe6pZFUgfm+DIDIMCKKEg95DRcXiw8VNggkHfne/1Wf3xoA2l2Qh9RcSu/eARq55CEg==",
"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.70",
"@librechat/agents": "^3.1.71",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@smithy/node-http-handler": "^4.4.5",

View file

@ -95,7 +95,7 @@
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@langchain/core": "^0.3.80",
"@librechat/agents": "^3.1.70",
"@librechat/agents": "^3.1.71",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@smithy/node-http-handler": "^4.4.5",

View file

@ -1,3 +1,18 @@
/**
* Stub `buildBashExecutionToolDescription` since the installed SDK version
* may pre-date the export. Kept as a partial mock so `Providers` and the
* rest of the namespace come through `jest.requireActual`.
*/
jest.mock('@librechat/agents', () => ({
...jest.requireActual('@librechat/agents'),
buildBashExecutionToolDescription: ({
enableToolOutputReferences,
}: {
enableToolOutputReferences?: boolean;
} = {}): string =>
enableToolOutputReferences === true ? 'bash {{tool<idx>turn<turn>}}' : 'bash',
}));
import { Providers } from '@librechat/agents';
import { EModelEndpoint } from 'librechat-data-provider';
import type { Agent } from 'librechat-data-provider';

View file

@ -920,3 +920,155 @@ describe('subagentConfigs', () => {
expect(childInputs.discoveredTools).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Suite: toolOutputReferences gating
// ---------------------------------------------------------------------------
describe('toolOutputReferences gating', () => {
/**
* Captures the top-level `Run.create` config (not just agentInputs) so the
* test can assert presence/absence of the `toolOutputReferences` key.
*/
async function callAndCaptureRunConfig(
overrides?: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const agents = [makeAgent(overrides)];
const signal = new AbortController().signal;
await createRun({
agents: agents as never,
signal,
streaming: true,
streamUsage: true,
});
const createMock = Run.create as jest.Mock;
expect(createMock).toHaveBeenCalledTimes(1);
return createMock.mock.calls[0][0] as Record<string, unknown>;
}
it('passes toolOutputReferences when agent has codeEnvAvailable=true', async () => {
const callArgs = await callAndCaptureRunConfig({ codeEnvAvailable: true });
expect(callArgs.toolOutputReferences).toEqual({ enabled: true });
});
it('omits toolOutputReferences when codeEnvAvailable is false', async () => {
const callArgs = await callAndCaptureRunConfig({ codeEnvAvailable: false });
expect(callArgs).not.toHaveProperty('toolOutputReferences');
});
it('omits toolOutputReferences when codeEnvAvailable is unset', async () => {
const callArgs = await callAndCaptureRunConfig();
expect(callArgs).not.toHaveProperty('toolOutputReferences');
});
it('enables toolOutputReferences if any agent in a multi-agent run has codeEnvAvailable=true', async () => {
const signal = new AbortController().signal;
await createRun({
agents: [
makeAgent({ id: 'agent_a', codeEnvAvailable: false }),
makeAgent({ id: 'agent_b', codeEnvAvailable: true }),
] as never,
signal,
streaming: true,
streamUsage: true,
});
const createMock = Run.create as jest.Mock;
const callArgs = createMock.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.toolOutputReferences).toEqual({ enabled: true });
});
it('enables toolOutputReferences when only a subagent has codeEnvAvailable=true', async () => {
/**
* Real scenario: a parent agent without `execute_code` spawns a
* subagent that does have it. The SDK's shared tool-output
* reference registry serves every ToolNode in the run, so the
* subagent's `bash_tool` benefits from the run-level flag and
* without this gate looking at `subagentAgentConfigs`, the
* subagent's `{{tool<idx>turn<turn>}}` placeholders would pass
* through unsubstituted.
*/
const signal = new AbortController().signal;
const subagent = makeAgent({ id: 'agent_child', codeEnvAvailable: true });
await createRun({
agents: [
makeAgent({
id: 'agent_parent',
codeEnvAvailable: false,
subagents: { enabled: true, allowSelf: false, agent_ids: ['agent_child'] },
subagentAgentConfigs: [subagent],
}),
] as never,
signal,
streaming: true,
streamUsage: true,
});
const createMock = Run.create as jest.Mock;
const callArgs = createMock.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.toolOutputReferences).toEqual({ enabled: true });
});
it('enables toolOutputReferences when a transitively-nested subagent has codeEnvAvailable=true', async () => {
/**
* Multi-level delegation (parent child grandchild): only the
* grandchild has `codeEnvAvailable`. Verifies the recursion
* descends past one level of `subagentAgentConfigs`.
*/
const signal = new AbortController().signal;
const grandchild = makeAgent({ id: 'agent_grandchild', codeEnvAvailable: true });
const child = makeAgent({
id: 'agent_child',
codeEnvAvailable: false,
subagents: { enabled: true, allowSelf: false, agent_ids: ['agent_grandchild'] },
subagentAgentConfigs: [grandchild],
});
await createRun({
agents: [
makeAgent({
id: 'agent_parent',
codeEnvAvailable: false,
subagents: { enabled: true, allowSelf: false, agent_ids: ['agent_child'] },
subagentAgentConfigs: [child],
}),
] as never,
signal,
streaming: true,
streamUsage: true,
});
const createMock = Run.create as jest.Mock;
const callArgs = createMock.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs.toolOutputReferences).toEqual({ enabled: true });
});
it('terminates and omits toolOutputReferences for a cyclic agent tree with no codeenv', async () => {
/**
* Cycle safety: `A → B → A`, neither has `codeEnvAvailable`. The
* `visited` set in `anyAgentHasCodeEnv` must short-circuit the
* recursion without it this would stack-overflow before
* `Run.create` is reached. Mirrors the cycle-safety pattern
* `buildSubagentConfigs` already uses elsewhere in this module.
*/
const signal = new AbortController().signal;
type CyclicAgent = ReturnType<typeof makeAgent> & {
subagentAgentConfigs?: ReturnType<typeof makeAgent>[];
};
const a = makeAgent({ id: 'agent_a', codeEnvAvailable: false }) as CyclicAgent;
const b = makeAgent({ id: 'agent_b', codeEnvAvailable: false }) as CyclicAgent;
a.subagentAgentConfigs = [b];
b.subagentAgentConfigs = [a];
await createRun({
agents: [a] as never,
signal,
streaming: true,
streamUsage: true,
});
const createMock = Run.create as jest.Mock;
const callArgs = createMock.mock.calls[0][0] as Record<string, unknown>;
expect(callArgs).not.toHaveProperty('toolOutputReferences');
});
});

View file

@ -24,6 +24,12 @@ jest.mock('@librechat/agents', () => ({
description: 'bash',
schema: {},
},
buildBashExecutionToolDescription: ({
enableToolOutputReferences,
}: {
enableToolOutputReferences?: boolean;
} = {}): string =>
enableToolOutputReferences === true ? 'bash {{tool<idx>turn<turn>}}' : 'bash',
}));
import { Types } from 'mongoose';
@ -869,6 +875,26 @@ describe('injectSkillCatalog', () => {
expect(definedNames).not.toContain('skill');
});
it('registers bash_tool with the tool-output reference syntax guide when codeEnvAvailable', async () => {
/**
* Symmetry check with `initializeAgent`'s call: `injectSkillCatalog`
* must forward `enableToolOutputReferences` so that if call order
* ever flips (skills-first), the resulting `bash_tool` description
* still contains the `{{tool<idx>turn<turn>}}` syntax guide. Using
* the registry mock's stub of `buildBashExecutionToolDescription`
* (defined at the top of this file), the guide collapses to a
* `{{tool<idx>turn<turn>}}` literal substring when the flag is on
* and is absent when off.
*/
const owned = makeSkill('owned-skill', userObjectId);
const listSkillsByAccess = buildPager([[owned]]);
const result = await injectSkillCatalog(
baseParams({ listSkillsByAccess, codeEnvAvailable: true }),
);
const bashDef = (result.toolDefinitions ?? []).find((d) => d.name === 'bash_tool');
expect(bashDef?.description).toContain('{{tool<idx>turn<turn>}}');
});
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')`).

View file

@ -742,6 +742,7 @@ export async function initializeAgent(
toolRegistry,
toolDefinitions,
includeBash: true,
enableToolOutputReferences: effectiveCodeEnvAvailable,
});
toolDefinitions = codeExecResult.toolDefinitions;
} else if (agentRequestsCodeExec) {

View file

@ -239,6 +239,14 @@ type RunAgent = Omit<Agent, 'tools'> & {
toolDefinitions?: LCTool[];
/** Precomputed flag indicating if any tools have defer_loading enabled */
hasDeferredTools?: boolean;
/**
* Per-agent codeenv gate set by `initializeAgent`: admin-level
* `execute_code` capability AND the agent actually requested
* `execute_code` in its tools. Used here to enable
* `RunConfig.toolOutputReferences` only on runs where the bash tool
* is actually registered.
*/
codeEnvAvailable?: boolean;
/** Optional per-agent summarization overrides */
summarization?: SummarizationConfig;
/**
@ -502,6 +510,43 @@ function computeEffectiveMaxContextTokens(
/** Identifier for the self-spawn subagent (reuses parent's AgentInputs in an isolated child graph). */
const SELF_SUBAGENT_TYPE = 'self';
/**
* Recursive any-true check across the agent tree: returns `true` if this
* agent or any subagent (transitively) has the per-agent codeenv gate
* enabled.
*
* The SDK's tool-output reference registry is shared across every
* `ToolNode` compiled from the run's graph (parent + every subagent
* alike), so a single subagent with `bash_tool` registered is enough to
* make `RunConfig.toolOutputReferences` worth activating for the whole
* run without it, the subagent's `{{tool<idx>turn<turn>}}`
* placeholders would pass through to the shell unsubstituted.
*
* Cycle-safe via a `visited` set, mirroring `buildSubagentConfigs`'s
* `ancestors` pattern. The bash tool description itself is still gated
* per-agent in `initializeAgent`, so only agents that actually have
* bash registered learn the `{{…}}` syntax broadening the run-level
* registry gate doesn't broaden the model-facing surface.
*/
function anyAgentHasCodeEnv(agents: RunAgent[], visited: Set<string> = new Set()): boolean {
for (const agent of agents) {
if (visited.has(agent.id)) {
continue;
}
visited.add(agent.id);
if (agent.codeEnvAvailable === true) {
return true;
}
if (
agent.subagentAgentConfigs != null &&
anyAgentHasCodeEnv(agent.subagentAgentConfigs, visited)
) {
return true;
}
}
return false;
}
/**
* Builds SubagentConfig entries for an agent: optional self-spawn plus any
* explicit child agents loaded in `agent.subagentAgentConfigs`. Returns an empty
@ -818,6 +863,25 @@ export async function createRun({
(graphConfig as StandardGraphConfig).type = 'standard';
}
/**
* Enable tool-output references when the bash tool is actually
* present anywhere in this run top-level agent OR any subagent
* (transitively). `codeEnvAvailable` on each `RunAgent` is the
* per-agent gate (admin `execute_code` capability AND the agent's
* own `tools` listing `execute_code`), so the feature follows the
* same activation as the bash-tool registration in
* `initializeAgent`. The walk into `subagentAgentConfigs` is
* load-bearing: a parent without `execute_code` can spawn a
* subagent that has it, and the SDK's shared registry serves
* every `ToolNode` compiled from this run's graph so missing
* subagents in this gate would leave the child's
* `{{tool<idx>turn<turn>}}` placeholders unsubstituted. SDK
* defaults (~400 KB per output, 5 MB total) keep substituted
* payloads inside typical shell ARG_MAX limits, so no overrides
* are needed for the experimental rollout.
*/
const enableToolOutputReferences = anyAgentHasCodeEnv(agents);
return Run.create({
runId,
graphConfig,
@ -826,5 +890,8 @@ export async function createRun({
indexTokenCountMap,
initialSessions,
calibrationRatio,
...(enableToolOutputReferences && {
toolOutputReferences: { enabled: true },
}),
});
}

View file

@ -418,10 +418,23 @@ export async function injectSkillCatalog(
toolRegistry?.set(skillToolDef.name, skillToolDef);
}
/**
* Forward `enableToolOutputReferences` to keep the skills caller
* symmetric with `initializeAgent`'s call. Today `initializeAgent`
* registers `bash_tool` first and the registry `.has()` check makes
* this call a no-op but if call order ever flips (skills-first),
* a missing flag here would silently produce a `bash_tool`
* description without the `{{tool<idx>turn<turn>}}` guide, and the
* `initializeAgent` pass would become the no-op. Mirror the gate
* `initializeAgent` uses (`effectiveCodeEnvAvailable`, which here
* is `codeEnvAvailable === true`) so both paths produce identical
* tool definitions regardless of which fires first.
*/
const codeExecResult = registerCodeExecutionTools({
toolRegistry,
toolDefinitions: workingDefs,
includeBash: codeEnvAvailable === true,
enableToolOutputReferences: codeEnvAvailable === true,
});
workingDefs = codeExecResult.toolDefinitions;

View file

@ -17,6 +17,17 @@ jest.mock('@librechat/agents', () => ({
description: 'bash',
schema: { type: 'object', properties: {} },
},
/**
* Deterministic stub mirroring the SDK's `buildBashExecutionToolDescription`:
* appends an LLM-facing reference-syntax marker only when
* `enableToolOutputReferences` is true.
*/
buildBashExecutionToolDescription: ({
enableToolOutputReferences,
}: {
enableToolOutputReferences?: boolean;
} = {}): string =>
enableToolOutputReferences === true ? 'bash {{tool<idx>turn<turn>}}' : 'bash',
}));
import type { LCTool, LCToolRegistry } from '@librechat/agents';
@ -266,4 +277,96 @@ describe('registerCodeExecutionTools', () => {
expect(result.registered.sort()).toEqual(['bash_tool', 'read_file']);
});
});
describe('enableToolOutputReferences', () => {
const findBashDef = (defs: LCTool[]): LCTool | undefined =>
defs.find((d) => d.name === 'bash_tool');
it('appends the {{tool<idx>turn<turn>}} guide when flag is true', () => {
const result = registerCodeExecutionTools({
toolRegistry: makeRegistry(),
toolDefinitions: [],
includeBash: true,
enableToolOutputReferences: true,
});
const bash = findBashDef(result.toolDefinitions);
expect(bash?.description).toContain('{{tool<idx>turn<turn>}}');
});
it('omits the {{tool<idx>turn<turn>}} guide when flag is false', () => {
const result = registerCodeExecutionTools({
toolRegistry: makeRegistry(),
toolDefinitions: [],
includeBash: true,
enableToolOutputReferences: false,
});
const bash = findBashDef(result.toolDefinitions);
expect(bash?.description).not.toContain('{{tool<idx>turn<turn>}}');
});
it('omits the guide by default when flag is unspecified', () => {
const result = registerCodeExecutionTools({
toolRegistry: makeRegistry(),
toolDefinitions: [],
includeBash: true,
});
const bash = findBashDef(result.toolDefinitions);
expect(bash?.description).not.toContain('{{tool<idx>turn<turn>}}');
});
it('returns the same frozen bash_tool reference across calls with the same flag', () => {
/**
* The two `bash_tool` variants are cached at module scope so
* repeated agent inits in the same process don't re-allocate
* + re-freeze + re-build the description on every call.
* Asserting reference equality across two fresh registries
* pins that contract a regression that switches back to a
* per-call `Object.freeze` would fail this test.
*/
const a = registerCodeExecutionTools({
toolRegistry: makeRegistry(),
toolDefinitions: [],
includeBash: true,
enableToolOutputReferences: true,
});
const b = registerCodeExecutionTools({
toolRegistry: makeRegistry(),
toolDefinitions: [],
includeBash: true,
enableToolOutputReferences: true,
});
expect(findBashDef(a.toolDefinitions)).toBe(findBashDef(b.toolDefinitions));
});
it('returns distinct frozen references for the two flag variants', () => {
/**
* Sanity check on the two-singleton cache: the with-refs and
* without-refs definitions are distinct objects so toggling
* the flag in `registerCodeExecutionTools` actually picks up
* the alternate description, not the same cached reference.
*/
const withRefs = registerCodeExecutionTools({
toolRegistry: makeRegistry(),
toolDefinitions: [],
includeBash: true,
enableToolOutputReferences: true,
});
const withoutRefs = registerCodeExecutionTools({
toolRegistry: makeRegistry(),
toolDefinitions: [],
includeBash: true,
enableToolOutputReferences: false,
});
const a = findBashDef(withRefs.toolDefinitions);
const b = findBashDef(withoutRefs.toolDefinitions);
expect(a).not.toBe(b);
expect(a?.description).toContain('{{tool<idx>turn<turn>}}');
expect(b?.description).not.toContain('{{tool<idx>turn<turn>}}');
});
});
});

View file

@ -1,4 +1,8 @@
import { BashExecutionToolDefinition, ReadFileToolDefinition } from '@librechat/agents';
import {
BashExecutionToolDefinition,
ReadFileToolDefinition,
buildBashExecutionToolDescription,
} from '@librechat/agents';
import type { LCTool, LCToolRegistry } from '@librechat/agents';
interface ToolDefLike {
@ -60,6 +64,13 @@ export interface RegisterCodeExecutionToolsParams {
* no-op so there is exactly one copy of each tool in `toolDefinitions`.
*/
includeBash: boolean;
/**
* When `true`, the registered `bash_tool` description includes the
* LLM-facing `{{tool<idx>turn<turn>}}` reference syntax guide so the
* model knows it can substitute prior tool outputs in subsequent
* commands. Paired with `RunConfig.toolOutputReferences` in `createRun`.
*/
enableToolOutputReferences?: boolean;
}
export interface RegisterCodeExecutionToolsResult {
@ -69,11 +80,11 @@ export interface RegisterCodeExecutionToolsResult {
}
/**
* 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.
* Hoisted module-level definition for `read_file` so
* `registerCodeExecutionTools` doesn't re-allocate on every call. The
* shape is derived entirely from a static `@librechat/agents` export
* no per-request state so a single frozen object is safe to share
* across every agent init.
*/
const READ_FILE_DEF: LCTool = Object.freeze({
name: ReadFileToolDefinition.name,
@ -82,11 +93,33 @@ const READ_FILE_DEF: LCTool = Object.freeze({
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;
/**
* The `bash_tool` description varies along exactly one axis whether
* the LLM-facing `{{tool<idx>turn<turn>}}` reference syntax guide is
* appended so two frozen module-level singletons cover every call
* site. Both are shaped identically to the legacy `BASH_TOOL_DEF`
* constant; only `description` differs. Sharing references across
* every agent init avoids per-call `Object.freeze` + SDK
* `buildBashExecutionToolDescription` work, matching the no-allocation
* intent of the original constant while keeping the per-agent gate
* behavior introduced for tool-output references.
*/
function createBashToolDef(enableToolOutputReferences: boolean): LCTool {
return Object.freeze({
name: BashExecutionToolDefinition.name,
description: buildBashExecutionToolDescription({ enableToolOutputReferences }),
parameters: BashExecutionToolDefinition.schema as unknown as LCTool['parameters'],
}) as LCTool;
}
const BASH_TOOL_DEF_WITH_OUTPUT_REFS = createBashToolDef(true);
const BASH_TOOL_DEF_WITHOUT_OUTPUT_REFS = createBashToolDef(false);
function buildBashToolDef(opts: { enableToolOutputReferences: boolean }): LCTool {
return opts.enableToolOutputReferences
? BASH_TOOL_DEF_WITH_OUTPUT_REFS
: BASH_TOOL_DEF_WITHOUT_OUTPUT_REFS;
}
/**
* Idempotently registers the skill-flavored code-execution tool pair
@ -103,9 +136,11 @@ const BASH_TOOL_DEF: LCTool = Object.freeze({
export function registerCodeExecutionTools(
params: RegisterCodeExecutionToolsParams,
): RegisterCodeExecutionToolsResult {
const { toolRegistry, toolDefinitions, includeBash } = params;
const { toolRegistry, toolDefinitions, includeBash, enableToolOutputReferences = false } = params;
const candidates: LCTool[] = includeBash ? [READ_FILE_DEF, BASH_TOOL_DEF] : [READ_FILE_DEF];
const candidates: LCTool[] = includeBash
? [READ_FILE_DEF, buildBashToolDef({ enableToolOutputReferences })]
: [READ_FILE_DEF];
const existingNames = new Set((toolDefinitions ?? []).map((d) => d.name));