Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions librechat.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,13 @@ endpoints:
# maxRecursionLimit: 100
# # (optional) Abort a run once a single streamed tool call's arguments exceed this many bytes.
# # Guards against runaway malformed tool-call generation. Defaults to 65536 (64 KiB); 0 disables.
# # Host-side skill/sandbox edits, not attached-worker edits. Over-budget calls write nothing.
# hostFileEdits:
# maxEdits: 100
# maxWorkBytes: 67108864 # cumulative matching/reconstruction bytes
# maxOccurrences: 100000 # cumulative occurrences across every edit/pass
# timeoutMs: 2000 # terminate an overlong worker job
# maxConcurrent: 2 # per API process; no waiting queue
# maxToolCallArgBytes: 65536
# # (optional) Abort a run once a single model generation emits more than this many stream events.
# # Defense in depth against looping provider streams. Disabled by default.
Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/agents/edits.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@ describe('normalizeEditArgs', () => {
expect(normalizeEditArgs({ edits: normalized })).toEqual(normalized);
});
});

it('rejects oversized batches even when entries are valid or JSON-stringified', () => {
const edits = Array.from({ length: 101 }, () => ({ old_text: 'a', new_text: 'b' }));
expect(normalizeEditArgs({ edits })).toContain('limited to 100');
expect(normalizeEditArgs({ edits: JSON.stringify(edits) })).toContain('limited to 100');
});
5 changes: 5 additions & 0 deletions packages/api/src/agents/edits.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { HOST_FILE_EDIT_HARD_MAX_COUNT } from 'librechat-data-provider';

export type TextEdit = {
old_text: string;
new_text: string;
Expand Down Expand Up @@ -51,6 +53,9 @@ export function normalizeEditArgs(args: {
if (!Array.isArray(coercedEdits) || coercedEdits.length === 0) {
return 'Provide a non-empty edits array when edits is supplied.';
}
if (coercedEdits.length > HOST_FILE_EDIT_HARD_MAX_COUNT) {
return `File edits are limited to ${HOST_FILE_EDIT_HARD_MAX_COUNT} replacements per call.`;
}
const edits: TextEdit[] = [];
for (const rawEdit of coercedEdits) {
const edit = coerceJsonValue(rawEdit);
Expand Down
7 changes: 7 additions & 0 deletions packages/api/src/agents/files/__fixtures__/edit-worker.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const { parentPort } = require('node:worker_threads');

parentPort.on('message', ({ content }) => {
if (content === 'wait') return;
if (content === 'crash') throw new Error('PRIVATE-WORKER-DIAGNOSTIC');
parentPort.postMessage({ ok: true, result: { content, strategies: [] } });
});
28 changes: 28 additions & 0 deletions packages/api/src/agents/files/edit-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { parentPort } from 'node:worker_threads';
import type { HostEditResult, HostEditWorkLimits } from './matching';
import type { TextEdit } from '../edits';
import { applyTextEdits, HostEditError } from './matching';

export interface HostEditJob {
content: string;
edits: TextEdit[];
limits: HostEditWorkLimits;
}

export type HostEditReply = { ok: true; result: HostEditResult } | { ok: false; message: string };

parentPort?.on('message', (job: HostEditJob) => {
let reply: HostEditReply;
try {
reply = { ok: true, result: applyTextEdits(job.content, job.edits, job.limits) };
} catch (error) {
reply = {
ok: false,
message:
error instanceof HostEditError
? error.message
: 'File edit processing failed. Nothing was written.',
};
}
parentPort?.postMessage(reply);
});
60 changes: 60 additions & 0 deletions packages/api/src/agents/files/matching.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { HostEditWorkLimits } from './matching';
import { applyTextEdits } from './matching';

const limits: HostEditWorkLimits = {
maxEdits: 100,
maxWorkBytes: 64 * 1024 * 1024,
maxOccurrences: 100000,
maxOutputBytes: 10 * 1024 * 1024,
};

describe('cumulative host edit accounting', () => {
it('charges input, matching, and reconstruction once for a full-context exact edit', () => {
const edits = [{ old_text: 'ax', new_text: 'bx' }];
expect(applyTextEdits('ax', edits, { ...limits, maxWorkBytes: 12 })).toEqual({
content: 'bx',
strategies: ['exact'],
});
expect(() => applyTextEdits('ax', edits, { ...limits, maxWorkBytes: 11 })).toThrow(
'budget exceeded',
);
});

it('charges contraction as well as growth across a batch', () => {
const edits = [
{ old_text: 'a', new_text: 'b', replace_all: true },
{ old_text: 'b', new_text: 'a', replace_all: true },
{ old_text: 'a', new_text: '', replace_all: true },
];
expect(applyTextEdits('a', edits, { ...limits, maxWorkBytes: 16 })).toEqual({
content: '',
strategies: ['exact', 'exact', 'exact'],
});
expect(() => applyTextEdits('a', edits, { ...limits, maxWorkBytes: 15 })).toThrow(
'budget exceeded',
);
});

it('remeasures UTF-8 sizes after each replacement and rejects oversized intermediates', () => {
const edits = [
{ old_text: 'a', new_text: 'Γ©', replace_all: true },
{ old_text: 'Γ©', new_text: 'πŸ˜€', replace_all: true },
{ old_text: 'πŸ˜€', new_text: '', replace_all: true },
];
expect(() => applyTextEdits('aa', edits, { ...limits, maxOutputBytes: 7 })).toThrow(
'larger than',
);
expect(applyTextEdits('aa', edits, { ...limits, maxOutputBytes: 8 }).content).toBe('');
});

it('keeps exact offsets and output size after a tolerant replacement', () => {
const edits = [
{ old_text: 'two words', new_text: 'Γ©' },
{ old_text: 'Γ©', new_text: 'πŸ˜€' },
];
expect(applyTextEdits('two words', edits, { ...limits, maxOutputBytes: 10 })).toEqual({
content: 'πŸ˜€',
strategies: ['whitespace-normalized', 'exact'],
});
});
});
Loading
Loading