mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
fix(ai-builder): Strip cache_control markers from persisted tool messages and responder input (#26132)
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { AIMessage, HumanMessage } from '@langchain/core/messages';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
|
||||
import {
|
||||
invokeResponderAgent,
|
||||
type ResponderAgentType,
|
||||
type ResponderContext,
|
||||
} from '../responder.agent';
|
||||
|
||||
describe('invokeResponderAgent', () => {
|
||||
function createMockAgent(responseContent = 'Test response'): ResponderAgentType {
|
||||
return {
|
||||
invoke: jest.fn().mockResolvedValue({
|
||||
messages: [new AIMessage({ content: responseContent })],
|
||||
}),
|
||||
} as unknown as ResponderAgentType;
|
||||
}
|
||||
|
||||
function createContext(messages: BaseMessage[]): ResponderContext {
|
||||
return {
|
||||
messages,
|
||||
coordinationLog: [],
|
||||
workflowJSON: { nodes: [], connections: {}, name: '' },
|
||||
};
|
||||
}
|
||||
|
||||
it('should return the last message from agent as the response', async () => {
|
||||
const mockAgent = createMockAgent('Here is your workflow summary');
|
||||
|
||||
const messages: BaseMessage[] = [new HumanMessage('Build a workflow')];
|
||||
const result = await invokeResponderAgent(mockAgent, createContext(messages));
|
||||
|
||||
expect(result.response).toBeInstanceOf(AIMessage);
|
||||
expect(result.response.content).toBe('Here is your workflow summary');
|
||||
});
|
||||
|
||||
it('should return fallback response when agent returns empty messages', async () => {
|
||||
const mockAgent = {
|
||||
invoke: jest.fn().mockResolvedValue({ messages: [] }),
|
||||
} as unknown as ResponderAgentType;
|
||||
|
||||
const messages: BaseMessage[] = [new HumanMessage('Build a workflow')];
|
||||
const result = await invokeResponderAgent(mockAgent, createContext(messages));
|
||||
|
||||
expect(result.response).toBeInstanceOf(AIMessage);
|
||||
expect(result.response.content).toContain('encountered an issue');
|
||||
});
|
||||
|
||||
it('should pass messages and context to agent.invoke', async () => {
|
||||
const mockAgent = createMockAgent();
|
||||
const messages: BaseMessage[] = [
|
||||
new HumanMessage('Build a workflow'),
|
||||
new AIMessage({ content: 'Sure, I can help' }),
|
||||
];
|
||||
|
||||
await invokeResponderAgent(mockAgent, createContext(messages));
|
||||
|
||||
const invokeCall = (mockAgent.invoke as jest.Mock).mock.calls[0] as [
|
||||
{ messages: BaseMessage[] },
|
||||
Record<string, unknown>,
|
||||
];
|
||||
expect(invokeCall[0].messages).toHaveLength(2);
|
||||
expect(invokeCall[1].context).toEqual(
|
||||
expect.objectContaining({ coordinationLog: [], workflowJSON: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
handleCreateWorkflowName,
|
||||
handleDeleteMessages,
|
||||
} from './utils/state-modifier';
|
||||
import { extractUserRequest } from './utils/subgraph-helpers';
|
||||
import { extractUserRequest, filterOutSubgraphToolMessages } from './utils/subgraph-helpers';
|
||||
import type { BuilderFeatureFlags, StageLLMs } from './workflow-builder-agent';
|
||||
|
||||
/**
|
||||
@@ -275,9 +275,10 @@ export function createMultiAgentWorkflowWithSubgraphs(config: MultiAgentSubgraph
|
||||
// Add Supervisor Node (only used for initial routing)
|
||||
// Accepts config as second param to propagate callbacks for tracing
|
||||
.addNode('supervisor', async (state, config) => {
|
||||
const conversationMessages = filterOutSubgraphToolMessages(state.messages);
|
||||
const routing = await supervisorAgent.invoke(
|
||||
{
|
||||
messages: state.messages,
|
||||
messages: conversationMessages,
|
||||
workflowJSON: state.workflowJSON,
|
||||
coordinationLog: state.coordinationLog,
|
||||
previousSummary: state.previousSummary,
|
||||
@@ -332,10 +333,11 @@ export function createMultiAgentWorkflowWithSubgraphs(config: MultiAgentSubgraph
|
||||
};
|
||||
}
|
||||
|
||||
const responderMessages = filterOutSubgraphToolMessages(state.messages);
|
||||
const { response, introspectionEvents } = await invokeResponderAgent(
|
||||
responderAgent,
|
||||
{
|
||||
messages: state.messages,
|
||||
messages: responderMessages,
|
||||
coordinationLog: state.coordinationLog,
|
||||
discoveryContext: state.discoveryContext,
|
||||
workflowJSON: state.workflowJSON,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HumanMessage, AIMessage, ToolMessage } from '@langchain/core/messages';
|
||||
import type { StructuredTool } from '@langchain/core/tools';
|
||||
import { END, isCommand, isGraphInterrupt } from '@langchain/langgraph';
|
||||
|
||||
import { stripAllCacheControlMarkers } from './cache-control';
|
||||
import { isBaseMessage } from '../types/langchain';
|
||||
import type { WorkflowMetadata } from '../types/tools';
|
||||
import type { WorkflowOperation } from '../types/workflow';
|
||||
@@ -244,7 +245,7 @@ export function extractToolMessagesForPersistence(messages: BaseMessage[]): Base
|
||||
}
|
||||
}
|
||||
|
||||
return messages.filter((msg) => {
|
||||
const filtered = messages.filter((msg) => {
|
||||
if (ToolMessage.isInstance(msg)) {
|
||||
return true;
|
||||
}
|
||||
@@ -254,4 +255,34 @@ export function extractToolMessagesForPersistence(messages: BaseMessage[]): Base
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Strip cache_control markers from persisted messages to avoid exceeding
|
||||
// Anthropic's cache marker limit when these are loaded in subsequent requests
|
||||
stripAllCacheControlMarkers(filtered);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out internal subgraph tool messages from the conversation.
|
||||
*
|
||||
* Subgraph tool messages (ToolMessages and AIMessages with tool_calls) are
|
||||
* persisted in parent state for frontend UI restoration, but they are not
|
||||
* relevant for agents like the supervisor or responder. Including them
|
||||
* wastes tokens and risks exceeding Anthropic's cache_control marker limit
|
||||
* if stale markers remain on those messages.
|
||||
*
|
||||
* @param messages - Parent graph messages array
|
||||
* @returns Messages with internal tool messages removed
|
||||
*/
|
||||
export function filterOutSubgraphToolMessages(messages: BaseMessage[]): BaseMessage[] {
|
||||
return messages.filter((msg) => {
|
||||
if (ToolMessage.isInstance(msg)) {
|
||||
return false;
|
||||
}
|
||||
if (AIMessage.isInstance(msg) && msg.tool_calls && msg.tool_calls.length > 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
extractUserRequest,
|
||||
createStandardShouldContinue,
|
||||
extractToolMessagesForPersistence,
|
||||
filterOutSubgraphToolMessages,
|
||||
executeSubgraphTools,
|
||||
} from '../subgraph-helpers';
|
||||
|
||||
@@ -291,6 +292,71 @@ describe('subgraph-helpers', () => {
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should strip cache_control markers from returned messages', () => {
|
||||
// Simulate what happens after applySubgraphCacheMarkers mutates messages in-place:
|
||||
// ToolMessages end up with cache_control markers in their content blocks
|
||||
const toolMessageWithMarker = new ToolMessage({
|
||||
content: [{ type: 'text', text: 'Result', cache_control: { type: 'ephemeral' } }],
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
const aiMessage = new AIMessage({
|
||||
content: '',
|
||||
tool_calls: [{ name: 'test_tool', args: {}, id: 'call-1' }],
|
||||
});
|
||||
const messages: BaseMessage[] = [
|
||||
new HumanMessage('Context'),
|
||||
aiMessage,
|
||||
toolMessageWithMarker,
|
||||
];
|
||||
|
||||
const result = extractToolMessagesForPersistence(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Verify cache_control has been stripped from the content block
|
||||
const toolMsg = result[1];
|
||||
expect(Array.isArray(toolMsg.content)).toBe(true);
|
||||
const contentBlock = (toolMsg.content as Array<Record<string, unknown>>)[0];
|
||||
expect(contentBlock.cache_control).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should strip cache_control markers from multiple messages', () => {
|
||||
const aiMessage1 = new AIMessage({
|
||||
content: '',
|
||||
tool_calls: [{ name: 'tool1', args: {}, id: 'call-1' }],
|
||||
});
|
||||
const toolMsg1 = new ToolMessage({
|
||||
content: [{ type: 'text', text: 'Result 1', cache_control: { type: 'ephemeral' } }],
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
const aiMessage2 = new AIMessage({
|
||||
content: '',
|
||||
tool_calls: [{ name: 'tool2', args: {}, id: 'call-2' }],
|
||||
});
|
||||
const toolMsg2 = new ToolMessage({
|
||||
content: [{ type: 'text', text: 'Result 2', cache_control: { type: 'ephemeral' } }],
|
||||
tool_call_id: 'call-2',
|
||||
});
|
||||
const messages: BaseMessage[] = [
|
||||
new HumanMessage('Context'),
|
||||
aiMessage1,
|
||||
toolMsg1,
|
||||
aiMessage2,
|
||||
toolMsg2,
|
||||
];
|
||||
|
||||
const result = extractToolMessagesForPersistence(messages);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
// Both tool messages should have cache_control stripped
|
||||
for (const msg of result) {
|
||||
if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
expect((block as Record<string, unknown>).cache_control).toBeUndefined();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle tool_call with undefined id gracefully', () => {
|
||||
const aiMessage = new AIMessage({
|
||||
content: '',
|
||||
@@ -310,6 +376,78 @@ describe('subgraph-helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterOutSubgraphToolMessages', () => {
|
||||
it('should return empty array for empty messages', () => {
|
||||
expect(filterOutSubgraphToolMessages([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should keep HumanMessages and text-only AIMessages', () => {
|
||||
const human = new HumanMessage('Build a workflow');
|
||||
const aiResponse = new AIMessage('Here is your workflow');
|
||||
const messages: BaseMessage[] = [human, aiResponse];
|
||||
|
||||
const result = filterOutSubgraphToolMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe(human);
|
||||
expect(result[1]).toBe(aiResponse);
|
||||
});
|
||||
|
||||
it('should remove ToolMessages', () => {
|
||||
const messages: BaseMessage[] = [
|
||||
new HumanMessage('Build a workflow'),
|
||||
new ToolMessage({ content: 'Node added', tool_call_id: 'call-1' }),
|
||||
];
|
||||
|
||||
const result = filterOutSubgraphToolMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(HumanMessage);
|
||||
});
|
||||
|
||||
it('should remove AIMessages with tool_calls', () => {
|
||||
const messages: BaseMessage[] = [
|
||||
new HumanMessage('Build a workflow'),
|
||||
new AIMessage({
|
||||
content: '',
|
||||
tool_calls: [{ name: 'add_node', args: {}, id: 'call-1' }],
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterOutSubgraphToolMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(HumanMessage);
|
||||
});
|
||||
|
||||
it('should filter out complete tool call pairs from a mixed conversation', () => {
|
||||
const human = new HumanMessage('Build a workflow');
|
||||
const aiResponse = new AIMessage('Your workflow is ready');
|
||||
const messages: BaseMessage[] = [
|
||||
human,
|
||||
// Subgraph tool messages (should be removed)
|
||||
new AIMessage({
|
||||
content: '',
|
||||
tool_calls: [{ name: 'search_nodes', args: {}, id: 'call-1' }],
|
||||
}),
|
||||
new ToolMessage({ content: 'Found 3 nodes', tool_call_id: 'call-1' }),
|
||||
new AIMessage({
|
||||
content: '',
|
||||
tool_calls: [{ name: 'add_node', args: {}, id: 'call-2' }],
|
||||
}),
|
||||
new ToolMessage({ content: 'Node added', tool_call_id: 'call-2' }),
|
||||
// Responder response (should be kept)
|
||||
aiResponse,
|
||||
];
|
||||
|
||||
const result = filterOutSubgraphToolMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe(human);
|
||||
expect(result[1]).toBe(aiResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeSubgraphTools', () => {
|
||||
function makeTool(
|
||||
name: string,
|
||||
|
||||
Reference in New Issue
Block a user