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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@librechat/agents",
"version": "3.1.89",
"version": "3.1.90",
"main": "./dist/cjs/main.cjs",
"module": "./dist/esm/main.mjs",
"types": "./dist/types/index.d.ts",
Expand Down
7 changes: 7 additions & 0 deletions src/graphs/Graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1562,6 +1562,13 @@ export class StandardGraph extends Graph<t.BaseGraphState, t.GraphNode> {
maxDepth: effectiveSubagentDepth,
createChildGraph: (input): StandardGraph => {
const childGraph = new StandardGraph(input);
childGraph.hookRegistry = this.hookRegistry;
/**
* Do not propagate `humanInTheLoop` into the child graph yet:
* nested subagent interrupts need a stable child checkpoint and
* resume bridge. Child hooks still fire; `ask` decisions fail
* closed inside the subagent until that flow is implemented.
*/
childGraph.toolOutputReferences = this.toolOutputReferences;
childGraph.eagerEventToolExecution = this.eagerEventToolExecution;
childGraph.toolExecution = this.toolExecution;
Expand Down
38 changes: 38 additions & 0 deletions src/hooks/__tests__/executeHooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,44 @@ describe('executeHooks', () => {
consoleWarnSpy.mockRestore();
});

describe('abort listener management', () => {
it('uses one abort listener for many hooks on one matcher', async () => {
const registry = new HookRegistry();
const listenerCounts = new Map<AbortSignal, number>();
let maxAbortListeners = 0;
const addEventListenerSpy = jest
.spyOn(AbortSignal.prototype, 'addEventListener')
.mockImplementation(function (
this: AbortSignal,
type: string,
_listener: EventListenerOrEventListenerObject | null
): void {
if (type !== 'abort') {
return;
}
const count = (listenerCounts.get(this) ?? 0) + 1;
listenerCounts.set(this, count);
maxAbortListeners = Math.max(maxAbortListeners, count);
});
const hooks = Array.from({ length: 12 }, () =>
runStartHook(async (): Promise<RunStartHookOutput> => ({}))
);

try {
registry.register('RunStart', { hooks });

await executeHooks({
registry,
input: runStartInput(),
timeoutMs: 1000,
});
expect(maxAbortListeners).toBe(1);
} finally {
addEventListenerSpy.mockRestore();
}
});
});

describe('empty matcher set', () => {
it('returns an empty aggregated result when no matchers are registered', async () => {
const registry = new HookRegistry();
Expand Down
34 changes: 27 additions & 7 deletions src/hooks/executeHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ interface HookOutcome {
timedOut: boolean;
}

interface AbortRace {
promise: Promise<never>;
cleanup: () => void;
}

function freshResult(): AggregatedHookResult {
return {
additionalContexts: [],
Expand Down Expand Up @@ -110,10 +115,10 @@ async function runHook(
hook: WideCallback,
input: HookInput,
signal: AbortSignal,
abortPromise: Promise<never>,
matcher: WideMatcher
): Promise<HookOutcome> {
const hookPromise = Promise.resolve().then(() => hook(input, signal));
const { promise: abortPromise, cleanup } = makeAbortPromise(signal);
try {
const output = await Promise.race([hookPromise, abortPromise]);
return { matcher, output, error: null, timedOut: false };
Expand All @@ -124,8 +129,22 @@ async function runHook(
error: describeError(err),
timedOut: isTimeout(err),
};
}
}

async function runMatcherHooks(
matcher: WideMatcher,
input: HookInput,
signal: AbortSignal
): Promise<HookOutcome[]> {
const abortRace: AbortRace = makeAbortPromise(signal);
const tasks = matcher.hooks.map((hook) =>
runHook(hook, input, signal, abortRace.promise, matcher)
);
try {
return await Promise.all(tasks);
} finally {
cleanup();
abortRace.cleanup();
}
}

Expand Down Expand Up @@ -373,26 +392,27 @@ export async function executeHooks(
}

// --- SYNC CRITICAL SECTION: once-matcher removal must complete before any await ---
const tasks: Promise<HookOutcome>[] = [];
const tasks: Promise<HookOutcome[]>[] = [];
for (const matcher of matchers) {
if (!matchesQuery(matcher.pattern, matchQuery)) {
continue;
}
if (matcher.once === true) {
registry.removeMatcher(event, matcher, sessionId);
}
if (matcher.hooks.length === 0) {
continue;
}
const perHookTimeout = matcher.timeout ?? timeoutMs;
const matcherSignal = combineSignals(signal, perHookTimeout);
for (const hook of matcher.hooks) {
tasks.push(runHook(hook, input, matcherSignal, matcher));
}
tasks.push(runMatcherHooks(matcher, input, matcherSignal));
}
// --- END SYNC CRITICAL SECTION ---
if (tasks.length === 0) {
return freshResult();
}

const outcomes = await Promise.all(tasks);
const outcomes = (await Promise.all(tasks)).flat();
reportErrors(outcomes, event, logger);
const aggregated = fold(outcomes);
/**
Expand Down
30 changes: 27 additions & 3 deletions src/llm/anthropic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ import type {
ChatAnthropicToolType,
AnthropicMCPServerURLDefinition,
AnthropicContextManagementConfigParam,
AnthropicRequestOptions,
} from '@/llm/anthropic/types';
import { _makeMessageChunkFromAnthropicEvent } from './utils/message_outputs';
import { _convertMessagesToAnthropicPayload } from './utils/message_inputs';
import {
_convertMessagesToAnthropicPayload,
stripUnsupportedAssistantPrefill,
} from './utils/message_inputs';
import { handleToolChoice } from './utils/tools';

const DEFAULT_STREAM_DELAY = 25;
Expand Down Expand Up @@ -591,6 +595,26 @@ export class CustomAnthropic extends ChatAnthropicMessages {
});
}

protected override async createStreamWithRetry(
request: AnthropicStreamingMessageCreateParams,
options?: AnthropicRequestOptions
): ReturnType<ChatAnthropicMessages['createStreamWithRetry']> {
return super.createStreamWithRetry(
stripUnsupportedAssistantPrefill(request),
options
);
}

protected override async completionWithRetry(
request: AnthropicMessageCreateParams,
options: AnthropicRequestOptions
): ReturnType<ChatAnthropicMessages['completionWithRetry']> {
return super.completionWithRetry(
stripUnsupportedAssistantPrefill(request),
options
);
}

async *_streamResponseChunks(
messages: BaseMessage[],
options: this['ParsedCallOptions'],
Expand All @@ -599,11 +623,11 @@ export class CustomAnthropic extends ChatAnthropicMessages {
this.resetTokenEvents();
const params = this.invocationParams(options);
const formattedMessages = _convertMessagesToAnthropicPayload(messages);
const payload = {
const payload = stripUnsupportedAssistantPrefill({
...params,
...formattedMessages,
stream: true,
} as const;
} as const);
const coerceContentToString =
!_toolsInParams(payload) &&
!_documentsInParams(payload) &&
Expand Down
61 changes: 60 additions & 1 deletion src/llm/anthropic/llm.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ import type {
ToolEndEvent,
TPayload,
} from '@/types';
import { _convertMessagesToAnthropicPayload } from './utils/message_inputs';
import {
_convertMessagesToAnthropicPayload,
modelDisallowsAssistantPrefill,
stripUnsupportedAssistantPrefill,
} from './utils/message_inputs';
import {
_makeMessageChunkFromAnthropicEvent,
getAnthropicUsageMetadata,
Expand Down Expand Up @@ -2637,6 +2641,61 @@ describe('Anthropic Reasoning with contentBlocks', () => {
});
});

describe('Claude assistant prefill compatibility', () => {
test.each([
'claude-sonnet-4-6',
'claude-sonnet-4-6@20260217',
'claude-opus-4-7',
'claude-opus-4-10',
'global.anthropic.claude-opus-4-6-v1:0',
'anthropic/claude-sonnet-4.6',
'anthropic/claude-sonnet-4.12',
])('detects %s as not supporting assistant prefill', (model) => {
expect(modelDisallowsAssistantPrefill(model)).toBe(true);
});

test.each([
'claude-sonnet-4-5-20250929',
'claude-opus-4-20250514',
'anthropic.claude-opus-4-20250514-v1:0',
'gpt-5.4',
])('leaves %s prefill support unchanged', (model) => {
expect(modelDisallowsAssistantPrefill(model)).toBe(false);
});

test('strips trailing assistant messages for Claude 4.6+ requests', () => {
const request = {
model: 'claude-opus-4-6',
max_tokens: 100,
messages: [
{ role: 'user' as const, content: 'What changed?' },
{ role: 'assistant' as const, content: 'Draft prefill' },
{ role: 'assistant' as const, content: 'Another prefill' },
],
};

const sanitized = stripUnsupportedAssistantPrefill(request);

expect(sanitized).not.toBe(request);
expect(sanitized.messages).toEqual([
{ role: 'user', content: 'What changed?' },
]);
});

test('does not strip assistant messages for older Claude models', () => {
const request = {
model: 'claude-sonnet-4-5-20250929',
max_tokens: 100,
messages: [
{ role: 'user' as const, content: 'Write JSON only.' },
{ role: 'assistant' as const, content: '{' },
],
};

expect(stripUnsupportedAssistantPrefill(request)).toBe(request);
});
});

const opus46Model = 'claude-opus-4-6';

describe('Opus 4.6', () => {
Expand Down
46 changes: 46 additions & 0 deletions src/llm/anthropic/utils/message_inputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ type GoogleFunctionCallBlock = MessageContentComplex & {
};

const ANTHROPIC_EMPTY_TEXT_PLACEHOLDER = '_';
const CLAUDE_4_RELEASE_DATE_MODEL_PATTERN =
/claude-(?:opus|sonnet|haiku)-4-\d{8}(?:[-.@]|$)/i;
const CLAUDE_4_MINOR_MODEL_PATTERN =
/claude-(?:opus|sonnet|haiku)-4[-.](\d+)(?:[-.@]|$)/i;

function _formatImage(imageUrl: string) {
const parsed = parseBase64DataUrl({ dataUrl: imageUrl });
Expand Down Expand Up @@ -796,6 +800,48 @@ export function _convertMessagesToAnthropicPayload(
} as AnthropicMessageCreateParams;
}

export function modelDisallowsAssistantPrefill(model?: string): boolean {
const modelId = model ?? '';
if (CLAUDE_4_RELEASE_DATE_MODEL_PATTERN.test(modelId)) {
return false;
}

const match = CLAUDE_4_MINOR_MODEL_PATTERN.exec(modelId);
if (!match) {
return false;
}
return Number(match[1]) >= 6;
}

export function stripUnsupportedAssistantPrefill<
T extends Pick<AnthropicMessageCreateParams, 'messages'> & { model?: string },
>(request: T): T {
if (!modelDisallowsAssistantPrefill(request.model)) {
return request;
}

const messages = request.messages;
if (
messages.length <= 1 ||
messages[messages.length - 1]?.role !== 'assistant'
) {
return request;
}

const nextMessages = [...messages];
while (
nextMessages.length > 1 &&
nextMessages[nextMessages.length - 1]?.role === 'assistant'
) {
nextMessages.pop();
}

return {
...request,
messages: nextMessages,
};
}

function mergeMessages(messages: AnthropicMessageCreateParams['messages']) {
if (messages.length <= 1) {
return messages;
Expand Down
Loading
Loading