mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix: address codex authoring review findings
This commit is contained in:
parent
ac4049edbb
commit
f1c2d92265
6 changed files with 362 additions and 24 deletions
|
|
@ -7,8 +7,9 @@ jest.mock('nanoid', () => ({
|
|||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
sendEvent: jest.fn(),
|
||||
HOST_FILE_AUTHORING_ARTIFACT_KEY: '__librechat_file_authoring',
|
||||
isCodeSessionToolName: jest.fn((name) =>
|
||||
['execute_code', 'bash_tool', 'read_file', 'create_file', 'edit_file'].includes(name),
|
||||
['execute_code', 'bash_tool', 'read_file'].includes(name),
|
||||
),
|
||||
}));
|
||||
|
||||
|
|
@ -374,12 +375,14 @@ describe('createToolEndCallback', () => {
|
|||
fileId,
|
||||
name,
|
||||
toolName = 'execute_code',
|
||||
hostFileAuthoring = false,
|
||||
}) {
|
||||
return {
|
||||
output: {
|
||||
name: toolName,
|
||||
tool_call_id: toolCallId,
|
||||
artifact: {
|
||||
...(hostFileAuthoring ? { __librechat_file_authoring: true } : {}),
|
||||
session_id: 'sess-1',
|
||||
files: [{ id: fileId, name, session_id: 'sess-1' }],
|
||||
},
|
||||
|
|
@ -607,6 +610,7 @@ describe('createToolEndCallback', () => {
|
|||
fileId: 'fid-created',
|
||||
name: 'created.txt',
|
||||
toolName: 'create_file',
|
||||
hostFileAuthoring: true,
|
||||
});
|
||||
await toolEndCallback({ output: event.output }, event.metadata);
|
||||
await Promise.all(artifactPromises);
|
||||
|
|
@ -622,6 +626,25 @@ describe('createToolEndCallback', () => {
|
|||
);
|
||||
expect(res.write).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not process arbitrary user tool artifacts named create_file as code outputs', async () => {
|
||||
res.headersSent = true;
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
const event = makeCodeExecutionEvent({
|
||||
runId: 'run-user-create',
|
||||
threadId: 'thread789',
|
||||
toolCallId: 'tool-user-create',
|
||||
fileId: 'fid-user-created',
|
||||
name: 'created.txt',
|
||||
toolName: 'create_file',
|
||||
});
|
||||
|
||||
await toolEndCallback({ output: event.output }, event.metadata);
|
||||
await Promise.all(artifactPromises);
|
||||
|
||||
expect(processCodeOutput).not.toHaveBeenCalled();
|
||||
expect(res.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,14 +12,19 @@ const {
|
|||
GenerationJobManager,
|
||||
writeAttachmentEvent,
|
||||
createToolExecuteHandler,
|
||||
HOST_FILE_AUTHORING_ARTIFACT_KEY,
|
||||
isCodeSessionToolName,
|
||||
} = require('@librechat/api');
|
||||
const { processFileCitations } = require('~/server/services/Files/Citations');
|
||||
const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process');
|
||||
const { saveBase64Image } = require('~/server/services/Files/process');
|
||||
|
||||
function isCodeArtifactToolName(name) {
|
||||
return isCodeSessionToolName(name);
|
||||
function isHostFileAuthoringArtifact(artifact) {
|
||||
return artifact?.[HOST_FILE_AUTHORING_ARTIFACT_KEY] === true;
|
||||
}
|
||||
|
||||
function isCodeArtifactToolOutput(output) {
|
||||
return isCodeSessionToolName(output.name) || isHostFileAuthoringArtifact(output.artifact);
|
||||
}
|
||||
|
||||
class ModelEndHandler {
|
||||
|
|
@ -627,7 +632,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null })
|
|||
return;
|
||||
}
|
||||
|
||||
if (!isCodeArtifactToolName(output.name)) {
|
||||
if (!isCodeArtifactToolOutput(output)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -894,7 +899,7 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises })
|
|||
return;
|
||||
}
|
||||
|
||||
if (!isCodeArtifactToolName(output.name)) {
|
||||
if (!isCodeArtifactToolOutput(output)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -386,7 +386,8 @@ describe('createToolExecuteHandler', () => {
|
|||
describe('host file authoring collisions', () => {
|
||||
it('invokes a loaded user tool named create_file when host file authoring is not active', async () => {
|
||||
const capturedArgs: unknown[] = [];
|
||||
const tool = createMockTool('create_file', [], { capturedArgs });
|
||||
const capturedConfigs: Record<string, unknown>[] = [];
|
||||
const tool = createMockTool('create_file', capturedConfigs, { capturedArgs });
|
||||
const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({
|
||||
loadedTools: [tool] as never[],
|
||||
configurable: { fileAuthoringToolNames: new Set<string>() },
|
||||
|
|
@ -398,12 +399,18 @@ describe('createToolExecuteHandler', () => {
|
|||
id: 'call_user_create_file',
|
||||
name: 'create_file',
|
||||
args: { custom: true },
|
||||
codeSessionContext: {
|
||||
session_id: 'ignored-session',
|
||||
files: [{ id: 'ignored-file', name: 'ignored.txt', session_id: 'ignored-session' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.content).toContain('create_file executed');
|
||||
expect(capturedArgs).toEqual([{ custom: true }]);
|
||||
expect(capturedConfigs[0].session_id).toBeUndefined();
|
||||
expect(capturedConfigs[0]._injected_files).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1331,6 +1338,56 @@ describe('createToolExecuteHandler', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('rejects bundled skill file writes when the skill version changed after reading', async () => {
|
||||
const getSkillByName = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: SKILL_ID,
|
||||
name: 'edit-skill',
|
||||
body: '# Existing',
|
||||
fileCount: 1,
|
||||
version: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: SKILL_ID,
|
||||
name: 'edit-skill',
|
||||
body: '# Existing changed elsewhere',
|
||||
fileCount: 1,
|
||||
version: 2,
|
||||
});
|
||||
const saveSkillFileContent = jest.fn();
|
||||
const handler = makeAuthoringHandler({
|
||||
getSkillByName,
|
||||
getSkillFileByPath: jest.fn(async () => ({
|
||||
content: 'hello old\n',
|
||||
isBinary: false,
|
||||
mimeType: 'text/markdown',
|
||||
bytes: 10,
|
||||
filepath: '/tmp/a.md',
|
||||
source: 'local',
|
||||
relativePath: 'references/a.md',
|
||||
})),
|
||||
saveSkillFileContent,
|
||||
});
|
||||
|
||||
const [result] = await invokeHandler(handler, [
|
||||
{
|
||||
id: 'call_edit_stale_bundled_file',
|
||||
name: 'edit_file',
|
||||
args: {
|
||||
file_path: 'skills/edit-skill/references/a.md',
|
||||
old_text: 'hello old',
|
||||
new_text: 'hello new',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.errorMessage).toContain('changed while editing');
|
||||
expect(result.errorMessage).toContain('skills/edit-skill/references/a.md');
|
||||
expect(saveSkillFileContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes structured frontmatter when editing SKILL.md', async () => {
|
||||
const oldBody =
|
||||
'---\nname: runtime-skill\ndescription: Use before\naction: ignored\n---\n# Body\n';
|
||||
|
|
@ -1745,6 +1802,87 @@ describe('createToolExecuteHandler', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('propagates newly created sandbox sessions to queued same-path authoring calls', async () => {
|
||||
let readCount = 0;
|
||||
let writeCount = 0;
|
||||
const sandboxFiles = [
|
||||
{ id: 'file-queued', name: 'queued.txt', storage_session_id: 'sess-new' },
|
||||
];
|
||||
const readSandboxFile = jest.fn(
|
||||
async ({
|
||||
session_id,
|
||||
files,
|
||||
}: {
|
||||
session_id?: string;
|
||||
files?: Array<{ id: string; name: string; storage_session_id?: string }>;
|
||||
}) => {
|
||||
readCount++;
|
||||
if (readCount === 1) {
|
||||
throw new Error('cat: /mnt/data/queued.txt: No such file or directory');
|
||||
}
|
||||
|
||||
expect(session_id).toBe('sess-new');
|
||||
expect(files).toEqual(sandboxFiles);
|
||||
return { content: 'hello world\n' };
|
||||
},
|
||||
);
|
||||
const writeSandboxFile = jest.fn(
|
||||
async ({
|
||||
session_id,
|
||||
files,
|
||||
content,
|
||||
}: {
|
||||
session_id?: string;
|
||||
files?: Array<{ id: string; name: string; storage_session_id?: string }>;
|
||||
content: string;
|
||||
}) => {
|
||||
writeCount++;
|
||||
if (writeCount === 1) {
|
||||
expect(session_id).toBeUndefined();
|
||||
expect(files).toBeUndefined();
|
||||
expect(content).toBe('hello world\n');
|
||||
} else {
|
||||
expect(session_id).toBe('sess-new');
|
||||
expect(files).toEqual(sandboxFiles);
|
||||
expect(content).toBe('goodbye world\n');
|
||||
}
|
||||
return {
|
||||
stdout: `WROTE ${content.length} bytes to /mnt/data/queued.txt\n`,
|
||||
session_id: 'sess-new',
|
||||
files: sandboxFiles,
|
||||
};
|
||||
},
|
||||
);
|
||||
const handler = makeSandboxAuthoringHandler({
|
||||
readSandboxFile,
|
||||
writeSandboxFile,
|
||||
});
|
||||
|
||||
const results = await invokeHandler(handler, [
|
||||
{
|
||||
id: 'call_create_queued_sandbox',
|
||||
name: 'create_file',
|
||||
args: {
|
||||
file_path: '/mnt/data/queued.txt',
|
||||
content: 'hello world\n',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'call_edit_queued_sandbox',
|
||||
name: 'edit_file',
|
||||
args: {
|
||||
file_path: '/mnt/data/queued.txt',
|
||||
old_text: 'hello world',
|
||||
new_text: 'goodbye world',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(results.map((result) => result.status)).toEqual(['success', 'success']);
|
||||
expect(readSandboxFile).toHaveBeenCalledTimes(2);
|
||||
expect(writeSandboxFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects non-skill paths when code execution is unavailable', async () => {
|
||||
const writeSandboxFile = jest.fn();
|
||||
const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({
|
||||
|
|
|
|||
|
|
@ -19,7 +19,12 @@ import { logAxiosError, runOutsideTracing } from '~/utils';
|
|||
import { buildSkillPrimeMessage } from './skills';
|
||||
import { cleanCodeToolOutput } from './cleanup';
|
||||
import { primeSkillFiles } from './skillFiles';
|
||||
import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME, isCodeSessionToolName } from './tools';
|
||||
import {
|
||||
CREATE_FILE_TOOL_NAME,
|
||||
EDIT_FILE_TOOL_NAME,
|
||||
HOST_FILE_AUTHORING_ARTIFACT_KEY,
|
||||
isCodeSessionToolName,
|
||||
} from './tools';
|
||||
import { parseFrontmatter } from '../skills/import';
|
||||
|
||||
export interface ToolEndCallbackData {
|
||||
|
|
@ -210,7 +215,7 @@ export interface ToolExecuteOptions {
|
|||
readSandboxFile?: (params: {
|
||||
file_path: string;
|
||||
session_id?: string;
|
||||
files?: Array<{ id: string; name: string; session_id?: string }>;
|
||||
files?: Array<{ id: string; name: string; session_id?: string; storage_session_id?: string }>;
|
||||
req?: ServerRequest;
|
||||
}) => Promise<{ content: string } | null>;
|
||||
/**
|
||||
|
|
@ -223,7 +228,7 @@ export interface ToolExecuteOptions {
|
|||
file_path: string;
|
||||
content: string;
|
||||
session_id?: string;
|
||||
files?: Array<{ id: string; name: string; session_id?: string }>;
|
||||
files?: Array<{ id: string; name: string; session_id?: string; storage_session_id?: string }>;
|
||||
req?: ServerRequest;
|
||||
}) => Promise<{
|
||||
stdout?: string;
|
||||
|
|
@ -471,7 +476,7 @@ type LoadedSandboxText = LoadedSkillText;
|
|||
|
||||
type SandboxSessionContext = {
|
||||
session_id?: string;
|
||||
files?: Array<{ id: string; name: string; session_id?: string }>;
|
||||
files?: Array<{ id: string; name: string; session_id?: string; storage_session_id?: string }>;
|
||||
};
|
||||
|
||||
const MIME_MAP: Readonly<Record<string, string>> = Object.freeze({
|
||||
|
|
@ -1172,8 +1177,66 @@ async function handleSandboxFileFallback(
|
|||
}
|
||||
}
|
||||
|
||||
function sandboxSessionContext(tc: ToolCallRequest): SandboxSessionContext | undefined {
|
||||
return tc.codeSessionContext as SandboxSessionContext | undefined;
|
||||
function sandboxSessionContext(
|
||||
tc: ToolCallRequest,
|
||||
override?: SandboxSessionContext,
|
||||
): SandboxSessionContext | undefined {
|
||||
return override ?? (tc.codeSessionContext as SandboxSessionContext | undefined);
|
||||
}
|
||||
|
||||
function cloneSandboxSessionContext(
|
||||
context: SandboxSessionContext | undefined,
|
||||
): SandboxSessionContext {
|
||||
return {
|
||||
...(context?.session_id ? { session_id: context.session_id } : {}),
|
||||
...(context?.files ? { files: context.files.map((file) => ({ ...file })) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSandboxSessionArtifact(
|
||||
context: SandboxSessionContext,
|
||||
artifact: ToolExecuteResult['artifact'],
|
||||
): void {
|
||||
if (!artifact || typeof artifact !== 'object') {
|
||||
return;
|
||||
}
|
||||
const value = artifact as {
|
||||
session_id?: unknown;
|
||||
files?: unknown;
|
||||
};
|
||||
if (typeof value.session_id === 'string' && value.session_id.length > 0) {
|
||||
context.session_id = value.session_id;
|
||||
}
|
||||
if (!Array.isArray(value.files)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files: SandboxSessionContext['files'] = [];
|
||||
for (const file of value.files) {
|
||||
if (!file || typeof file !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const ref = file as {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
session_id?: unknown;
|
||||
storage_session_id?: unknown;
|
||||
};
|
||||
if (typeof ref.id !== 'string' || typeof ref.name !== 'string') {
|
||||
continue;
|
||||
}
|
||||
files.push({
|
||||
id: ref.id,
|
||||
name: ref.name,
|
||||
...(typeof ref.session_id === 'string' ? { session_id: ref.session_id } : {}),
|
||||
...(typeof ref.storage_session_id === 'string'
|
||||
? { storage_session_id: ref.storage_session_id }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (files.length > 0) {
|
||||
context.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
function isSandboxMissingFileError(error: unknown): boolean {
|
||||
|
|
@ -1203,11 +1266,13 @@ async function loadSandboxTextForAuthoring({
|
|||
tc,
|
||||
options,
|
||||
req,
|
||||
sandboxContext,
|
||||
}: {
|
||||
filePath: string;
|
||||
tc: ToolCallRequest;
|
||||
options: ToolExecuteOptions;
|
||||
req?: ServerRequest;
|
||||
sandboxContext?: SandboxSessionContext;
|
||||
}): Promise<LoadedSandboxText> {
|
||||
const ext = lowercaseExtension(filePath);
|
||||
if (BINARY_EXTENSIONS_NEVER_READABLE.has(ext)) {
|
||||
|
|
@ -1220,7 +1285,7 @@ async function loadSandboxTextForAuthoring({
|
|||
};
|
||||
}
|
||||
|
||||
const ctx = sandboxSessionContext(tc);
|
||||
const ctx = sandboxSessionContext(tc, sandboxContext);
|
||||
try {
|
||||
const result = await options.readSandboxFile({
|
||||
file_path: filePath,
|
||||
|
|
@ -1275,6 +1340,7 @@ async function writeSandboxTextForAuthoring({
|
|||
content,
|
||||
oldContent,
|
||||
created,
|
||||
sandboxContext,
|
||||
}: {
|
||||
tc: ToolCallRequest;
|
||||
options: ToolExecuteOptions;
|
||||
|
|
@ -1283,6 +1349,7 @@ async function writeSandboxTextForAuthoring({
|
|||
content: string;
|
||||
oldContent?: string;
|
||||
created: boolean;
|
||||
sandboxContext?: SandboxSessionContext;
|
||||
}): AuthoringResult {
|
||||
if (!options.writeSandboxFile) {
|
||||
return errorResult(
|
||||
|
|
@ -1290,7 +1357,7 @@ async function writeSandboxTextForAuthoring({
|
|||
`Sandbox file writing is not configured. Use \`bash_tool\` to write "${filePath}".`,
|
||||
);
|
||||
}
|
||||
const ctx = sandboxSessionContext(tc);
|
||||
const ctx = sandboxSessionContext(tc, sandboxContext);
|
||||
let writeResult: Awaited<ReturnType<NonNullable<ToolExecuteOptions['writeSandboxFile']>>>;
|
||||
try {
|
||||
writeResult = await options.writeSandboxFile({
|
||||
|
|
@ -1318,6 +1385,7 @@ async function writeSandboxTextForAuthoring({
|
|||
const summary = `${action} ${filePath} (${content.length} chars).`;
|
||||
return successResult(tc, diff ? `${summary}\n\n${diff}` : summary, {
|
||||
path: filePath,
|
||||
[HOST_FILE_AUTHORING_ARTIFACT_KEY]: true,
|
||||
bytes_written: Buffer.byteLength(content, 'utf8'),
|
||||
created,
|
||||
...(diff ? { diff } : {}),
|
||||
|
|
@ -1369,6 +1437,13 @@ function isHostFileAuthoringToolCall(
|
|||
return getFileAuthoringToolNames(mergedConfigurable)?.has(toolName) === true;
|
||||
}
|
||||
|
||||
function isCodeSessionAwareToolCall(
|
||||
toolName: string,
|
||||
mergedConfigurable: Record<string, unknown>,
|
||||
): boolean {
|
||||
return isCodeSessionToolName(toolName, getFileAuthoringToolNames(mergedConfigurable));
|
||||
}
|
||||
|
||||
function isSkillPrimedForAuthoring(
|
||||
skillName: string,
|
||||
mergedConfigurable: Record<string, unknown>,
|
||||
|
|
@ -1686,6 +1761,34 @@ async function inspectBundledSkillFileForCreate({
|
|||
return { status: 'present', oldContent: loaded.content };
|
||||
}
|
||||
|
||||
async function ensureBundledSkillVersionCurrent({
|
||||
tc,
|
||||
options,
|
||||
skill,
|
||||
displayPath,
|
||||
}: {
|
||||
tc: ToolCallRequest;
|
||||
options: ToolExecuteOptions;
|
||||
skill: AuthoringSkill;
|
||||
displayPath: string;
|
||||
}): Promise<ToolExecuteResult | null> {
|
||||
if (!options.getSkillByName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = await options.getSkillByName(skill.name, [skill._id], {});
|
||||
if (!current) {
|
||||
return errorResult(tc, `Skill "${skill.name}" not found or not accessible.`);
|
||||
}
|
||||
if (current.version !== skill.version) {
|
||||
return errorResult(
|
||||
tc,
|
||||
`Skill "${skill.name}" changed while editing. Re-read ${displayPath} and retry.`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function writeSkillMd({
|
||||
tc,
|
||||
options,
|
||||
|
|
@ -1831,6 +1934,15 @@ async function writeBundledSkillFile({
|
|||
if (!req || !options.saveSkillFileContent) {
|
||||
return errorResult(tc, 'Skill file writing is not configured.');
|
||||
}
|
||||
const staleDenied = await ensureBundledSkillVersionCurrent({
|
||||
tc,
|
||||
options,
|
||||
skill,
|
||||
displayPath,
|
||||
});
|
||||
if (staleDenied) {
|
||||
return staleDenied;
|
||||
}
|
||||
|
||||
await options.saveSkillFileContent({
|
||||
req,
|
||||
|
|
@ -1858,6 +1970,7 @@ async function handleSandboxCreateFileCall({
|
|||
filePath,
|
||||
content,
|
||||
overwrite,
|
||||
sandboxContext,
|
||||
}: {
|
||||
tc: ToolCallRequest;
|
||||
options: ToolExecuteOptions;
|
||||
|
|
@ -1865,13 +1978,20 @@ async function handleSandboxCreateFileCall({
|
|||
filePath: string;
|
||||
content: string;
|
||||
overwrite: boolean;
|
||||
sandboxContext?: SandboxSessionContext;
|
||||
}): AuthoringResult {
|
||||
const pathError = invalidSandboxAuthoringPath(filePath);
|
||||
if (pathError) {
|
||||
return errorResult(tc, pathError);
|
||||
}
|
||||
|
||||
const current = await loadSandboxTextForAuthoring({ filePath, tc, options, req });
|
||||
const current = await loadSandboxTextForAuthoring({
|
||||
filePath,
|
||||
tc,
|
||||
options,
|
||||
req,
|
||||
sandboxContext,
|
||||
});
|
||||
if (current.status === 'error') {
|
||||
return errorResult(tc, current.message);
|
||||
}
|
||||
|
|
@ -1887,6 +2007,7 @@ async function handleSandboxCreateFileCall({
|
|||
content,
|
||||
oldContent: current.status === 'loaded' ? current.content : undefined,
|
||||
created: current.status === 'missing',
|
||||
sandboxContext,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1896,19 +2017,27 @@ async function handleSandboxEditFileCall({
|
|||
req,
|
||||
filePath,
|
||||
edits,
|
||||
sandboxContext,
|
||||
}: {
|
||||
tc: ToolCallRequest;
|
||||
options: ToolExecuteOptions;
|
||||
req?: ServerRequest;
|
||||
filePath: string;
|
||||
edits: TextEdit[];
|
||||
sandboxContext?: SandboxSessionContext;
|
||||
}): AuthoringResult {
|
||||
const pathError = invalidSandboxAuthoringPath(filePath);
|
||||
if (pathError) {
|
||||
return errorResult(tc, pathError);
|
||||
}
|
||||
|
||||
const current = await loadSandboxTextForAuthoring({ filePath, tc, options, req });
|
||||
const current = await loadSandboxTextForAuthoring({
|
||||
filePath,
|
||||
tc,
|
||||
options,
|
||||
req,
|
||||
sandboxContext,
|
||||
});
|
||||
if (current.status === 'missing') {
|
||||
return errorResult(tc, `File not found: "${filePath}"`);
|
||||
}
|
||||
|
|
@ -1934,6 +2063,7 @@ async function handleSandboxEditFileCall({
|
|||
content: edited.content,
|
||||
oldContent: current.content,
|
||||
created: false,
|
||||
sandboxContext,
|
||||
});
|
||||
if (result.status === 'success') {
|
||||
result.artifact = {
|
||||
|
|
@ -1952,6 +2082,7 @@ async function handleCreateFileCall(
|
|||
options: ToolExecuteOptions,
|
||||
req?: ServerRequest,
|
||||
sourceConfigurable?: Record<string, unknown>,
|
||||
sandboxContext?: SandboxSessionContext,
|
||||
): AuthoringResult {
|
||||
const args = tc.args as { file_path?: unknown; content?: unknown; overwrite?: unknown };
|
||||
if (typeof args.file_path !== 'string' || args.file_path.length === 0) {
|
||||
|
|
@ -1979,6 +2110,7 @@ async function handleCreateFileCall(
|
|||
filePath: args.file_path,
|
||||
content: args.content,
|
||||
overwrite,
|
||||
sandboxContext,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2045,6 +2177,7 @@ async function handleEditFileCall(
|
|||
mergedConfigurable: Record<string, unknown>,
|
||||
options: ToolExecuteOptions,
|
||||
req?: ServerRequest,
|
||||
sandboxContext?: SandboxSessionContext,
|
||||
): AuthoringResult {
|
||||
const args = tc.args as {
|
||||
file_path?: unknown;
|
||||
|
|
@ -2074,6 +2207,7 @@ async function handleEditFileCall(
|
|||
req,
|
||||
filePath: args.file_path,
|
||||
edits,
|
||||
sandboxContext,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2795,14 +2929,21 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
loadedConfigurable,
|
||||
);
|
||||
const authoringQueues = new Map<string, Promise<void>>();
|
||||
const sandboxAuthoringContexts = new Map<string, SandboxSessionContext>();
|
||||
|
||||
const results: ToolExecuteResult[] = await Promise.all(
|
||||
toolCalls.map(async (tc: ToolCallRequest) => {
|
||||
const execute = async (): Promise<ToolExecuteResult> => {
|
||||
const execute = async (
|
||||
sandboxContext?: SandboxSessionContext,
|
||||
): Promise<ToolExecuteResult> => {
|
||||
const isFileAuthoringCall = isHostFileAuthoringToolCall(
|
||||
tc.name,
|
||||
mergedConfigurable,
|
||||
);
|
||||
const isSandboxFileAuthoringCall =
|
||||
isFileAuthoringCall &&
|
||||
typeof (tc.args as { file_path?: unknown }).file_path === 'string' &&
|
||||
!(tc.args as { file_path: string }).file_path.startsWith(SKILL_FILE_PREFIX);
|
||||
if (
|
||||
tc.name === Constants.SKILL_TOOL ||
|
||||
tc.name === Constants.READ_FILE ||
|
||||
|
|
@ -2832,6 +2973,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
options,
|
||||
req,
|
||||
sourceConfigurable,
|
||||
sandboxContext,
|
||||
);
|
||||
} else if (tc.name === EDIT_FILE_TOOL_NAME && isFileAuthoringCall) {
|
||||
handlerResult = await handleEditFileCall(
|
||||
|
|
@ -2839,6 +2981,7 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
mergedConfigurable,
|
||||
options,
|
||||
req,
|
||||
sandboxContext,
|
||||
);
|
||||
} else {
|
||||
handlerResult = errorResult(tc, `Tool ${tc.name} not found`);
|
||||
|
|
@ -2857,6 +3000,14 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
};
|
||||
}
|
||||
|
||||
if (
|
||||
isSandboxFileAuthoringCall &&
|
||||
handlerResult.status === 'success' &&
|
||||
sandboxContext
|
||||
) {
|
||||
mergeSandboxSessionArtifact(sandboxContext, handlerResult.artifact);
|
||||
}
|
||||
|
||||
if (toolEndCallback && handlerResult.artifact) {
|
||||
await toolEndCallback(
|
||||
{
|
||||
|
|
@ -2903,7 +3054,10 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
turn: tc.turn,
|
||||
};
|
||||
|
||||
if (tc.codeSessionContext && isCodeSessionToolName(tc.name)) {
|
||||
if (
|
||||
tc.codeSessionContext &&
|
||||
isCodeSessionAwareToolCall(tc.name, mergedConfigurable)
|
||||
) {
|
||||
toolCallConfig.session_id = tc.codeSessionContext.session_id;
|
||||
if (tc.codeSessionContext.files && tc.codeSessionContext.files.length > 0) {
|
||||
toolCallConfig._injected_files = tc.codeSessionContext.files;
|
||||
|
|
@ -3011,7 +3165,8 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
// (model context, SSE forwarding, persistence) see it.
|
||||
// Non-code-execution tools pass through unchanged.
|
||||
const cleanedContent =
|
||||
isCodeSessionToolName(tc.name) && typeof result.content === 'string'
|
||||
isCodeSessionAwareToolCall(tc.name, mergedConfigurable) &&
|
||||
typeof result.content === 'string'
|
||||
? cleanCodeToolOutput(result.content)
|
||||
: result.content;
|
||||
|
||||
|
|
@ -3063,8 +3218,18 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand
|
|||
if (!queueKey) {
|
||||
return await execute();
|
||||
}
|
||||
let sandboxContext: SandboxSessionContext | undefined;
|
||||
if (queueKey.startsWith('sandbox:')) {
|
||||
sandboxContext =
|
||||
sandboxAuthoringContexts.get(queueKey) ??
|
||||
cloneSandboxSessionContext(sandboxSessionContext(tc));
|
||||
sandboxAuthoringContexts.set(queueKey, sandboxContext);
|
||||
}
|
||||
const previous = authoringQueues.get(queueKey) ?? Promise.resolve();
|
||||
const resultPromise = previous.then(execute, execute);
|
||||
const resultPromise = previous.then(
|
||||
() => execute(sandboxContext),
|
||||
() => execute(sandboxContext),
|
||||
);
|
||||
authoringQueues.set(
|
||||
queueKey,
|
||||
resultPromise.then(
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
BuildToolSetConfig,
|
||||
registerCodeExecutionTools,
|
||||
registerFileAuthoringTools,
|
||||
FILE_AUTHORING_TOOL_NAMES,
|
||||
isFileAuthoringToolDefinition,
|
||||
isCodeSessionToolName,
|
||||
} from './tools';
|
||||
|
|
@ -437,8 +438,10 @@ describe('registerFileAuthoringTools', () => {
|
|||
|
||||
it('recognizes host-side file authoring tools as code-session-aware without mutating the shared set', () => {
|
||||
expect(isCodeSessionToolName('bash_tool')).toBe(true);
|
||||
expect(isCodeSessionToolName('create_file')).toBe(true);
|
||||
expect(isCodeSessionToolName('edit_file')).toBe(true);
|
||||
expect(isCodeSessionToolName('create_file')).toBe(false);
|
||||
expect(isCodeSessionToolName('edit_file')).toBe(false);
|
||||
expect(isCodeSessionToolName('create_file', FILE_AUTHORING_TOOL_NAMES)).toBe(true);
|
||||
expect(isCodeSessionToolName('edit_file', FILE_AUTHORING_TOOL_NAMES)).toBe(true);
|
||||
expect(CODE_EXECUTION_TOOLS.has('create_file')).toBe(false);
|
||||
expect(CODE_EXECUTION_TOOLS.has('edit_file')).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,13 +8,17 @@ import type { LCTool, LCToolRegistry } from '@librechat/agents';
|
|||
|
||||
export const CREATE_FILE_TOOL_NAME = 'create_file';
|
||||
export const EDIT_FILE_TOOL_NAME = 'edit_file';
|
||||
export const HOST_FILE_AUTHORING_ARTIFACT_KEY = '__librechat_file_authoring';
|
||||
export const FILE_AUTHORING_TOOL_NAMES: ReadonlySet<string> = new Set([
|
||||
CREATE_FILE_TOOL_NAME,
|
||||
EDIT_FILE_TOOL_NAME,
|
||||
]);
|
||||
|
||||
export function isCodeSessionToolName(name: string): boolean {
|
||||
return CODE_EXECUTION_TOOLS.has(name) || FILE_AUTHORING_TOOL_NAMES.has(name);
|
||||
export function isCodeSessionToolName(
|
||||
name: string,
|
||||
hostFileAuthoringToolNames?: ReadonlySet<string>,
|
||||
): boolean {
|
||||
return CODE_EXECUTION_TOOLS.has(name) || hostFileAuthoringToolNames?.has(name) === true;
|
||||
}
|
||||
|
||||
interface ToolDefLike {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue