Skip to content

Duplicate function_call items in session history after resuming from interruption #701

@mjschock

Description

@mjschock

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:

  1. Initial Run:

    • Model response → processModelResponse adds tool_call_item
    • Tool needs approval → executeFunctionToolCalls adds tool_approval_item
    • _generatedItems = [tool_call_item, tool_approval_item]
    • saveToSession filters out approval item
    • Only tool_call_item saved to session
    • Counter = 2 (tracks both items in _generatedItems)
  2. Resume After Approval:

    • Counter = 2, _generatedItems = [tool_call_item, tool_approval_item]
    • resolveInterruptedTurn executes:
      • 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)
    • saveToSession with counter=0:
      • slice(0) returns ALL items including tool_call_item again
      • Duplicate created!

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:

  1. Removed counter resets after resolveInterruptedTurn in all code paths (non-streaming and streaming)
  2. 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.
  3. Added handoff filtering in resolveInterruptedTurn to 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions