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

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions