fix(ai-builder): Strip cache_control markers from persisted tool messages and responder input (#26132)

This commit is contained in:
Michael Drury
2026-02-23 14:49:33 +00:00
committed by GitHub
parent 47d5738ed6
commit 05d15a0e44
4 changed files with 242 additions and 4 deletions
@@ -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,