Skip to content

[Bug]: Anthropic compaction request keeps document citations but drops the cited documents #586

Description

@sisve

What happened?

When summarization compacts an Anthropic conversation, the compaction request can include citations on a replayed assistant message without the document block they point at. Anthropic rejects the request:

400 invalid_request_error
messages.5.content.0.citations.0: Invalid document index in document citation.

The steps that lead there:

  1. LibreChat sends attached files to Anthropic as document blocks with citations: { enabled: true } (packages/api/src/files/encode/document.ts), on the latest user message. The model's answer cites them by document_index.
  2. The citations are stored on the assistant message. On every replay, _convertMessagesToAnthropicPayload copies them back onto the text block (message_inputs.ts) and doesn't check that the cited documents are in the same request.
  3. At compaction, splitAtRecencyBoundary splits the history into older messages to summarize (messagesToRefine) and a recent tail to keep (node.ts). summarizeWithCacheHit sends only the older messages plus the instruction, [...cachedHistory, new HumanMessage(instruction)] (node.ts).
  4. If the cited assistant message lands in the summarized part and the user message with the documents lands in the kept tail, the request has citations and zero documents. Anthropic rejects it and the summary is never written.

I couldn't find anything in src/summarization/ or src/messages/ that strips citations or brings the documents along.

The compaction request should be valid on its own. That means either dropping citations from replayed text blocks whose documents aren't in the request (the text is still worth summarizing), or sending the cited document blocks along with the history.

Version Information

  • @librechat/agents 3.9.1, as used by LibreChat v0.8.8-rc4. Also reproduced on main at 64177c70.
  • Provider: Anthropic (claude-opus-4-7) with prompt caching on.

Steps to Reproduce

  1. Use an agent on an Anthropic model with summarization enabled.
  2. Attach a document (PDF or text) and ask about it, so the answer comes back with citations.
  3. Keep chatting until summarization triggers, with the attachment still on the latest user message.
  4. The compaction request fails with Invalid document index in document citation.

The unit test below reproduces it without a live model. On main it fails with:

✕ does not send citations whose documents were left out of the compaction request
  + "messages.1.content.0.citations.0: document_index 0, request has 0 document(s)"

The first test is a control: it shows the conversation is a valid Anthropic request before compaction. The second test runs the real createSummarizeNode with a model stub that records what it is sent. It then checks every citation in the compaction request against the documents in that request.

import { AIMessage, HumanMessage } from '@langchain/core/messages';
import type { RunnableConfig } from '@langchain/core/runnables';
import type { BaseMessage } from '@langchain/core/messages';
import type * as t from '@/types';
import { _convertMessagesToAnthropicPayload } from '@/llm/anthropic/utils/message_inputs';
import { createSummarizeNode } from '@/summarization/node';
import { AgentContext } from '@/agents/AgentContext';
import { Providers } from '@/common';
import * as providers from '@/llm/providers';
import * as eventUtils from '@/utils/events';

/**
 * Anthropic rejects a request whose text blocks carry a document citation
 * without the cited document in the same request:
 *   400 invalid_request_error
 *   "messages.N.content.0.citations.0: Invalid document index in document citation."
 */
function invalidCitations(messages: BaseMessage[]): string[] {
  const payload = _convertMessagesToAnthropicPayload(messages);
  let documentCount = 0;
  for (const message of payload.messages) {
    if (Array.isArray(message.content)) {
      documentCount += message.content.filter(
        (block) => block.type === 'document'
      ).length;
    }
  }
  const problems: string[] = [];
  payload.messages.forEach((message, messageIndex) => {
    if (!Array.isArray(message.content)) {
      return;
    }
    message.content.forEach((block, blockIndex) => {
      const citations = (block as { citations?: unknown }).citations;
      if (!Array.isArray(citations)) {
        return;
      }
      citations.forEach((citation, citationIndex) => {
        const documentIndex = (citation as { document_index?: number })
          .document_index;
        if (documentIndex != null && documentIndex >= documentCount) {
          problems.push(
            `messages.${messageIndex}.content.${blockIndex}.citations.${citationIndex}: ` +
              `document_index ${documentIndex}, request has ${documentCount} document(s)`
          );
        }
      });
    });
  });
  return problems;
}

/** Turn 1 is answered with citations into a document that the latest user
 *  message carries, which is where LibreChat attaches a conversation's files. */
function conversation(): BaseMessage[] {
  return [
    new HumanMessage('Can you read the attached spec?'),
    new AIMessage({
      content: [
        {
          type: 'text',
          text: 'Yes, the spec requires BankID sign-in.',
          citations: [
            {
              type: 'char_location',
              cited_text: 'Members sign in with BankID.',
              document_index: 0,
              document_title: null,
              start_char_index: 0,
              end_char_index: 28,
            },
          ],
        },
      ],
    }),
    new HumanMessage({
      content: [
        { type: 'text', text: 'Now cross-reference it against the backlog.' },
        {
          type: 'document',
          source: {
            type: 'text',
            media_type: 'text/plain',
            data: 'Members sign in with BankID.',
          },
          citations: { enabled: true },
          context: 'File: "spec.txt"',
        },
      ],
    }),
  ];
}

describe('summarization with Anthropic document citations', () => {
  afterEach(() => {
    jest.restoreAllMocks();
  });

  it('the conversation itself is a valid Anthropic request', () => {
    expect(invalidCitations(conversation())).toEqual([]);
  });

  it('does not send citations whose documents were left out of the compaction request', async () => {
    jest
      .spyOn(eventUtils, 'safeDispatchCustomEvent')
      .mockImplementation((async () => undefined) as never);
    const calls: BaseMessage[][] = [];
    jest.spyOn(providers, 'getChatModelClass').mockReturnValue(
      class {
        invoke(messages: BaseMessage[]): Promise<{ content: string }> {
          calls.push(messages);
          return Promise.resolve({ content: 'summary' });
        }
      } as never
    );

    const agentContext = AgentContext.fromConfig({
      agentId: 'agent_0',
      provider: Providers.ANTHROPIC,
      instructions: 'Test agent',
      summarizationEnabled: true,
      summarizationConfig: { retainRecent: { turns: 1 } },
    } as t.AgentInputs);
    let step = 0;
    const node = createSummarizeNode({
      agentContext,
      graph: {
        contentData: [],
        contentIndexMap: new Map(),
        config: {} as RunnableConfig,
        runId: 'run_1',
        isMultiAgent: false,
        dispatchRunStep: async (): Promise<void> => {},
        dispatchRunStepCompleted: async (): Promise<void> => {},
      } as never,
      generateStepId: (): [string, number] => [`step_${step++}`, 0],
    });

    await node(
      {
        messages: conversation(),
        summarizationRequest: {
          remainingContextTokens: 0,
          agentId: 'agent_0',
        },
      },
      {} as RunnableConfig
    );

    expect(calls).toHaveLength(1);
    expect(invalidCitations(calls[0])).toEqual([]);
  });
});

Example error response

{"type":"error","error":{"type":"invalid_request_error","message":"messages.5.content.0.citations.0: Invalid document index in document citation."}}

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions