Skip to content

Commit 6cc77ae

Browse files
authored
Merge pull request #184 from UiPath/feat/agent-framework-session-persistence
feat: add SQLite session persistence for agent-framework
2 parents f121a61 + 4813d47 commit 6cc77ae

8 files changed

Lines changed: 405 additions & 9 deletions

File tree

packages/uipath-agent-framework/pyproject.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
[project]
22
name = "uipath-agent-framework"
3-
version = "0.0.1"
3+
version = "0.0.2"
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"
77
dependencies = [
88
"agent-framework-core>=1.0.0b260212",
9+
"aiosqlite>=0.20.0",
910
"openinference-instrumentation-agent-framework>=0.1.0",
1011
"uipath>=2.8.41, <2.9.0",
1112
"uipath-runtime>=0.9.0, <0.10.0",
@@ -91,6 +92,14 @@ module = "openinference.*"
9192
ignore_missing_imports = true
9293
ignore_errors = true
9394

95+
[[tool.mypy.overrides]]
96+
module = "aiosqlite.*"
97+
ignore_missing_imports = true
98+
99+
[[tool.mypy.overrides]]
100+
module = ["anthropic", "anthropic.*"]
101+
ignore_missing_imports = true
102+
94103
[[tool.mypy.overrides]]
95104
module = "agent_framework_anthropic.*"
96105
ignore_missing_imports = true

packages/uipath-agent-framework/samples/quickstart-agent/pyproject.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,4 @@ dev = [
1919
[tool.uv]
2020
prerelease = "allow"
2121

22-
[tool.uv.sources]
23-
uipath-dev = { path = "../../../../../uipath-dev-python", editable = true }
24-
uipath-agent-framework = { path = "../../", editable = true }
22+

packages/uipath-agent-framework/src/uipath_agent_framework/chat/anthropic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def __new__(
103103
_check_anthropic_dependency()
104104

105105
from agent_framework_anthropic import AnthropicClient
106-
from anthropic import AsyncAnthropic # type: ignore[import-not-found]
106+
from anthropic import AsyncAnthropic # type: ignore[import-untyped]
107107

108108
uipath_url, token = get_uipath_config()
109109
gateway_url = build_gateway_url("awsbedrock", model, uipath_url)

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

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Factory for creating Agent Framework runtimes from agent_framework.json configuration."""
22

33
import asyncio
4+
import os
45
from typing import Any
56

67
from agent_framework import BaseAgent
@@ -25,6 +26,7 @@
2526
)
2627
from uipath_agent_framework.runtime.loader import AgentFrameworkAgentLoader
2728
from uipath_agent_framework.runtime.runtime import UiPathAgentFrameworkRuntime
29+
from uipath_agent_framework.runtime.storage import SqliteSessionStore
2830

2931

3032
class UiPathAgentFrameworkRuntimeFactory:
@@ -47,6 +49,9 @@ def __init__(
4749
self._agent_loaders: dict[str, AgentFrameworkAgentLoader] = {}
4850
self._agent_lock = asyncio.Lock()
4951

52+
self._session_store: SqliteSessionStore | None = None
53+
self._session_store_lock = asyncio.Lock()
54+
5055
self._setup_instrumentation()
5156

5257
def _setup_instrumentation(self) -> None:
@@ -64,6 +69,32 @@ def _load_config(self) -> AgentFrameworkConfig:
6469
self._config = AgentFrameworkConfig()
6570
return self._config
6671

72+
def _get_db_path(self) -> str:
73+
"""Get the database path for session persistence.
74+
75+
Uses UiPathRuntimeContext to resolve the state file path.
76+
Cleans up stale state files when not resuming.
77+
"""
78+
path = self.context.resolved_state_file_path
79+
# Delete previous state file if not resuming
80+
if (
81+
not self.context.resume
82+
and self.context.job_id is None
83+
and not self.context.keep_state_file
84+
):
85+
if os.path.exists(path):
86+
os.remove(path)
87+
return path
88+
89+
async def _get_session_store(self) -> SqliteSessionStore:
90+
"""Get or create the shared session store instance."""
91+
async with self._session_store_lock:
92+
if self._session_store is None:
93+
db_path = self._get_db_path()
94+
self._session_store = SqliteSessionStore(db_path)
95+
await self._session_store.setup()
96+
return self._session_store
97+
6798
async def _load_agent(self, entrypoint: str) -> BaseAgent:
6899
"""
69100
Load an agent for the given entrypoint.
@@ -182,11 +213,19 @@ async def _create_runtime_instance(
182213
runtime_id: str,
183214
entrypoint: str,
184215
) -> UiPathRuntimeProtocol:
185-
"""Create a runtime instance from an agent."""
216+
"""Create a runtime instance from an agent.
217+
218+
Creates the runtime with a shared SqliteSessionStore for persistent
219+
conversation history. Sessions are isolated by runtime_id — each
220+
runtime instance gets its own conversation state.
221+
"""
222+
session_store = await self._get_session_store()
223+
186224
return UiPathAgentFrameworkRuntime(
187225
agent=agent,
188226
runtime_id=runtime_id,
189227
entrypoint=entrypoint,
228+
session_store=session_store,
190229
)
191230

192231
async def new_runtime(
@@ -218,3 +257,7 @@ async def dispose(self) -> None:
218257

219258
self._agent_loaders.clear()
220259
self._agent_cache.clear()
260+
261+
if self._session_store:
262+
await self._session_store.dispose()
263+
self._session_store = None

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

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from agent_framework import (
99
AgentResponse,
1010
AgentResponseUpdate,
11+
AgentSession,
1112
BaseAgent,
1213
Content,
1314
FunctionTool,
@@ -36,6 +37,7 @@
3637
get_agent_tools,
3738
get_entrypoints_schema,
3839
)
40+
from .storage import SqliteSessionStore
3941

4042
logger = logging.getLogger(__name__)
4143

@@ -48,11 +50,13 @@ def __init__(
4850
agent: BaseAgent,
4951
runtime_id: str | None = None,
5052
entrypoint: str | None = None,
53+
session_store: SqliteSessionStore | None = None,
5154
):
5255
self.agent: BaseAgent = agent
5356
self.runtime_id: str = runtime_id or "default"
5457
self.entrypoint: str | None = entrypoint
5558
self.chat = AgentFrameworkChatMessagesMapper()
59+
self._session_store = session_store
5660

5761
@staticmethod
5862
def _build_agent_tool_names(agent: BaseAgent) -> set[str]:
@@ -84,6 +88,30 @@ def _build_tool_name_to_agent(agent: BaseAgent) -> dict[str, str]:
8488
mapping[tool.name] = agent_name
8589
return mapping
8690

91+
async def _load_session(self) -> AgentSession:
92+
"""Load or create an AgentSession for this runtime_id.
93+
94+
If a session store is configured, loads the persisted session state.
95+
Otherwise creates a fresh session each time.
96+
"""
97+
if self._session_store:
98+
session_data = await self._session_store.load_session(self.runtime_id)
99+
if session_data is not None:
100+
logger.debug(
101+
"Restoring session from store for runtime_id=%s",
102+
self.runtime_id,
103+
)
104+
return AgentSession.from_dict(session_data) # type: ignore[attr-defined]
105+
106+
return self.agent.create_session(session_id=self.runtime_id) # type: ignore[attr-defined]
107+
108+
async def _save_session(self, session: AgentSession) -> None:
109+
"""Persist the session state after execution."""
110+
if self._session_store:
111+
session_data = session.to_dict() # type: ignore[attr-defined]
112+
await self._session_store.save_session(self.runtime_id, session_data)
113+
logger.debug("Saved session to store for runtime_id=%s", self.runtime_id)
114+
87115
async def execute(
88116
self,
89117
input: dict[str, Any] | None = None,
@@ -92,7 +120,9 @@ async def execute(
92120
"""Execute the agent with the provided input and return the result."""
93121
try:
94122
user_input = self._prepare_input(input)
95-
response = await self.agent.run(user_input) # type: ignore[attr-defined]
123+
session = await self._load_session()
124+
response = await self.agent.run(user_input, session=session) # type: ignore[attr-defined]
125+
await self._save_session(session)
96126
output = self._extract_output(response)
97127
return self._create_success_result(output)
98128
except Exception as e:
@@ -115,6 +145,7 @@ async def stream(
115145
"""
116146
try:
117147
user_input = self._prepare_input(input)
148+
session = await self._load_session()
118149
agent_name = self.agent.name or "agent"
119150

120151
# Pre-compute which tool names correspond to sub-agents
@@ -132,7 +163,7 @@ async def stream(
132163
active_tools: str | None = None
133164
final_text = ""
134165

135-
response_stream = self.agent.run(user_input, stream=True) # type: ignore[attr-defined]
166+
response_stream = self.agent.run(user_input, stream=True, session=session) # type: ignore[attr-defined]
136167
async for update in response_stream:
137168
if not isinstance(update, AgentResponseUpdate):
138169
continue
@@ -290,6 +321,9 @@ async def stream(
290321
for msg_event in self.chat.close_message():
291322
yield UiPathRuntimeMessageEvent(payload=msg_event)
292323

324+
# Persist session state after streaming completes
325+
await self._save_session(session)
326+
293327
# Get final response
294328
final_response = await response_stream.get_final_response()
295329
output = self._extract_output(final_response)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""SQLite session store for Agent Framework agents.
2+
3+
Persists AgentSession state between turns using SQLite, keyed by runtime_id.
4+
Each runtime_id maps to an isolated session — conversation history accumulates
5+
across calls via the InMemoryHistoryProvider that Agent Framework auto-injects.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import asyncio
11+
import json
12+
import logging
13+
import os
14+
from typing import Any
15+
16+
import aiosqlite
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class SqliteSessionStore:
22+
"""SQLite-backed store for Agent Framework session state.
23+
24+
Stores serialized AgentSession dicts (via to_dict/from_dict) in a single
25+
table, keyed by runtime_id. Thread-safe via asyncio lock.
26+
"""
27+
28+
def __init__(self, db_path: str) -> None:
29+
self.db_path = db_path
30+
self._conn: aiosqlite.Connection | None = None
31+
self._lock = asyncio.Lock()
32+
self._initialized = False
33+
34+
async def setup(self) -> None:
35+
"""Ensure storage directory and database table exist."""
36+
dir_name = os.path.dirname(self.db_path)
37+
if dir_name:
38+
os.makedirs(dir_name, exist_ok=True)
39+
40+
conn = await self._get_conn()
41+
async with self._lock:
42+
await conn.execute(
43+
"""
44+
CREATE TABLE IF NOT EXISTS sessions (
45+
runtime_id TEXT PRIMARY KEY,
46+
session_data TEXT NOT NULL
47+
)
48+
"""
49+
)
50+
await conn.commit()
51+
self._initialized = True
52+
logger.debug("Session store initialized at %s", self.db_path)
53+
54+
async def _get_conn(self) -> aiosqlite.Connection:
55+
"""Get or create the database connection."""
56+
if self._conn is None:
57+
self._conn = await aiosqlite.connect(self.db_path, timeout=30.0)
58+
await self._conn.execute("PRAGMA journal_mode=WAL")
59+
await self._conn.execute("PRAGMA busy_timeout=30000")
60+
await self._conn.execute("PRAGMA synchronous=NORMAL")
61+
await self._conn.commit()
62+
return self._conn
63+
64+
async def load_session(self, runtime_id: str) -> dict[str, Any] | None:
65+
"""Load a serialized session dict for the given runtime_id.
66+
67+
Returns None if no session exists for this runtime_id.
68+
"""
69+
if not self._initialized:
70+
await self.setup()
71+
72+
conn = await self._get_conn()
73+
async with self._lock:
74+
cursor = await conn.execute(
75+
"SELECT session_data FROM sessions WHERE runtime_id = ?",
76+
(runtime_id,),
77+
)
78+
row = await cursor.fetchone()
79+
80+
if not row:
81+
logger.debug("No session found for runtime_id=%s", runtime_id)
82+
return None
83+
84+
logger.debug("Loaded session for runtime_id=%s", runtime_id)
85+
return json.loads(row[0])
86+
87+
async def save_session(self, runtime_id: str, session_data: dict[str, Any]) -> None:
88+
"""Save a serialized session dict for the given runtime_id."""
89+
if not self._initialized:
90+
await self.setup()
91+
92+
data_json = json.dumps(session_data)
93+
conn = await self._get_conn()
94+
async with self._lock:
95+
await conn.execute(
96+
"""
97+
INSERT INTO sessions (runtime_id, session_data)
98+
VALUES (?, ?)
99+
ON CONFLICT(runtime_id) DO UPDATE SET
100+
session_data = excluded.session_data
101+
""",
102+
(runtime_id, data_json),
103+
)
104+
await conn.commit()
105+
106+
logger.debug("Saved session for runtime_id=%s", runtime_id)
107+
108+
async def dispose(self) -> None:
109+
"""Close the database connection."""
110+
if self._conn:
111+
await self._conn.close()
112+
self._conn = None
113+
self._initialized = False
114+
115+
116+
__all__ = ["SqliteSessionStore"]

0 commit comments

Comments
 (0)