mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
🪢 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:
parent
8c073b4400
commit
f7e47f6012
11 changed files with 432 additions and 20 deletions
|
|
@ -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
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.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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')`).
|
||||
|
|
|
|||
|
|
@ -742,6 +742,7 @@ export async function initializeAgent(
|
|||
toolRegistry,
|
||||
toolDefinitions,
|
||||
includeBash: true,
|
||||
enableToolOutputReferences: effectiveCodeEnvAvailable,
|
||||
});
|
||||
toolDefinitions = codeExecResult.toolDefinitions;
|
||||
} else if (agentRequestsCodeExec) {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>}}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue