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
5 changes: 5 additions & 0 deletions .changeset/lemon-shrimps-whisper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openai/agents-core": patch
---

fix: #701 prevent duplicate function_call items in session history after resuming from interruption
48 changes: 35 additions & 13 deletions packages/agents-core/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,24 +732,36 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {

state._originalInput = turnResult.originalInput;
state._generatedItems = turnResult.generatedItems;
if (turnResult.nextStep.type === 'next_step_run_again') {
state._currentTurnPersistedItemCount = 0;
}
// Don't reset counter here - it's already been adjusted by resolveInterruptedTurn's rewind logic
// Counter will be reset when _currentTurn is incremented (starting a new turn)
state._currentStep = turnResult.nextStep;

if (turnResult.nextStep.type === 'next_step_interruption') {
// we are still in an interruption, so we need to avoid an infinite loop
return new RunResult<TContext, TAgent>(state);
}

// If continuing from interruption with next_step_run_again, continue the loop
// but DON'T increment turn or reset counter - we're continuing the same turn
if (turnResult.nextStep.type === 'next_step_run_again') {
continue;
}

continue;
}

if (state._currentStep.type === 'next_step_run_again') {
const artifacts = await prepareAgentArtifacts(state);

// Only increment turn and reset counter when starting a NEW turn
// If counter is non-zero, it means we're continuing from an interruption (counter was rewound)
// In that case, don't reset the counter - it's already been adjusted by the rewind logic
state._currentTurn++;
state._currentTurnPersistedItemCount = 0;
if (state._currentTurnPersistedItemCount === 0) {
// Only reset if counter is already 0 (starting a new turn)
// If counter is non-zero, we're continuing from interruption and it was already adjusted
state._currentTurnPersistedItemCount = 0;
}

if (state._currentTurn > state._maxTurns) {
state._currentAgentSpan?.setError({
Expand Down Expand Up @@ -867,9 +879,8 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {

state._originalInput = turnResult.originalInput;
state._generatedItems = turnResult.generatedItems;
if (turnResult.nextStep.type === 'next_step_run_again') {
state._currentTurnPersistedItemCount = 0;
}
// Don't reset counter here - it's already been adjusted by resolveInterruptedTurn's rewind logic
// Counter will be reset when _currentTurn is incremented (starting a new turn)
state._currentStep = turnResult.nextStep;

if (parallelGuardrailPromise) {
Expand Down Expand Up @@ -1021,9 +1032,8 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {

result.state._originalInput = turnResult.originalInput;
result.state._generatedItems = turnResult.generatedItems;
if (turnResult.nextStep.type === 'next_step_run_again') {
result.state._currentTurnPersistedItemCount = 0;
}
// Don't reset counter here - it's already been adjusted by resolveInterruptedTurn's rewind logic
// Counter will be reset when _currentTurn is incremented (starting a new turn)
result.state._currentStep = turnResult.nextStep;
if (turnResult.nextStep.type === 'next_step_interruption') {
// we are still in an interruption, so we need to avoid an infinite loop
Expand All @@ -1035,8 +1045,15 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {
if (result.state._currentStep.type === 'next_step_run_again') {
const artifacts = await prepareAgentArtifacts(result.state);

// Only increment turn and reset counter when starting a NEW turn
// If counter is non-zero, it means we're continuing from an interruption (counter was rewound)
// In that case, don't reset the counter - it's already been adjusted by the rewind logic
result.state._currentTurn++;
result.state._currentTurnPersistedItemCount = 0;
if (result.state._currentTurnPersistedItemCount === 0) {
// Only reset if counter is already 0 (starting a new turn)
// If counter is non-zero, we're continuing from interruption and it was already adjusted
result.state._currentTurnPersistedItemCount = 0;
}

if (result.state._currentTurn > result.state._maxTurns) {
result.state._currentAgentSpan?.setError({
Expand Down Expand Up @@ -1208,10 +1225,15 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {

result.state._originalInput = turnResult.originalInput;
result.state._generatedItems = turnResult.generatedItems;
// Don't reset counter here - it's already been adjusted by resolveInterruptedTurn's rewind logic
// Counter will be reset when _currentTurn is incremented (starting a new turn)
result.state._currentStep = turnResult.nextStep;

// If continuing from interruption with next_step_run_again, don't increment turn or reset counter
// We're continuing the same turn, not starting a new one
if (turnResult.nextStep.type === 'next_step_run_again') {
result.state._currentTurnPersistedItemCount = 0;
continue;
}
result.state._currentStep = turnResult.nextStep;
}

if (result.state._currentStep.type === 'next_step_final_output') {
Expand Down
31 changes: 31 additions & 0 deletions packages/agents-core/src/runImplementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,37 @@ export async function resolveInterruptedTurn<TContext>(
return false;
});

// Filter out handoffs that were already executed before the interruption.
// Handoffs that were already executed will have their call items in originalPreStepItems.
// We check by callId to avoid re-executing the same handoff call.
const executedHandoffCallIds = new Set<string>();
for (const item of originalPreStepItems) {
if (item instanceof RunHandoffCallItem && item.rawItem.callId) {
executedHandoffCallIds.add(item.rawItem.callId);
}
}
const pendingHandoffs = processedResponse.handoffs.filter((handoff) => {
const callId = handoff.toolCall?.callId;
// Only filter by callId - if callId matches, this handoff was already executed
return !callId || !executedHandoffCallIds.has(callId);
});

// If there are pending handoffs that haven't been executed yet, execute them now.
// Otherwise, if there were handoffs that were already executed, we need to make sure
// they don't get re-executed when we continue.
if (pendingHandoffs.length > 0) {
return await executeHandoffCalls(
agent,
originalInput,
preStepItems,
newItems,
newResponse,
pendingHandoffs,
runner,
state._context,
);
}

const completedStep = await maybeCompleteTurnFromToolResults({
agent,
runner,
Expand Down