Skip to content

Commit 37a2626

Browse files
authored
Merge pull request #363 from UiPath/feat/resumable-runtime-compat
feat: align integrations with resumable runtime
2 parents a6550c3 + b8b60d6 commit 37a2626

17 files changed

Lines changed: 586 additions & 108 deletions

File tree

packages/uipath-agent-framework/pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-agent-framework"
3-
version = "0.0.13"
3+
version = "0.1.0"
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"
@@ -9,8 +9,8 @@ dependencies = [
99
"agent-framework-orchestrations>=1.0.0b260219",
1010
"aiosqlite>=0.20.0",
1111
"openinference-instrumentation-agent-framework>=0.1.0",
12-
"uipath>=2.10.0, <2.11.0",
13-
"uipath-runtime>=0.11.0, <0.12.0",
12+
"uipath>=2.13.0, <2.14.0",
13+
"uipath-runtime>=0.12.1, <0.13.0",
1414
]
1515
classifiers = [
1616
"Intended Audience :: Developers",

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,17 +167,31 @@ async def delete_trigger(
167167
self, runtime_id: str, trigger: UiPathResumeTrigger
168168
) -> None:
169169
"""Delete a specific resume trigger by runtime_id and interrupt_id."""
170+
await self.delete_triggers(runtime_id, [trigger])
171+
172+
async def delete_triggers(
173+
self, runtime_id: str, triggers: list[UiPathResumeTrigger]
174+
) -> None:
175+
"""Delete specific resume triggers by runtime_id and interrupt_id."""
176+
interrupt_ids = [trigger.interrupt_id for trigger in triggers]
177+
if not interrupt_ids:
178+
return
179+
170180
conn = await self._get_conn()
171181
async with self._lock:
172182
await conn.execute(
173-
"DELETE FROM resume_triggers WHERE runtime_id = ? AND interrupt_id = ?",
174-
(runtime_id, trigger.interrupt_id),
183+
(
184+
"DELETE FROM resume_triggers "
185+
"WHERE runtime_id = ? "
186+
f"AND interrupt_id IN ({','.join('?' for _ in interrupt_ids)})"
187+
),
188+
(runtime_id, *interrupt_ids),
175189
)
176190
await conn.commit()
177191

178192
logger.debug(
179-
"Deleted trigger %s for runtime_id=%s",
180-
trigger.interrupt_id,
193+
"Deleted %d triggers for runtime_id=%s",
194+
len(interrupt_ids),
181195
runtime_id,
182196
)
183197

packages/uipath-agent-framework/tests/test_storage.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
import tempfile
55

66
from agent_framework import WorkflowCheckpoint
7+
from uipath.runtime import (
8+
UiPathResumeTrigger,
9+
UiPathResumeTriggerName,
10+
UiPathResumeTriggerType,
11+
)
712

813
from uipath_agent_framework.runtime.resumable_storage import (
914
ScopedCheckpointStorage,
@@ -236,6 +241,112 @@ async def test_dispose_allows_reconnect(self):
236241
assert loaded.checkpoint_id == "cp-persist"
237242
await storage2.dispose()
238243

244+
async def test_delete_triggers(self):
245+
"""delete_triggers removes sibling triggers and preserves runtime isolation."""
246+
with tempfile.TemporaryDirectory() as tmpdir:
247+
db_path = os.path.join(tmpdir, "test.db")
248+
storage = SqliteResumableStorage(db_path)
249+
await storage.setup()
250+
251+
api_trigger = UiPathResumeTrigger(
252+
trigger_type=UiPathResumeTriggerType.API,
253+
trigger_name=UiPathResumeTriggerName.API,
254+
item_key="first",
255+
interrupt_id="interrupt-1",
256+
)
257+
timer_trigger = UiPathResumeTrigger(
258+
trigger_type=UiPathResumeTriggerType.TIMER,
259+
trigger_name=UiPathResumeTriggerName.TIMER,
260+
item_key="third",
261+
interrupt_id="interrupt-1",
262+
)
263+
unrelated_trigger = UiPathResumeTrigger(
264+
trigger_type=UiPathResumeTriggerType.TASK,
265+
trigger_name=UiPathResumeTriggerName.TASK,
266+
item_key="second",
267+
interrupt_id="interrupt-2",
268+
)
269+
other_runtime_trigger = UiPathResumeTrigger(
270+
trigger_type=UiPathResumeTriggerType.API,
271+
trigger_name=UiPathResumeTriggerName.API,
272+
item_key="other-runtime",
273+
interrupt_id="interrupt-1",
274+
)
275+
276+
await storage.save_triggers(
277+
"runtime-a", [api_trigger, timer_trigger, unrelated_trigger]
278+
)
279+
await storage.save_triggers("runtime-b", [other_runtime_trigger])
280+
281+
await storage.delete_triggers("runtime-a", [api_trigger, timer_trigger])
282+
283+
runtime_a_triggers = await storage.get_triggers("runtime-a")
284+
assert runtime_a_triggers is not None
285+
assert [trigger.interrupt_id for trigger in runtime_a_triggers] == [
286+
"interrupt-2"
287+
]
288+
assert runtime_a_triggers[0].item_key == "second"
289+
290+
runtime_b_triggers = await storage.get_triggers("runtime-b")
291+
assert runtime_b_triggers is not None
292+
assert len(runtime_b_triggers) == 1
293+
assert runtime_b_triggers[0].item_key == "other-runtime"
294+
await storage.dispose()
295+
296+
async def test_delete_trigger_deletes_single_trigger(self):
297+
"""delete_trigger removes one trigger through the compatibility wrapper."""
298+
with tempfile.TemporaryDirectory() as tmpdir:
299+
db_path = os.path.join(tmpdir, "test.db")
300+
storage = SqliteResumableStorage(db_path)
301+
await storage.setup()
302+
303+
trigger = UiPathResumeTrigger(
304+
trigger_type=UiPathResumeTriggerType.API,
305+
trigger_name=UiPathResumeTriggerName.API,
306+
item_key="first",
307+
interrupt_id="interrupt-1",
308+
)
309+
unrelated_trigger = UiPathResumeTrigger(
310+
trigger_type=UiPathResumeTriggerType.TASK,
311+
trigger_name=UiPathResumeTriggerName.TASK,
312+
item_key="second",
313+
interrupt_id="interrupt-2",
314+
)
315+
316+
await storage.save_triggers("runtime-a", [trigger, unrelated_trigger])
317+
318+
await storage.delete_trigger("runtime-a", trigger)
319+
320+
runtime_a_triggers = await storage.get_triggers("runtime-a")
321+
assert runtime_a_triggers is not None
322+
assert [trigger.interrupt_id for trigger in runtime_a_triggers] == [
323+
"interrupt-2"
324+
]
325+
await storage.dispose()
326+
327+
async def test_delete_triggers_empty_list_is_noop(self):
328+
"""delete_triggers with an empty list leaves stored triggers unchanged."""
329+
with tempfile.TemporaryDirectory() as tmpdir:
330+
db_path = os.path.join(tmpdir, "test.db")
331+
storage = SqliteResumableStorage(db_path)
332+
await storage.setup()
333+
334+
trigger = UiPathResumeTrigger(
335+
trigger_type=UiPathResumeTriggerType.API,
336+
trigger_name=UiPathResumeTriggerName.API,
337+
item_key="first",
338+
interrupt_id="interrupt-1",
339+
)
340+
await storage.save_triggers("runtime-a", [trigger])
341+
342+
await storage.delete_triggers("runtime-a", [])
343+
344+
runtime_a_triggers = await storage.get_triggers("runtime-a")
345+
assert runtime_a_triggers is not None
346+
assert len(runtime_a_triggers) == 1
347+
assert runtime_a_triggers[0].item_key == "first"
348+
await storage.dispose()
349+
239350

240351
class TestScopedCheckpointStorage:
241352
"""Tests for ScopedCheckpointStorage prefix isolation."""

0 commit comments

Comments
 (0)