-
Notifications
You must be signed in to change notification settings - Fork 492
Description
Please read this first
- Have you read the docs? Agents SDK docs - Yes
- Have you searched for related issues? Yes, no similar issues found
Describe the bug
When using OpenAIConversationsSession with human-in-the-loop (HITL) tool approval, function_call items are duplicated in the conversation history. This occurs because the _currentTurnPersistedItemCount counter is incorrectly reset to 0 after resolveInterruptedTurn, undoing the rewind logic and causing items to be saved twice to the session.
Impact:
- Duplicate function_call entries in conversation history
- Affects ANY usage of sessions with HITL (with or without state serialization)
- Does not cause functional failures but pollutes conversation history
Debug information
- Agents SDK version:
v0.3.3(tested with current main branch) - Runtime environment:
Node.js 22.20.0
Repro steps
import { z } from 'zod';
import { Agent, run, tool } from '@openai/agents';
import { OpenAIConversationsSession } from '@openai/agents-openai';
import OpenAI from 'openai';
const getWeatherTool = tool({
name: 'get_weather',
description: 'Get weather for a city',
parameters: z.object({ city: z.string() }),
needsApproval: async () => true, // Require approval for all calls
execute: async ({ city }) => `Sunny, 72°F in ${city}`,
});
const agent = new Agent({
name: 'Assistant',
instructions: 'Use get_weather tool to answer weather questions.',
tools: [getWeatherTool],
});
const sessionInputCallback = async (historyItems: any[], newItems: any[]) => {
return [...historyItems, ...newItems];
};
async function main() {
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const session = new OpenAIConversationsSession({ client } as any);
// Initial run - creates interruption
let result = await run(
agent,
[{ role: 'user', content: "What's the weather in Oakland?" }],
{ session, sessionInputCallback }
);
// Approve tool call
for (const interruption of result.interruptions || []) {
result.state.approve(interruption);
}
// Resume - creates duplicate
result = await run(agent, result.state, { session });
// Check conversation
const items = await client.conversations.items.list(
await session.getSessionId(),
{ order: 'asc' }
);
const functionCalls = items.data.filter((item: any) => item.type === 'function_call');
console.log(`Function calls: ${functionCalls.length}`); // Expected: 1, Actual: 2
}Expected behavior
Conversation should contain:
- 1 user message
- 1 function_call item
- 1 function_call_output item
- 1 assistant message
Actual behavior
Conversation contains:
- 1 user message
- 2 function_call items (duplicates with identical call_ids)
- 1 function_call_output item
- 1 assistant message
Root Cause
The bug is in packages/agents-core/src/run.ts. After resolveInterruptedTurn returns next_step_run_again, the code was resetting _currentTurnPersistedItemCount to 0, which undid the rewind logic that had already adjusted the counter.
The Flow:
-
Initial Run:
- Model response →
processModelResponseaddstool_call_item - Tool needs approval →
executeFunctionToolCallsaddstool_approval_item _generatedItems=[tool_call_item, tool_approval_item]saveToSessionfilters out approval item- Only
tool_call_itemsaved to session - Counter = 2 (tracks both items in
_generatedItems)
- Model response →
-
Resume After Approval:
- Counter = 2,
_generatedItems=[tool_call_item, tool_approval_item] resolveInterruptedTurnexecutes:- Rewind logic subtracts 1: counter = 2 - 1 = 1 ✓
- Tool executes, new items added
- Returns new
_generatedItems=[tool_call_item, tool_call_output_item, message_output_item]
- Counter reset to 0 ❌ (this was the bug)
saveToSessionwith counter=0:slice(0)returns ALL items includingtool_call_itemagain- Duplicate created!
- Counter = 2,
Proposed Fix
The counter should NOT be reset to 0 when resolveInterruptedTurn returns next_step_run_again. The reset should only happen when starting a genuinely new turn (when _currentTurn is incremented).
Fix applied:
- Removed counter resets after
resolveInterruptedTurnin all code paths (non-streaming and streaming) - When continuing from interruption with
next_step_run_again, only reset counter if it's already 0 (meaning we're starting a new turn). If counter is non-zero (was rewound), preserve the rewound value. - Added handoff filtering in
resolveInterruptedTurnto prevent re-execution of already-executed handoffs
Additional Context
- The bug affects sessions whether or not state serialization is used
- The duplicate items don't cause functional failures, but they pollute conversation history
- The fix ensures that when resuming from an interruption, we're continuing the same turn, not starting a new one