Skip to content

Commit 27a8882

Browse files
cristipufuclaude
andcommitted
fix: preserve checkpoint on breakpoint resume to prevent duplicate events
When a breakpoint fired before any new checkpoint was created during a workflow.run() call, the stale checkpoint detection (baseline == latest) saved checkpoint_id=None. The next resume then replayed from original_input, re-executing already-completed executors like triage, which re-emitted handoff_to_billing_agent events (4x duplicates). Fix: fall back to self._resumed_from_checkpoint_id instead of None when no new checkpoint was created. This preserves the checkpoint we resumed from (still valid) instead of losing it. For fresh turns, _resumed_from_checkpoint_id is None, so the multi-turn stale checkpoint fix from 5c6dc25 is preserved. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5c6dc25 commit 27a8882

4 files changed

Lines changed: 249 additions & 46 deletions

File tree

packages/uipath-agent-framework/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-agent-framework"
3-
version = "0.0.7"
3+
version = "0.0.8"
44
description = "Python SDK that enables developers to build and deploy Microsoft Agent Framework agents to the UiPath Cloud Platform"
55
readme = "README.md"
66
requires-python = ">=3.11"

packages/uipath-agent-framework/src/uipath_agent_framework/runtime/runtime.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -443,11 +443,15 @@ async def execute(
443443
# Only save checkpoint_id if it was created during THIS run.
444444
# If latest == baseline, no new checkpoint was created (e.g.
445445
# breakpoint on the first executor of a fresh turn) — save
446-
# None so the resume replays from original_input with skips.
446+
# the checkpoint we resumed from (if any) so we don't lose
447+
# it and replay from scratch on the next resume.
448+
# For fresh turns _resumed_from_checkpoint_id is None, which
449+
# correctly prevents using a stale checkpoint from the
450+
# previous turn.
447451
effective_checkpoint = (
448452
latest_checkpoint
449453
if latest_checkpoint != baseline_checkpoint_id
450-
else None
454+
else self._resumed_from_checkpoint_id
451455
)
452456
await self._save_breakpoint_state(
453457
original_input, checkpoint_id=effective_checkpoint
@@ -655,13 +659,8 @@ async def _stream_workflow(
655659
):
656660
if tool_event.phase not in emitted_phases:
657661
# Track pending tool nodes
658-
if (
659-
tool_event.phase
660-
== UiPathRuntimeStatePhase.STARTED
661-
):
662-
self._pending_tool_nodes.add(
663-
tool_event.node_name
664-
)
662+
if tool_event.phase == UiPathRuntimeStatePhase.STARTED:
663+
self._pending_tool_nodes.add(tool_event.node_name)
665664
elif (
666665
tool_event.phase
667666
== UiPathRuntimeStatePhase.COMPLETED
@@ -683,9 +682,9 @@ async def _stream_workflow(
683682
event.data, executor_id
684683
)
685684
for tool_event in tool_events:
686-
executor_tool_phases.setdefault(
687-
executor_id, set()
688-
).add(tool_event.phase)
685+
executor_tool_phases.setdefault(executor_id, set()).add(
686+
tool_event.phase
687+
)
689688
# Track pending tool nodes across stream iterations
690689
if tool_event.phase == UiPathRuntimeStatePhase.STARTED:
691690
self._pending_tool_nodes.add(tool_event.node_name)
@@ -738,10 +737,12 @@ async def _stream_workflow(
738737
)
739738
self._last_breakpoint_node = node_id
740739
# Only save checkpoint_id if it was created during THIS run.
740+
# Fall back to the checkpoint we resumed from (if any) to
741+
# avoid replaying from scratch on the next resume.
741742
effective_checkpoint = (
742743
latest_checkpoint
743744
if latest_checkpoint != baseline_checkpoint_id
744-
else None
745+
else self._resumed_from_checkpoint_id
745746
)
746747
await self._save_breakpoint_state(
747748
user_input, checkpoint_id=effective_checkpoint

0 commit comments

Comments
 (0)