Skip to content

Commit bdbaaec

Browse files
committed
fix: avoid replaying assistant conversation item IDs
1 parent 652c2f2 commit bdbaaec

6 files changed

Lines changed: 339 additions & 12 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@openai/agents-core': patch
3+
'@openai/agents-openai': patch
4+
---
5+
6+
fix: avoid replaying assistant conversation item IDs from OpenAI Conversations history

packages/agents-core/src/memory/session.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ export interface Session {
3838
*/
3939
getItems(limit?: number): Promise<AgentInputItem[]>;
4040

41+
/**
42+
* Optionally rewrite a stored history item before it is sent back to the model.
43+
*
44+
* Session implementations can use this to strip provider-managed replay metadata while
45+
* preserving their public `getItems()` shape for UI and deletion workflows.
46+
*/
47+
prepareHistoryItemForModelInput?(item: AgentInputItem): AgentInputItem;
48+
4149
/**
4250
* Append new items to the conversation history.
4351
*

packages/agents-core/src/runner/sessionPersistence.ts

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -342,8 +342,11 @@ export async function prepareInputItemsWithSession(
342342
const newInputItems = toAgentInputList(input);
343343

344344
if (!sessionInputCallback) {
345+
const historyForModelInput = history.map((item) =>
346+
prepareHistoryItemForModelInput(session, item),
347+
);
345348
const preparedInput = includeHistoryInPreparedInput
346-
? dropOrphanToolCalls([...history, ...newInputItems], {
349+
? dropOrphanToolCalls([...historyForModelInput, ...newInputItems], {
347350
pruningIndexes: new Set(history.map((_, index) => index)),
348351
})
349352
: newInputItems;
@@ -363,37 +366,41 @@ export async function prepareInputItemsWithSession(
363366
);
364367
}
365368

366-
const historyCounts = buildItemFrequencyMap(historySnapshot);
369+
const historyCounts = buildItemFrequencyMap(historySnapshot, {
370+
session,
371+
prepareForModelInput: true,
372+
});
367373
const newInputCounts = buildItemFrequencyMap(newInputSnapshot);
368374
const historyRefs = buildAgentInputPool(historySnapshot);
369375
const newInputRefs = buildAgentInputPool(newInputSnapshot);
370376
const historyIndexes = new Set<number>();
371377

372378
const appended: AgentInputItem[] = [];
373379
for (const [index, item] of combined.entries()) {
374-
const key = getAgentInputItemKey(item);
380+
const historyKey = getHistoryItemModelInputKey(session, item);
381+
const newInputKey = getAgentInputItemKey(item);
375382
if (removeAgentInputFromPool(newInputRefs, item)) {
376-
decrementCount(newInputCounts, key);
383+
decrementCount(newInputCounts, newInputKey);
377384
appended.push(item);
378385
continue;
379386
}
380387

381388
if (removeAgentInputFromPool(historyRefs, item)) {
382-
decrementCount(historyCounts, key);
389+
decrementCount(historyCounts, historyKey);
383390
historyIndexes.add(index);
384391
continue;
385392
}
386393

387-
const historyRemaining = historyCounts.get(key) ?? 0;
394+
const historyRemaining = historyCounts.get(historyKey) ?? 0;
388395
if (historyRemaining > 0) {
389-
historyCounts.set(key, historyRemaining - 1);
396+
historyCounts.set(historyKey, historyRemaining - 1);
390397
historyIndexes.add(index);
391398
continue;
392399
}
393400

394-
const newRemaining = newInputCounts.get(key) ?? 0;
401+
const newRemaining = newInputCounts.get(newInputKey) ?? 0;
395402
if (newRemaining > 0) {
396-
newInputCounts.set(key, newRemaining - 1);
403+
newInputCounts.set(newInputKey, newRemaining - 1);
397404
appended.push(item);
398405
continue;
399406
}
@@ -421,7 +428,14 @@ export async function prepareInputItemsWithSession(
421428
}
422429

423430
const prunedPreparedItems = includeHistoryInPreparedInput
424-
? dropOrphanToolCalls(preparedItems, { pruningIndexes: historyIndexes })
431+
? dropOrphanToolCalls(
432+
prepareHistoryItemsForModelInput(
433+
session,
434+
preparedItems,
435+
historyIndexes,
436+
),
437+
{ pruningIndexes: historyIndexes },
438+
)
425439
: preparedItems;
426440

427441
return {
@@ -430,6 +444,35 @@ export async function prepareInputItemsWithSession(
430444
};
431445
}
432446

447+
function prepareHistoryItemsForModelInput(
448+
session: Session,
449+
items: AgentInputItem[],
450+
historyIndexes: Set<number>,
451+
): AgentInputItem[] {
452+
if (!session.prepareHistoryItemForModelInput || historyIndexes.size === 0) {
453+
return items;
454+
}
455+
return items.map((item, index) =>
456+
historyIndexes.has(index)
457+
? prepareHistoryItemForModelInput(session, item)
458+
: item,
459+
);
460+
}
461+
462+
function prepareHistoryItemForModelInput(
463+
session: Session,
464+
item: AgentInputItem,
465+
): AgentInputItem {
466+
return session.prepareHistoryItemForModelInput?.(item) ?? item;
467+
}
468+
469+
function getHistoryItemModelInputKey(
470+
session: Session,
471+
item: AgentInputItem,
472+
): string {
473+
return getAgentInputItemKey(prepareHistoryItemForModelInput(session, item));
474+
}
475+
433476
function normalizeItemsForSessionPersistence(
434477
items: AgentInputItem[],
435478
): AgentInputItem[] {
@@ -635,10 +678,16 @@ async function runCompactionOnSession(
635678
);
636679
}
637680

638-
function buildItemFrequencyMap(items: AgentInputItem[]): Map<string, number> {
681+
function buildItemFrequencyMap(
682+
items: AgentInputItem[],
683+
options?: { session?: Session; prepareForModelInput?: boolean },
684+
): Map<string, number> {
639685
const counts = new Map<string, number>();
640686
for (const item of items) {
641-
const key = getAgentInputItemKey(item);
687+
const key =
688+
options?.prepareForModelInput && options.session
689+
? getHistoryItemModelInputKey(options.session, item)
690+
: getAgentInputItemKey(item);
642691
counts.set(key, (counts.get(key) ?? 0) + 1);
643692
}
644693
return counts;

packages/agents-core/test/runner/sessionPersistence.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,28 @@ describe('prepareInputItemsWithSession', () => {
697697
async clearSession(): Promise<void> {}
698698
}
699699

700+
class AssistantReplaySanitizingSession extends StubSession {
701+
prepareHistoryItemForModelInput(item: AgentInputItem): AgentInputItem {
702+
if (
703+
!item ||
704+
typeof item !== 'object' ||
705+
Array.isArray(item) ||
706+
item.type !== 'message' ||
707+
item.role !== 'assistant'
708+
) {
709+
return item;
710+
}
711+
712+
const {
713+
id: _id,
714+
providerData: _providerData,
715+
provider_data: _provider_data,
716+
...rest
717+
} = item as Record<string, unknown>;
718+
return rest as AgentInputItem;
719+
}
720+
}
721+
700722
it('concatenates session history with array inputs when no callback is provided', async () => {
701723
const historyItem: AgentInputItem = {
702724
type: 'message',
@@ -738,6 +760,171 @@ describe('prepareInputItemsWithSession', () => {
738760
expect(sessionItems[1]).toBe(newItems[1]);
739761
});
740762

763+
it('sanitizes assistant history items before model input when the session requests it', async () => {
764+
const userHistoryItem: AgentInputItem = {
765+
id: 'conv-user',
766+
type: 'message',
767+
role: 'user',
768+
content: 'user history',
769+
providerData: { server: 'metadata' },
770+
};
771+
const assistantHistoryItem: AgentInputItem = {
772+
id: 'conv-assistant',
773+
type: 'message',
774+
role: 'assistant',
775+
status: 'completed',
776+
content: [
777+
{
778+
type: 'output_text',
779+
text: 'assistant history',
780+
},
781+
],
782+
providerData: { server: 'metadata' },
783+
};
784+
const functionCallItem: AgentInputItem = {
785+
id: 'conv-call',
786+
type: 'function_call',
787+
name: 'lookup',
788+
callId: 'call-history',
789+
arguments: '{}',
790+
status: 'completed',
791+
};
792+
const functionCallOutputItem: AgentInputItem = {
793+
id: 'conv-output',
794+
type: 'function_call_result',
795+
name: 'lookup',
796+
callId: 'call-history',
797+
output: 'ok',
798+
status: 'completed',
799+
};
800+
const session = new AssistantReplaySanitizingSession([
801+
userHistoryItem,
802+
assistantHistoryItem,
803+
functionCallItem,
804+
functionCallOutputItem,
805+
]);
806+
807+
const result = await prepareInputItemsWithSession('new', session);
808+
809+
expect(result.preparedInput).toEqual([
810+
userHistoryItem,
811+
{
812+
type: 'message',
813+
role: 'assistant',
814+
status: 'completed',
815+
content: [
816+
{
817+
type: 'output_text',
818+
text: 'assistant history',
819+
},
820+
],
821+
},
822+
functionCallItem,
823+
functionCallOutputItem,
824+
...toAgentInputList('new'),
825+
]);
826+
expect(result.sessionItems).toEqual(toAgentInputList('new'));
827+
});
828+
829+
it('matches sanitized assistant history returned by callbacks without re-persisting it', async () => {
830+
const assistantHistoryItem: AgentInputItem = {
831+
id: 'conv-assistant',
832+
type: 'message',
833+
role: 'assistant',
834+
status: 'completed',
835+
content: [
836+
{
837+
type: 'output_text',
838+
text: 'assistant history',
839+
},
840+
],
841+
providerData: { server: 'metadata' },
842+
};
843+
const newItem: AgentInputItem = {
844+
type: 'message',
845+
role: 'user',
846+
content: 'new',
847+
};
848+
const session = new AssistantReplaySanitizingSession([
849+
assistantHistoryItem,
850+
]);
851+
852+
const result = await prepareInputItemsWithSession(
853+
[newItem],
854+
session,
855+
(history, newItems) => {
856+
const {
857+
id: _id,
858+
providerData: _providerData,
859+
...historyCopy
860+
} = history[0] as Record<string, unknown>;
861+
return [historyCopy as AgentInputItem, { ...newItems[0] }];
862+
},
863+
);
864+
865+
expect(result.preparedInput).toEqual([
866+
{
867+
type: 'message',
868+
role: 'assistant',
869+
status: 'completed',
870+
content: [
871+
{
872+
type: 'output_text',
873+
text: 'assistant history',
874+
},
875+
],
876+
},
877+
newItem,
878+
]);
879+
expect(result.sessionItems).toEqual([newItem]);
880+
});
881+
882+
it('keeps sanitized user history distinct when callbacks remove its id', async () => {
883+
const userHistoryItem: AgentInputItem = {
884+
id: 'conv-user',
885+
type: 'message',
886+
role: 'user',
887+
content: 'user history',
888+
providerData: { server: 'metadata' },
889+
};
890+
const newItem: AgentInputItem = {
891+
type: 'message',
892+
role: 'user',
893+
content: 'new',
894+
};
895+
const session = new AssistantReplaySanitizingSession([userHistoryItem]);
896+
897+
const result = await prepareInputItemsWithSession(
898+
[newItem],
899+
session,
900+
(history, newItems) => {
901+
const {
902+
id: _id,
903+
providerData: _providerData,
904+
...historyCopy
905+
} = history[0] as Record<string, unknown>;
906+
return [historyCopy as AgentInputItem, { ...newItems[0] }];
907+
},
908+
);
909+
910+
expect(result.preparedInput).toEqual([
911+
{
912+
type: 'message',
913+
role: 'user',
914+
content: 'user history',
915+
},
916+
newItem,
917+
]);
918+
expect(result.sessionItems).toEqual([
919+
{
920+
type: 'message',
921+
role: 'user',
922+
content: 'user history',
923+
},
924+
newItem,
925+
]);
926+
});
927+
741928
it('only persists new inputs when callbacks prepend history duplicates', async () => {
742929
const historyItem: AgentInputItem = {
743930
type: 'message',

packages/agents-openai/src/memory/openaiConversationsSession.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,10 @@ export class OpenAIConversationsSession
174174
return orderedItems;
175175
}
176176

177+
prepareHistoryItemForModelInput(item: AgentInputItem): AgentInputItem {
178+
return stripAssistantReplayMetadata(item);
179+
}
180+
177181
async addItems(items: AgentInputItem[]): Promise<void> {
178182
if (!items.length) {
179183
return;
@@ -241,6 +245,25 @@ function stripIdsAndProviderData(items: AgentInputItem[]): AgentInputItem[] {
241245
});
242246
}
243247

248+
function stripAssistantReplayMetadata(item: AgentInputItem): AgentInputItem {
249+
if (Array.isArray(item) || item === null || typeof item !== 'object') {
250+
return item;
251+
}
252+
253+
const record = item as Record<string, unknown>;
254+
if (record.type !== 'message' || record.role !== 'assistant') {
255+
return item;
256+
}
257+
258+
const {
259+
id: _id,
260+
providerData: _providerData,
261+
provider_data: _provider_data,
262+
...rest
263+
} = record;
264+
return rest as AgentInputItem;
265+
}
266+
244267
const INPUT_CONTENT_TYPES = new Set([
245268
'input_text',
246269
'input_image',

0 commit comments

Comments
 (0)