Skip to content

Commit 6d511d7

Browse files
committed
fix: prevent duplicate function_call items in session history after resuming from interruptions
1 parent 1300121 commit 6d511d7

File tree

2 files changed

+74
-13
lines changed

2 files changed

+74
-13
lines changed

packages/agents-core/src/run.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -732,24 +732,36 @@ export class Runner extends RunHooks<any, AgentOutputType<unknown>> {
732732

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

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

744+
// If continuing from interruption with next_step_run_again, continue the loop
745+
// but DON'T increment turn or reset counter - we're continuing the same turn
746+
if (turnResult.nextStep.type === 'next_step_run_again') {
747+
continue;
748+
}
749+
745750
continue;
746751
}
747752

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

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

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

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

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

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

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

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

12091226
result.state._originalInput = turnResult.originalInput;
12101227
result.state._generatedItems = turnResult.generatedItems;
1228+
// Don't reset counter here - it's already been adjusted by resolveInterruptedTurn's rewind logic
1229+
// Counter will be reset when _currentTurn is incremented (starting a new turn)
1230+
result.state._currentStep = turnResult.nextStep;
1231+
1232+
// If continuing from interruption with next_step_run_again, don't increment turn or reset counter
1233+
// We're continuing the same turn, not starting a new one
12111234
if (turnResult.nextStep.type === 'next_step_run_again') {
1212-
result.state._currentTurnPersistedItemCount = 0;
1235+
continue;
12131236
}
1214-
result.state._currentStep = turnResult.nextStep;
12151237
}
12161238

12171239
if (result.state._currentStep.type === 'next_step_final_output') {

packages/agents-core/src/runImplementation.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,37 @@ export async function resolveInterruptedTurn<TContext>(
687687
return false;
688688
});
689689

690+
// Filter out handoffs that were already executed before the interruption.
691+
// Handoffs that were already executed will have their call items in originalPreStepItems.
692+
// We check by callId to avoid re-executing the same handoff call.
693+
const executedHandoffCallIds = new Set<string>();
694+
for (const item of originalPreStepItems) {
695+
if (item instanceof RunHandoffCallItem && item.rawItem.callId) {
696+
executedHandoffCallIds.add(item.rawItem.callId);
697+
}
698+
}
699+
const pendingHandoffs = processedResponse.handoffs.filter((handoff) => {
700+
const callId = handoff.toolCall?.callId;
701+
// Only filter by callId - if callId matches, this handoff was already executed
702+
return !callId || !executedHandoffCallIds.has(callId);
703+
});
704+
705+
// If there are pending handoffs that haven't been executed yet, execute them now.
706+
// Otherwise, if there were handoffs that were already executed, we need to make sure
707+
// they don't get re-executed when we continue.
708+
if (pendingHandoffs.length > 0) {
709+
return await executeHandoffCalls(
710+
agent,
711+
originalInput,
712+
preStepItems,
713+
newItems,
714+
newResponse,
715+
pendingHandoffs,
716+
runner,
717+
state._context,
718+
);
719+
}
720+
690721
const completedStep = await maybeCompleteTurnFromToolResults({
691722
agent,
692723
runner,
@@ -1803,6 +1834,14 @@ export async function executeHandoffCalls<
18031834
runner: Runner,
18041835
runContext: RunContext<TContext>,
18051836
): Promise<SingleStepResult> {
1837+
logger.debug(
1838+
`[executeHandoffCalls] Executing ${runHandoffs.length} handoff(s):`,
1839+
);
1840+
for (const handoff of runHandoffs) {
1841+
logger.debug(
1842+
` - ${handoff.toolCall.name} (callId: ${handoff.toolCall.callId})`,
1843+
);
1844+
}
18061845
newStepItems = [...newStepItems];
18071846

18081847
if (runHandoffs.length === 0) {

0 commit comments

Comments
 (0)