Skip to content
Open
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
91 changes: 79 additions & 12 deletions src/agents/AgentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,8 +939,18 @@ export class AgentContext {
}

const promptCacheProvider = this.getPromptCacheProvider();
/**
* GPT-5.6 explicit caching needs the same structural split as Anthropic β€”
* a stable system message with the volatile tail moved behind it β€” but
* none of the Anthropic marker stamping: its breakpoints are attached to
* the serialized request later, in the OpenAI client. So it drives the
* relocation flag while leaving `promptCacheProvider` undefined.
*/
const openAIExplicitCache = this.usesOpenAIExplicitPromptCache();
const splitsDynamicInstructions =
promptCacheProvider != null || openAIExplicitCache;
const shouldMoveDynamicInstructions =
promptCacheProvider != null &&
splitsDynamicInstructions &&
stableInstructions !== '' &&
dynamicInstructions !== '';
const systemMessage = this.buildSystemMessage({
Expand Down Expand Up @@ -971,14 +981,16 @@ export class AgentContext {
this.summaryText !== '';

const bodyWithSummary =
hasSummaryBody && promptCacheProvider == null
hasSummaryBody && !splitsDynamicInstructions
? [this.buildSummaryHumanMessage(promptCacheProvider), ...messages]
: messages;
const dynamicTail = this.buildPromptCacheDynamicTail({
dynamicInstructions,
hasSummaryBody,
promptCacheProvider,
splitsDynamicInstructions,
shouldMoveDynamicInstructions,
keepsInstructionRole:
openAIExplicitCache && promptCacheProvider == null,
});
let body = this.buildBodyWithPromptCacheDynamicTail(
bodyWithSummary,
Expand Down Expand Up @@ -1025,20 +1037,35 @@ export class AgentContext {
private buildPromptCacheDynamicTail({
dynamicInstructions,
hasSummaryBody,
promptCacheProvider,
splitsDynamicInstructions,
shouldMoveDynamicInstructions,
keepsInstructionRole,
}: {
dynamicInstructions: string;
hasSummaryBody: boolean;
promptCacheProvider: PromptCacheProvider | undefined;
splitsDynamicInstructions: boolean;
shouldMoveDynamicInstructions: boolean;
keepsInstructionRole: boolean;
}): BaseMessage[] {
if (promptCacheProvider == null) {
if (!splitsDynamicInstructions) {
return [];
}

/**
* The tail keeps its role where relocating it is this library's own idea.
* `additional_instructions` is declared a system tail and carries host
* constraints and cross-run summary context; on OpenAI and Azure a user
* message ranks below a system one, so emitting it as a `HumanMessage`
* would let later user content override those constraints, changing how
* an agent behaves because caching was switched on. Anthropic and
* OpenRouter keep the `HumanMessage` they already shipped with.
*/
const dynamicTail = shouldMoveDynamicInstructions
? [new HumanMessage(dynamicInstructions)]
? [
keepsInstructionRole
? new SystemMessage(dynamicInstructions)
: new HumanMessage(dynamicInstructions),
]
: [];

if (!hasSummaryBody) {
Expand All @@ -1063,10 +1090,19 @@ export class AgentContext {
: this.getPromptCacheDynamicTailIndex(messages, promptCacheProvider);
const stablePrefix = messages.slice(0, tailIndex);
const trailingMessages = messages.slice(tailIndex);
const cacheablePrefix = this.addStablePromptCacheMarkers(
stablePrefix,
this.getPromptCacheTtl(promptCacheProvider)
);
/**
* Anthropic-format markers only. On the OpenAI explicit path the prefix is
* left untouched: its breakpoints are attached to the serialized request,
* and a `cache_control` block here would be sent to a provider that has no
* such field.
*/
const cacheablePrefix =
promptCacheProvider == null
? stablePrefix
: this.addStablePromptCacheMarkers(
stablePrefix,
this.getPromptCacheTtl(promptCacheProvider)
);

return [...cacheablePrefix, ...tail, ...trailingMessages];
}
Expand Down Expand Up @@ -1133,6 +1169,29 @@ export class AgentContext {
return undefined;
}

/**
* GPT-5.6 explicit prompt caching, on first-party OpenAI and Azure OpenAI.
*
* Unlike the providers above this adds no marker to the message content
* here: `prompt_cache_breakpoint` is attached to the serialized request in
* the OpenAI client, which selects the last system/developer message. That
* selection is only worth anything if the system message stops at the
* stable instructions, so this exists to drive the same dynamic-tail
* relocation β€” nothing else.
*/
private usesOpenAIExplicitPromptCache(): boolean {
if (
this.provider !== Providers.OPENAI &&
this.provider !== Providers.AZURE
) {
return false;
}
const openAIOptions = this.clientOptions as
| { promptCacheExplicit?: boolean }
| undefined;
return openAIOptions?.promptCacheExplicit === true;
}

private hasBedrockPromptCache(): boolean {
if (this.provider !== Providers.BEDROCK) {
return false;
Expand Down Expand Up @@ -1238,8 +1297,16 @@ export class AgentContext {
return new SystemMessage({ content } as BaseMessageFields);
}

/**
* A relocated tail must not also appear here. On the GPT-5.6 explicit path
* this is what leaves the system message holding only the stable prefix,
* which is where the client then places the breakpoint.
*/
return new SystemMessage(
[stableInstructions, dynamicInstructions]
[
stableInstructions,
shouldMoveDynamicInstructions ? '' : dynamicInstructions,
]
.filter((part) => part !== '')
.join('\n\n')
);
Expand Down
119 changes: 119 additions & 0 deletions src/agents/__tests__/AgentContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,125 @@ describe('AgentContext', () => {
);
});

it.each([Providers.OPENAI, Providers.AZURE])(
'moves the dynamic tail behind stable history for %s explicit caching',
async (provider) => {
const ctx = createBasicContext({
agentConfig: {
provider,
clientOptions: {
model: 'gpt-5.6',
promptCacheExplicit: true,
} as t.OpenAIClientOptions,
instructions: 'Stable instructions',
additional_instructions: 'Dynamic instructions',
},
});

const result = await ctx.systemRunnable!.invoke([
new HumanMessage('Hello'),
new AIMessage('Hi'),
new HumanMessage('Second'),
]);

/** Plain text: the breakpoint is attached to the request, not the content. */
expect(result[0].content).toBe('Stable instructions');
expect(result[1].content).toBe('Hello');
expect(result[2].content).toBe('Hi');
expect(result[3].content).toBe('Dynamic instructions');
expect(result[4].content).toBe('Second');
for (const message of result) {
expect(JSON.stringify(message.content)).not.toContain(
'cache_control'
);
}
}
);

it.each([Providers.OPENAI, Providers.AZURE])(
'keeps the relocated tail in an instruction role for %s explicit caching',
async (provider) => {
/**
* `additional_instructions` is declared a system tail and carries host
* constraints and cross-run summary context. A user message ranks below
* a system one on these providers, so emitting the relocated tail as a
* HumanMessage would let later user content override those constraints
* because caching was switched on.
*/
const ctx = createBasicContext({
agentConfig: {
provider,
clientOptions: {
model: 'gpt-5.6',
promptCacheExplicit: true,
} as t.OpenAIClientOptions,
instructions: 'Stable instructions',
additional_instructions: 'Dynamic instructions',
},
});

const result = await ctx.systemRunnable!.invoke([
new HumanMessage('Hello'),
new AIMessage('Hi'),
new HumanMessage('Second'),
]);

const tail = result[3];
expect(tail.content).toBe('Dynamic instructions');
expect(tail.getType()).toBe('system');
expect(result[0].getType()).toBe('system');
}
);

it('leaves the Anthropic relocated tail on the role it already shipped with', async () => {
/** Not this change's to alter: Anthropic relocated to a HumanMessage before it. */
const ctx = createBasicContext({
agentConfig: {
provider: Providers.ANTHROPIC,
clientOptions: {
model: 'claude-3-5-sonnet',
promptCache: true,
} as t.OpenAIClientOptions,
instructions: 'Stable instructions',
additional_instructions: 'Dynamic instructions',
},
});

const result = await ctx.systemRunnable!.invoke([
new HumanMessage('Hello'),
new AIMessage('Hi'),
new HumanMessage('Second'),
]);

/** Anthropic wraps content in cache_control blocks, so match on the text. */
const tail = result.find((m) =>
JSON.stringify(m.content).includes('Dynamic instructions')
);
expect(tail).toBeDefined();
expect(tail!.getType()).toBe('human');
});

it('keeps dynamic-only instructions in the system message under explicit caching', async () => {
const ctx = createBasicContext({
agentConfig: {
provider: Providers.OPENAI,
clientOptions: {
model: 'gpt-5.6',
promptCacheExplicit: true,
} as t.OpenAIClientOptions,
instructions: undefined,
additional_instructions: 'Dynamic only',
},
});

const result = await ctx.systemRunnable!.invoke([
new HumanMessage('Hello'),
]);

expect(result[0].content).toBe('Dynamic only');
expect(result).toHaveLength(2);
});

it('moves OpenRouter dynamic instructions behind stable history', async () => {
const ctx = createBasicContext({
agentConfig: {
Expand Down
26 changes: 22 additions & 4 deletions src/llm/openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,18 @@ function selectCacheBreakpointIndexes(
let latestUserIndex = -1;
for (let index = 0; index < roles.length; index++) {
const role = roles[index];
if ((role === 'system' || role === 'developer') && cacheable[index]) {
/**
* The first instruction message, not the last. The stable prefix is built
* first and the volatile tail follows it in its own system message, so
* marking the last one would put the breakpoint behind content that turns
* over every turn β€” the invalidation this breakpoint exists to avoid.
* With a single system message the two are the same message.
*/
if (
(role === 'system' || role === 'developer') &&
cacheable[index] &&
instructionIndex === -1
) {
instructionIndex = index;
Comment on lines +378 to 383

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve caching for direct multi-instruction requests

When the exported ChatOpenAI/Azure wrappers are invoked directly rather than through AgentContext, multiple system/developer messages do not follow the assumed stable-first/volatile-last layout. For example, a short system preamble followed by a large stable developer prompt now receives a breakpoint only on the short first message; explicit mode therefore cannot cache the useful combined instruction prefix and may fall below the provider's minimum cacheable prefix. The previous last-instruction selection supported these requests, so the stable-first behavior should be limited to messages explicitly identified as the relocated AgentContext tail rather than applied to every request.

Useful? React with πŸ‘Β / πŸ‘Ž.

}
if (role === 'user') {
Expand Down Expand Up @@ -1058,7 +1069,10 @@ type ResponsesAnnotationsBoundaryEvent = {
export function ensureResponsesOutputAnnotations(
event: ResponsesAnnotationsBoundaryEvent
): void {
if (event.type !== 'response.completed' && event.type !== 'response.incomplete') {
if (
event.type !== 'response.completed' &&
event.type !== 'response.incomplete'
) {
return;
}
const output = event.response?.output;
Expand Down Expand Up @@ -2344,7 +2358,9 @@ class LibreChatOpenAIResponses extends OriginalChatOpenAIResponses {
cache_control: cacheControl,
}),
};
if (shouldIncludeEncryptedReasoning(this.model, params, this.astraRulesApply)) {
if (
shouldIncludeEncryptedReasoning(this.model, params, this.astraRulesApply)
) {
params.include = [
...new Set([
...(params.include ?? []),
Expand Down Expand Up @@ -2627,7 +2643,9 @@ class LibreChatAzureOpenAIResponses extends OriginalAzureChatOpenAIResponses {
promptCacheExplicit: this.promptCacheExplicit,
safetyIdentifier: this.safetyIdentifier,
});
if (shouldIncludeEncryptedReasoning(this.model, params, this.astraRulesApply)) {
if (
shouldIncludeEncryptedReasoning(this.model, params, this.astraRulesApply)
) {
params.include = [
...new Set([
...(params.include ?? []),
Expand Down
23 changes: 23 additions & 0 deletions src/llm/openai/managedRequests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,29 @@ import {
} from './index';

describe('managed GPT-5.6 request fields', () => {
it('marks the stable instruction message, not the relocated tail behind it', () => {
/**
* The tail is a second system message so it keeps its instruction role.
* Marking the last one would put the breakpoint behind content that turns
* over every turn, which is the invalidation the breakpoint exists to
* avoid.
*/
const messages = addChatCacheBreakpoints([
{ role: 'system', content: 'Stable instructions.' },
{ role: 'user', content: 'First question.' },
{ role: 'assistant', content: 'First answer.' },
{ role: 'system', content: 'Dynamic tail.' },
{ role: 'user', content: 'Current question.' },
]);

/**
* The instruction breakpoint is the one under test. The tail also carries
* one here, from the separate rule that marks the history prefix before
* the current turn; that marker is not this selection.
*/
expect(JSON.stringify(messages[0])).toContain('prompt_cache_breakpoint');
});

it('places cache breakpoints after instructions and the prior history prefix', () => {
const messages = addChatCacheBreakpoints([
{ role: 'system', content: 'Stable instructions.' },
Expand Down
Loading