Skip to content
Merged
35 changes: 32 additions & 3 deletions src/agents/AgentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ANTHROPIC_TOOL_TOKEN_MULTIPLIER,
DEFAULT_TOOL_TOKEN_MULTIPLIER,
ContentTypes,
Constants,
Providers,
} from '@/common';
import { createSchemaOnlyTools } from '@/tools/schema';
Expand Down Expand Up @@ -389,7 +390,7 @@ export class AgentContext {
/**
* Builds instructions text for tools that are ONLY callable via programmatic code execution.
* These tools cannot be called directly by the LLM but are available through the
* run_tools_with_code tool.
* configured programmatic tool.
*
* Includes:
* - Code_execution-only tools that are NOT deferred
Expand All @@ -416,6 +417,7 @@ export class AgentContext {

if (programmaticOnlyTools.length === 0) return '';

const programmaticTool = this.getProgrammaticToolInstructionTarget();
const toolDescriptions = programmaticOnlyTools
.map((tool) => {
let desc = `- **${tool.name}**`;
Expand All @@ -431,12 +433,39 @@ export class AgentContext {

return (
'\n\n## Programmatic-Only Tools\n\n' +
'The following tools are available exclusively through the `run_tools_with_code` tool. ' +
'You cannot call these tools directly; instead, use `run_tools_with_code` with Python code that invokes them.\n\n' +
`The following tools are available exclusively through the \`${programmaticTool.name}\` tool. ` +
`You cannot call these tools directly; instead, use \`${programmaticTool.name}\` with ${programmaticTool.language} code that invokes them.\n\n` +
toolDescriptions
);
}

private getProgrammaticToolInstructionTarget(): {
name: string;
language: 'bash' | 'Python';
} {
if (this.hasAvailableTool(Constants.BASH_PROGRAMMATIC_TOOL_CALLING)) {
return {
name: Constants.BASH_PROGRAMMATIC_TOOL_CALLING,
language: 'bash',
};
}

if (this.hasAvailableTool(Constants.PROGRAMMATIC_TOOL_CALLING)) {
return { name: Constants.PROGRAMMATIC_TOOL_CALLING, language: 'Python' };
}

return { name: Constants.BASH_PROGRAMMATIC_TOOL_CALLING, language: 'bash' };
}

private hasAvailableTool(name: string): boolean {
if (this.toolDefinitions?.some((tool) => tool.name === name)) return true;
if (this.tools?.some((tool) => 'name' in tool && tool.name === name)) {
return true;
}
if (this.toolMap?.has(name)) return true;
return this.toolRegistry?.has(name) === true;
}

/**
* Gets the system runnable, creating it lazily if needed.
* Includes stable instructions, dynamic additional instructions, and
Expand Down
39 changes: 36 additions & 3 deletions src/agents/__tests__/AgentContext.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// src/agents/__tests__/AgentContext.test.ts
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
import { AgentContext } from '../AgentContext';
import { Providers } from '@/common';
import { Constants, Providers } from '@/common';
import { addBedrockCacheControl } from '@/messages/cache';
import type * as t from '@/types';

Expand Down Expand Up @@ -593,7 +593,7 @@ describe('AgentContext', () => {
});

describe('buildProgrammaticOnlyToolsInstructions', () => {
it('includes code_execution-only tools in system message', () => {
it('includes code_execution-only tools in system message', async () => {
const toolRegistry: t.LCToolRegistry = new Map([
[
'programmatic_tool',
Expand All @@ -606,11 +606,44 @@ describe('AgentContext', () => {
]);

const ctx = createBasicContext({
agentConfig: { instructions: 'Base', toolRegistry },
agentConfig: {
instructions: 'Base',
toolDefinitions: [{ name: Constants.BASH_PROGRAMMATIC_TOOL_CALLING }],
toolRegistry,
},
});

const runnable = ctx.systemRunnable;
expect(runnable).toBeDefined();
const result = await runnable!.invoke([]);
expect(result[0].content).toContain('run_tools_with_bash');
expect(result[0].content).not.toContain('run_tools_with_code');
});

it('uses Python PTC guidance when only run_tools_with_code is available', async () => {
const toolRegistry: t.LCToolRegistry = new Map([
[
'programmatic_tool',
{
name: 'programmatic_tool',
description: 'Only callable via code execution',
allowed_callers: ['code_execution'],
},
],
]);

const ctx = createBasicContext({
agentConfig: {
instructions: 'Base',
toolDefinitions: [{ name: Constants.PROGRAMMATIC_TOOL_CALLING }],
toolRegistry,
},
});

const result = await ctx.systemRunnable!.invoke([]);
expect(result[0].content).toContain('run_tools_with_code');
expect(result[0].content).toContain('Python code');
expect(result[0].content).not.toContain('run_tools_with_bash');
});

it('excludes direct-callable tools from programmatic section', () => {
Expand Down
5 changes: 4 additions & 1 deletion src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ export class ToolEndHandler implements t.EventHandler {
return;
}

if (metadata[Constants.PROGRAMMATIC_TOOL_CALLING] === true) {
if (
metadata[Constants.PROGRAMMATIC_TOOL_CALLING] === true ||
metadata[Constants.BASH_PROGRAMMATIC_TOOL_CALLING] === true
) {
return;
}

Expand Down
17 changes: 14 additions & 3 deletions src/tools/BashExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import fetch, { RequestInit } from 'node-fetch';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { tool, DynamicStructuredTool } from '@langchain/core/tools';
import type * as t from '@/types';
import { emptyOutputMessage, getCodeBaseURL } from './CodeExecutor';
import {
emptyOutputMessage,
buildCodeApiHttpErrorMessage,
getCodeBaseURL,
resolveCodeApiAuthHeaders,
} from './CodeExecutor';
import { Constants } from '@/common';

config();
Expand Down Expand Up @@ -104,6 +109,7 @@ function createBashExecutionTool(
): DynamicStructuredTool {
return tool(
async (rawInput, config) => {
const { authHeaders, ...executionParams } = params ?? {};
const { command, ...rest } = rawInput as {
command: string;
args?: string[];
Expand All @@ -117,7 +123,7 @@ function createBashExecutionTool(
lang: 'bash',
code: command,
...rest,
...params,
...executionParams,
};

/* See `CodeExecutor.ts` for the rationale — `/files/<session_id>`
Expand All @@ -137,11 +143,14 @@ function createBashExecutionTool(
}

try {
const resolvedAuthHeaders =
await resolveCodeApiAuthHeaders(authHeaders);
const fetchOptions: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'LibreChat/1.0',
...resolvedAuthHeaders,
},
body: JSON.stringify(postData),
};
Expand All @@ -151,7 +160,9 @@ function createBashExecutionTool(
}
const response = await fetch(EXEC_ENDPOINT, fetchOptions);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
throw new Error(
await buildCodeApiHttpErrorMessage('POST', EXEC_ENDPOINT, response)
);
}

const result: t.ExecuteResult = await response.json();
Expand Down
9 changes: 6 additions & 3 deletions src/tools/BashProgrammaticToolCalling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,8 @@ export function createBashProgrammaticToolCallingTool(
timeout,
...(files && files.length > 0 ? { files } : {}),
},
proxy
proxy,
initParams.authHeaders
);

// ====================================================================
Expand All @@ -339,7 +340,8 @@ export function createBashProgrammaticToolCallingTool(

const toolResults = await executeTools(
response.tool_calls ?? [],
toolMap
toolMap,
Constants.BASH_PROGRAMMATIC_TOOL_CALLING
);

response = await makeRequest(
Expand All @@ -348,7 +350,8 @@ export function createBashProgrammaticToolCallingTool(
continuation_token: response.continuation_token,
tool_results: toolResults,
},
proxy
proxy,
initParams.authHeaders
);
}

Expand Down
38 changes: 36 additions & 2 deletions src/tools/CodeExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,34 @@ const EXEC_ENDPOINT = `${baseEndpoint}/exec`;

type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];

export async function resolveCodeApiAuthHeaders(
authHeaders?: t.CodeApiAuthHeaders
): Promise<t.CodeApiAuthHeaderMap> {
if (authHeaders == null) {
return {};
}
if (typeof authHeaders === 'function') {
return authHeaders();
}
return authHeaders;
}

export async function buildCodeApiHttpErrorMessage(
method: string,
endpoint: string,
response: { status: number; text: () => Promise<string> }
): Promise<string> {
let responseBody = '';
try {
responseBody = await response.text();
} catch {
responseBody = '';
}
const body = responseBody.trim();
const bodySuffix = body === '' ? '' : `, body: ${body.slice(0, 1000)}`;
return `CodeAPI request failed: ${method} ${endpoint} returned ${response.status}${bodySuffix}`;
}

export const CodeExecutionToolDescription = `
Runs code and returns stdout/stderr output from a stateless execution environment, similar to running scripts in a command-line interface. Each execution is isolated and independent.

Expand All @@ -92,6 +120,7 @@ function createCodeExecutionTool(
): DynamicStructuredTool {
return tool(
async (rawInput, config) => {
const { authHeaders, ...executionParams } = params ?? {};
const { lang, code, ...rest } = rawInput as {
lang: SupportedLanguage;
code: string;
Expand All @@ -111,7 +140,7 @@ function createCodeExecutionTool(
lang,
code,
...rest,
...params,
...executionParams,
};

/* File injection: `_injected_files` from ToolNode (set when host
Expand All @@ -135,11 +164,14 @@ function createCodeExecutionTool(
}

try {
const resolvedAuthHeaders =
await resolveCodeApiAuthHeaders(authHeaders);
const fetchOptions: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'LibreChat/1.0',
...resolvedAuthHeaders,
},
body: JSON.stringify(postData),
};
Expand All @@ -149,7 +181,9 @@ function createCodeExecutionTool(
}
const response = await fetch(EXEC_ENDPOINT, fetchOptions);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
throw new Error(
await buildCodeApiHttpErrorMessage('POST', EXEC_ENDPOINT, response)
);
}

const result: t.ExecuteResult = await response.json();
Expand Down
Loading
Loading