agent-runtime-core 0.1.4__py3-none-any.whl → 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_runtime/__init__.py +53 -1
- agent_runtime/llm/anthropic.py +1 -1
- agent_runtime/llm/litellm_client.py +1 -1
- agent_runtime/llm/openai.py +1 -1
- agent_runtime/persistence/__init__.py +88 -0
- agent_runtime/persistence/base.py +332 -0
- agent_runtime/persistence/file.py +507 -0
- agent_runtime/persistence/manager.py +266 -0
- agent_runtime/tracing/langfuse.py +1 -1
- {agent_runtime_core-0.1.4.dist-info → agent_runtime_core-0.2.1.dist-info}/METADATA +3 -3
- {agent_runtime_core-0.1.4.dist-info → agent_runtime_core-0.2.1.dist-info}/RECORD +13 -9
- {agent_runtime_core-0.1.4.dist-info → agent_runtime_core-0.2.1.dist-info}/WHEEL +0 -0
- {agent_runtime_core-0.1.4.dist-info → agent_runtime_core-0.2.1.dist-info}/licenses/LICENSE +0 -0
agent_runtime/__init__.py
CHANGED
|
@@ -34,7 +34,7 @@ Example usage:
|
|
|
34
34
|
return RunResult(final_output={"message": "Hello!"})
|
|
35
35
|
"""
|
|
36
36
|
|
|
37
|
-
__version__ = "0.1
|
|
37
|
+
__version__ = "0.2.1"
|
|
38
38
|
|
|
39
39
|
# Core interfaces
|
|
40
40
|
from agent_runtime.interfaces import (
|
|
@@ -87,6 +87,34 @@ from agent_runtime.testing import (
|
|
|
87
87
|
run_agent_test,
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
+
# Persistence (memory, conversations, tasks, preferences)
|
|
91
|
+
from agent_runtime.persistence import (
|
|
92
|
+
# Abstract interfaces
|
|
93
|
+
MemoryStore,
|
|
94
|
+
ConversationStore,
|
|
95
|
+
TaskStore,
|
|
96
|
+
PreferencesStore,
|
|
97
|
+
Scope,
|
|
98
|
+
# Data classes
|
|
99
|
+
Conversation,
|
|
100
|
+
ConversationMessage,
|
|
101
|
+
ToolCall,
|
|
102
|
+
ToolResult,
|
|
103
|
+
TaskList,
|
|
104
|
+
Task,
|
|
105
|
+
TaskState,
|
|
106
|
+
# File implementations
|
|
107
|
+
FileMemoryStore,
|
|
108
|
+
FileConversationStore,
|
|
109
|
+
FileTaskStore,
|
|
110
|
+
FilePreferencesStore,
|
|
111
|
+
# Manager
|
|
112
|
+
PersistenceManager,
|
|
113
|
+
PersistenceConfig,
|
|
114
|
+
get_persistence_manager,
|
|
115
|
+
configure_persistence,
|
|
116
|
+
)
|
|
117
|
+
|
|
90
118
|
__all__ = [
|
|
91
119
|
# Version
|
|
92
120
|
"__version__",
|
|
@@ -125,4 +153,28 @@ __all__ = [
|
|
|
125
153
|
"LLMEvaluator",
|
|
126
154
|
"create_test_context",
|
|
127
155
|
"run_agent_test",
|
|
156
|
+
# Persistence - Abstract interfaces
|
|
157
|
+
"MemoryStore",
|
|
158
|
+
"ConversationStore",
|
|
159
|
+
"TaskStore",
|
|
160
|
+
"PreferencesStore",
|
|
161
|
+
"Scope",
|
|
162
|
+
# Persistence - Data classes
|
|
163
|
+
"Conversation",
|
|
164
|
+
"ConversationMessage",
|
|
165
|
+
"ToolCall",
|
|
166
|
+
"ToolResult",
|
|
167
|
+
"TaskList",
|
|
168
|
+
"Task",
|
|
169
|
+
"TaskState",
|
|
170
|
+
# Persistence - File implementations
|
|
171
|
+
"FileMemoryStore",
|
|
172
|
+
"FileConversationStore",
|
|
173
|
+
"FileTaskStore",
|
|
174
|
+
"FilePreferencesStore",
|
|
175
|
+
# Persistence - Manager
|
|
176
|
+
"PersistenceManager",
|
|
177
|
+
"PersistenceConfig",
|
|
178
|
+
"get_persistence_manager",
|
|
179
|
+
"configure_persistence",
|
|
128
180
|
]
|
agent_runtime/llm/anthropic.py
CHANGED
|
@@ -40,7 +40,7 @@ class AnthropicClient(LLMClient):
|
|
|
40
40
|
if AsyncAnthropic is None:
|
|
41
41
|
raise ImportError(
|
|
42
42
|
"anthropic package is required for AnthropicClient. "
|
|
43
|
-
"Install it with: pip install
|
|
43
|
+
"Install it with: pip install agent-runtime-core[anthropic]"
|
|
44
44
|
)
|
|
45
45
|
|
|
46
46
|
from agent_runtime.config import get_config
|
|
@@ -38,7 +38,7 @@ class LiteLLMClient(LLMClient):
|
|
|
38
38
|
if litellm is None:
|
|
39
39
|
raise ImportError(
|
|
40
40
|
"litellm package is required for LiteLLMClient. "
|
|
41
|
-
"Install it with: pip install
|
|
41
|
+
"Install it with: pip install agent-runtime-core[litellm]"
|
|
42
42
|
)
|
|
43
43
|
|
|
44
44
|
from agent_runtime.config import get_config
|
agent_runtime/llm/openai.py
CHANGED
|
@@ -42,7 +42,7 @@ class OpenAIClient(LLMClient):
|
|
|
42
42
|
if AsyncOpenAI is None:
|
|
43
43
|
raise ImportError(
|
|
44
44
|
"openai package is required for OpenAIClient. "
|
|
45
|
-
"Install it with: pip install
|
|
45
|
+
"Install it with: pip install agent-runtime-core[openai]"
|
|
46
46
|
)
|
|
47
47
|
|
|
48
48
|
from agent_runtime.config import get_config
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistence module for agent state, memory, and conversation history.
|
|
3
|
+
|
|
4
|
+
This module provides pluggable storage backends for:
|
|
5
|
+
- Memory (global and project-scoped key-value storage)
|
|
6
|
+
- Conversation history (full conversation state including tool calls)
|
|
7
|
+
- Task state (task lists and progress)
|
|
8
|
+
- Preferences (user and agent configuration)
|
|
9
|
+
|
|
10
|
+
Example usage:
|
|
11
|
+
from agent_runtime.persistence import (
|
|
12
|
+
MemoryStore,
|
|
13
|
+
ConversationStore,
|
|
14
|
+
FileMemoryStore,
|
|
15
|
+
FileConversationStore,
|
|
16
|
+
PersistenceManager,
|
|
17
|
+
Scope,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Use the high-level manager
|
|
21
|
+
manager = PersistenceManager()
|
|
22
|
+
|
|
23
|
+
# Store global memory
|
|
24
|
+
await manager.memory.set("user_name", "Alice", scope=Scope.GLOBAL)
|
|
25
|
+
|
|
26
|
+
# Store project-specific memory
|
|
27
|
+
await manager.memory.set("project_type", "python", scope=Scope.PROJECT)
|
|
28
|
+
|
|
29
|
+
# Save a conversation
|
|
30
|
+
await manager.conversations.save(conversation)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from agent_runtime.persistence.base import (
|
|
34
|
+
MemoryStore,
|
|
35
|
+
ConversationStore,
|
|
36
|
+
TaskStore,
|
|
37
|
+
PreferencesStore,
|
|
38
|
+
Scope,
|
|
39
|
+
Conversation,
|
|
40
|
+
ConversationMessage,
|
|
41
|
+
ToolCall,
|
|
42
|
+
ToolResult,
|
|
43
|
+
TaskList,
|
|
44
|
+
Task,
|
|
45
|
+
TaskState,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
from agent_runtime.persistence.file import (
|
|
49
|
+
FileMemoryStore,
|
|
50
|
+
FileConversationStore,
|
|
51
|
+
FileTaskStore,
|
|
52
|
+
FilePreferencesStore,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
from agent_runtime.persistence.manager import (
|
|
56
|
+
PersistenceManager,
|
|
57
|
+
PersistenceConfig,
|
|
58
|
+
get_persistence_manager,
|
|
59
|
+
configure_persistence,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
# Abstract interfaces
|
|
64
|
+
"MemoryStore",
|
|
65
|
+
"ConversationStore",
|
|
66
|
+
"TaskStore",
|
|
67
|
+
"PreferencesStore",
|
|
68
|
+
"Scope",
|
|
69
|
+
# Data classes
|
|
70
|
+
"Conversation",
|
|
71
|
+
"ConversationMessage",
|
|
72
|
+
"ToolCall",
|
|
73
|
+
"ToolResult",
|
|
74
|
+
"TaskList",
|
|
75
|
+
"Task",
|
|
76
|
+
"TaskState",
|
|
77
|
+
# File implementations
|
|
78
|
+
"FileMemoryStore",
|
|
79
|
+
"FileConversationStore",
|
|
80
|
+
"FileTaskStore",
|
|
81
|
+
"FilePreferencesStore",
|
|
82
|
+
# Manager
|
|
83
|
+
"PersistenceManager",
|
|
84
|
+
"PersistenceConfig",
|
|
85
|
+
"get_persistence_manager",
|
|
86
|
+
"configure_persistence",
|
|
87
|
+
]
|
|
88
|
+
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Abstract base classes for persistence backends.
|
|
3
|
+
|
|
4
|
+
These interfaces define the contract that all storage backends must implement.
|
|
5
|
+
Projects depending on agent-runtime-core can provide their own implementations
|
|
6
|
+
(e.g., database-backed, cloud storage, etc.).
|
|
7
|
+
|
|
8
|
+
For Django/database implementations:
|
|
9
|
+
- The `scope` parameter can be ignored if you use user/tenant context instead
|
|
10
|
+
- Store implementations receive context through their constructor (e.g., user, org)
|
|
11
|
+
- The abstract methods still accept scope for interface compatibility, but
|
|
12
|
+
implementations can choose to ignore it
|
|
13
|
+
|
|
14
|
+
Example Django implementation:
|
|
15
|
+
class DjangoMemoryStore(MemoryStore):
|
|
16
|
+
def __init__(self, user):
|
|
17
|
+
self.user = user
|
|
18
|
+
|
|
19
|
+
async def get(self, key: str, scope: Scope = Scope.PROJECT) -> Optional[Any]:
|
|
20
|
+
# Ignore scope, use self.user instead
|
|
21
|
+
try:
|
|
22
|
+
entry = await Memory.objects.aget(user=self.user, key=key)
|
|
23
|
+
return entry.value
|
|
24
|
+
except Memory.DoesNotExist:
|
|
25
|
+
return None
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from abc import ABC, abstractmethod
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from enum import Enum
|
|
32
|
+
from typing import Any, Optional, AsyncIterator
|
|
33
|
+
from uuid import UUID
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Scope(str, Enum):
|
|
37
|
+
"""
|
|
38
|
+
Storage scope for memory and other persistent data.
|
|
39
|
+
|
|
40
|
+
For file-based storage:
|
|
41
|
+
- GLOBAL: User's home directory (~/.agent_runtime/)
|
|
42
|
+
- PROJECT: Current working directory (./.agent_runtime/)
|
|
43
|
+
- SESSION: In-memory only, not persisted
|
|
44
|
+
|
|
45
|
+
For database-backed storage, implementations may ignore this
|
|
46
|
+
and use user/tenant context from the store constructor instead.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
GLOBAL = "global"
|
|
50
|
+
PROJECT = "project"
|
|
51
|
+
SESSION = "session"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TaskState(str, Enum):
|
|
55
|
+
"""State of a task."""
|
|
56
|
+
|
|
57
|
+
NOT_STARTED = "not_started"
|
|
58
|
+
IN_PROGRESS = "in_progress"
|
|
59
|
+
COMPLETE = "complete"
|
|
60
|
+
CANCELLED = "cancelled"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class ToolCall:
|
|
65
|
+
"""A tool call made during a conversation."""
|
|
66
|
+
|
|
67
|
+
id: str
|
|
68
|
+
name: str
|
|
69
|
+
arguments: dict
|
|
70
|
+
timestamp: datetime = field(default_factory=datetime.utcnow)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ToolResult:
|
|
75
|
+
"""Result of a tool call."""
|
|
76
|
+
|
|
77
|
+
tool_call_id: str
|
|
78
|
+
result: Any
|
|
79
|
+
error: Optional[str] = None
|
|
80
|
+
timestamp: datetime = field(default_factory=datetime.utcnow)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class ConversationMessage:
|
|
85
|
+
"""A message in a conversation with full state."""
|
|
86
|
+
|
|
87
|
+
id: UUID
|
|
88
|
+
role: str # system, user, assistant, tool
|
|
89
|
+
content: str | dict | list
|
|
90
|
+
timestamp: datetime = field(default_factory=datetime.utcnow)
|
|
91
|
+
|
|
92
|
+
# For assistant messages with tool calls
|
|
93
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
# For tool result messages
|
|
96
|
+
tool_call_id: Optional[str] = None
|
|
97
|
+
|
|
98
|
+
# Metadata
|
|
99
|
+
model: Optional[str] = None
|
|
100
|
+
usage: dict = field(default_factory=dict) # token counts
|
|
101
|
+
metadata: dict = field(default_factory=dict)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class Conversation:
|
|
106
|
+
"""A complete conversation with all state."""
|
|
107
|
+
|
|
108
|
+
id: UUID
|
|
109
|
+
title: Optional[str] = None
|
|
110
|
+
messages: list[ConversationMessage] = field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
# Metadata
|
|
113
|
+
created_at: datetime = field(default_factory=datetime.utcnow)
|
|
114
|
+
updated_at: datetime = field(default_factory=datetime.utcnow)
|
|
115
|
+
metadata: dict = field(default_factory=dict)
|
|
116
|
+
|
|
117
|
+
# Associated agent
|
|
118
|
+
agent_key: Optional[str] = None
|
|
119
|
+
|
|
120
|
+
# Summary for long conversations
|
|
121
|
+
summary: Optional[str] = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class Task:
|
|
126
|
+
"""A task in a task list."""
|
|
127
|
+
|
|
128
|
+
id: UUID
|
|
129
|
+
name: str
|
|
130
|
+
description: str = ""
|
|
131
|
+
state: TaskState = TaskState.NOT_STARTED
|
|
132
|
+
parent_id: Optional[UUID] = None
|
|
133
|
+
created_at: datetime = field(default_factory=datetime.utcnow)
|
|
134
|
+
updated_at: datetime = field(default_factory=datetime.utcnow)
|
|
135
|
+
metadata: dict = field(default_factory=dict)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class TaskList:
|
|
140
|
+
"""A list of tasks."""
|
|
141
|
+
|
|
142
|
+
id: UUID
|
|
143
|
+
name: str
|
|
144
|
+
tasks: list[Task] = field(default_factory=list)
|
|
145
|
+
created_at: datetime = field(default_factory=datetime.utcnow)
|
|
146
|
+
updated_at: datetime = field(default_factory=datetime.utcnow)
|
|
147
|
+
|
|
148
|
+
# Associated conversation/run
|
|
149
|
+
conversation_id: Optional[UUID] = None
|
|
150
|
+
run_id: Optional[UUID] = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class MemoryStore(ABC):
|
|
154
|
+
"""
|
|
155
|
+
Abstract interface for key-value memory storage.
|
|
156
|
+
|
|
157
|
+
Memory stores handle persistent key-value data that agents can
|
|
158
|
+
use to remember information across sessions.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
@abstractmethod
|
|
162
|
+
async def get(self, key: str, scope: Scope = Scope.PROJECT) -> Optional[Any]:
|
|
163
|
+
"""Get a value by key."""
|
|
164
|
+
...
|
|
165
|
+
|
|
166
|
+
@abstractmethod
|
|
167
|
+
async def set(self, key: str, value: Any, scope: Scope = Scope.PROJECT) -> None:
|
|
168
|
+
"""Set a value by key."""
|
|
169
|
+
...
|
|
170
|
+
|
|
171
|
+
@abstractmethod
|
|
172
|
+
async def delete(self, key: str, scope: Scope = Scope.PROJECT) -> bool:
|
|
173
|
+
"""Delete a key. Returns True if key existed."""
|
|
174
|
+
...
|
|
175
|
+
|
|
176
|
+
@abstractmethod
|
|
177
|
+
async def list_keys(self, scope: Scope = Scope.PROJECT, prefix: Optional[str] = None) -> list[str]:
|
|
178
|
+
"""List all keys, optionally filtered by prefix."""
|
|
179
|
+
...
|
|
180
|
+
|
|
181
|
+
@abstractmethod
|
|
182
|
+
async def clear(self, scope: Scope = Scope.PROJECT) -> None:
|
|
183
|
+
"""Clear all keys in the given scope."""
|
|
184
|
+
...
|
|
185
|
+
|
|
186
|
+
async def close(self) -> None:
|
|
187
|
+
"""Close any connections. Override if needed."""
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class ConversationStore(ABC):
|
|
192
|
+
"""
|
|
193
|
+
Abstract interface for conversation history storage.
|
|
194
|
+
|
|
195
|
+
Conversation stores handle full conversation state including
|
|
196
|
+
messages, tool calls, and metadata.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
@abstractmethod
|
|
200
|
+
async def save(self, conversation: Conversation, scope: Scope = Scope.PROJECT) -> None:
|
|
201
|
+
"""Save or update a conversation."""
|
|
202
|
+
...
|
|
203
|
+
|
|
204
|
+
@abstractmethod
|
|
205
|
+
async def get(self, conversation_id: UUID, scope: Scope = Scope.PROJECT) -> Optional[Conversation]:
|
|
206
|
+
"""Get a conversation by ID."""
|
|
207
|
+
...
|
|
208
|
+
|
|
209
|
+
@abstractmethod
|
|
210
|
+
async def delete(self, conversation_id: UUID, scope: Scope = Scope.PROJECT) -> bool:
|
|
211
|
+
"""Delete a conversation. Returns True if it existed."""
|
|
212
|
+
...
|
|
213
|
+
|
|
214
|
+
@abstractmethod
|
|
215
|
+
async def list_conversations(
|
|
216
|
+
self,
|
|
217
|
+
scope: Scope = Scope.PROJECT,
|
|
218
|
+
limit: int = 100,
|
|
219
|
+
offset: int = 0,
|
|
220
|
+
agent_key: Optional[str] = None,
|
|
221
|
+
) -> list[Conversation]:
|
|
222
|
+
"""List conversations, optionally filtered by agent."""
|
|
223
|
+
...
|
|
224
|
+
|
|
225
|
+
@abstractmethod
|
|
226
|
+
async def add_message(
|
|
227
|
+
self,
|
|
228
|
+
conversation_id: UUID,
|
|
229
|
+
message: ConversationMessage,
|
|
230
|
+
scope: Scope = Scope.PROJECT,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Add a message to an existing conversation."""
|
|
233
|
+
...
|
|
234
|
+
|
|
235
|
+
@abstractmethod
|
|
236
|
+
async def get_messages(
|
|
237
|
+
self,
|
|
238
|
+
conversation_id: UUID,
|
|
239
|
+
scope: Scope = Scope.PROJECT,
|
|
240
|
+
limit: Optional[int] = None,
|
|
241
|
+
before: Optional[datetime] = None,
|
|
242
|
+
) -> list[ConversationMessage]:
|
|
243
|
+
"""Get messages from a conversation."""
|
|
244
|
+
...
|
|
245
|
+
|
|
246
|
+
async def close(self) -> None:
|
|
247
|
+
"""Close any connections. Override if needed."""
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class TaskStore(ABC):
|
|
252
|
+
"""
|
|
253
|
+
Abstract interface for task list storage.
|
|
254
|
+
|
|
255
|
+
Task stores handle task lists and their state for tracking
|
|
256
|
+
agent progress on complex work.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
@abstractmethod
|
|
260
|
+
async def save(self, task_list: TaskList, scope: Scope = Scope.PROJECT) -> None:
|
|
261
|
+
"""Save or update a task list."""
|
|
262
|
+
...
|
|
263
|
+
|
|
264
|
+
@abstractmethod
|
|
265
|
+
async def get(self, task_list_id: UUID, scope: Scope = Scope.PROJECT) -> Optional[TaskList]:
|
|
266
|
+
"""Get a task list by ID."""
|
|
267
|
+
...
|
|
268
|
+
|
|
269
|
+
@abstractmethod
|
|
270
|
+
async def delete(self, task_list_id: UUID, scope: Scope = Scope.PROJECT) -> bool:
|
|
271
|
+
"""Delete a task list. Returns True if it existed."""
|
|
272
|
+
...
|
|
273
|
+
|
|
274
|
+
@abstractmethod
|
|
275
|
+
async def get_by_conversation(
|
|
276
|
+
self,
|
|
277
|
+
conversation_id: UUID,
|
|
278
|
+
scope: Scope = Scope.PROJECT,
|
|
279
|
+
) -> Optional[TaskList]:
|
|
280
|
+
"""Get the task list associated with a conversation."""
|
|
281
|
+
...
|
|
282
|
+
|
|
283
|
+
@abstractmethod
|
|
284
|
+
async def update_task(
|
|
285
|
+
self,
|
|
286
|
+
task_list_id: UUID,
|
|
287
|
+
task_id: UUID,
|
|
288
|
+
state: Optional[TaskState] = None,
|
|
289
|
+
name: Optional[str] = None,
|
|
290
|
+
description: Optional[str] = None,
|
|
291
|
+
scope: Scope = Scope.PROJECT,
|
|
292
|
+
) -> None:
|
|
293
|
+
"""Update a specific task in a task list."""
|
|
294
|
+
...
|
|
295
|
+
|
|
296
|
+
async def close(self) -> None:
|
|
297
|
+
"""Close any connections. Override if needed."""
|
|
298
|
+
pass
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class PreferencesStore(ABC):
|
|
302
|
+
"""
|
|
303
|
+
Abstract interface for preferences storage.
|
|
304
|
+
|
|
305
|
+
Preferences stores handle user and agent configuration
|
|
306
|
+
that persists across sessions.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
@abstractmethod
|
|
310
|
+
async def get(self, key: str, scope: Scope = Scope.GLOBAL) -> Optional[Any]:
|
|
311
|
+
"""Get a preference value."""
|
|
312
|
+
...
|
|
313
|
+
|
|
314
|
+
@abstractmethod
|
|
315
|
+
async def set(self, key: str, value: Any, scope: Scope = Scope.GLOBAL) -> None:
|
|
316
|
+
"""Set a preference value."""
|
|
317
|
+
...
|
|
318
|
+
|
|
319
|
+
@abstractmethod
|
|
320
|
+
async def delete(self, key: str, scope: Scope = Scope.GLOBAL) -> bool:
|
|
321
|
+
"""Delete a preference. Returns True if it existed."""
|
|
322
|
+
...
|
|
323
|
+
|
|
324
|
+
@abstractmethod
|
|
325
|
+
async def get_all(self, scope: Scope = Scope.GLOBAL) -> dict[str, Any]:
|
|
326
|
+
"""Get all preferences in the given scope."""
|
|
327
|
+
...
|
|
328
|
+
|
|
329
|
+
async def close(self) -> None:
|
|
330
|
+
"""Close any connections. Override if needed."""
|
|
331
|
+
pass
|
|
332
|
+
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File-based implementations of persistence stores.
|
|
3
|
+
|
|
4
|
+
These implementations store data in hidden directories:
|
|
5
|
+
- Global: ~/.agent_runtime/
|
|
6
|
+
- Project: ./.agent_runtime/
|
|
7
|
+
|
|
8
|
+
Data is stored as JSON files for easy inspection and debugging.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
from uuid import UUID
|
|
17
|
+
|
|
18
|
+
from agent_runtime.persistence.base import (
|
|
19
|
+
MemoryStore,
|
|
20
|
+
ConversationStore,
|
|
21
|
+
TaskStore,
|
|
22
|
+
PreferencesStore,
|
|
23
|
+
Scope,
|
|
24
|
+
Conversation,
|
|
25
|
+
ConversationMessage,
|
|
26
|
+
ToolCall,
|
|
27
|
+
ToolResult,
|
|
28
|
+
TaskList,
|
|
29
|
+
Task,
|
|
30
|
+
TaskState,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_base_path(scope: Scope, project_dir: Optional[Path] = None) -> Path:
|
|
35
|
+
"""Get the base path for a given scope."""
|
|
36
|
+
if scope == Scope.GLOBAL:
|
|
37
|
+
return Path.home() / ".agent_runtime"
|
|
38
|
+
elif scope == Scope.PROJECT:
|
|
39
|
+
base = project_dir or Path.cwd()
|
|
40
|
+
return base / ".agent_runtime"
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError(f"Cannot get path for scope: {scope}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ensure_dir(path: Path) -> None:
|
|
46
|
+
"""Ensure a directory exists."""
|
|
47
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _JSONEncoder(json.JSONEncoder):
|
|
51
|
+
"""Custom JSON encoder for our data types."""
|
|
52
|
+
|
|
53
|
+
def default(self, obj):
|
|
54
|
+
if isinstance(obj, UUID):
|
|
55
|
+
return str(obj)
|
|
56
|
+
if isinstance(obj, datetime):
|
|
57
|
+
return obj.isoformat()
|
|
58
|
+
if isinstance(obj, TaskState):
|
|
59
|
+
return obj.value
|
|
60
|
+
if hasattr(obj, '__dataclass_fields__'):
|
|
61
|
+
return {k: getattr(obj, k) for k in obj.__dataclass_fields__}
|
|
62
|
+
return super().default(obj)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _json_dumps(obj: Any) -> str:
|
|
66
|
+
"""Serialize object to JSON string."""
|
|
67
|
+
return json.dumps(obj, cls=_JSONEncoder, indent=2)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_datetime(value: Any) -> datetime:
|
|
71
|
+
"""Parse a datetime from string or return as-is."""
|
|
72
|
+
if isinstance(value, datetime):
|
|
73
|
+
return value
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
return datetime.fromisoformat(value)
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse_uuid(value: Any) -> UUID:
|
|
80
|
+
"""Parse a UUID from string or return as-is."""
|
|
81
|
+
if isinstance(value, UUID):
|
|
82
|
+
return value
|
|
83
|
+
if isinstance(value, str):
|
|
84
|
+
return UUID(value)
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class FileMemoryStore(MemoryStore):
|
|
89
|
+
"""
|
|
90
|
+
File-based memory store.
|
|
91
|
+
|
|
92
|
+
Stores key-value pairs in JSON files:
|
|
93
|
+
- {base_path}/memory/{key}.json
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, project_dir: Optional[Path] = None):
|
|
97
|
+
self._project_dir = project_dir
|
|
98
|
+
|
|
99
|
+
def _get_memory_path(self, scope: Scope) -> Path:
|
|
100
|
+
return _get_base_path(scope, self._project_dir) / "memory"
|
|
101
|
+
|
|
102
|
+
def _get_key_path(self, key: str, scope: Scope) -> Path:
|
|
103
|
+
# Sanitize key for filesystem
|
|
104
|
+
safe_key = key.replace("/", "_").replace("\\", "_")
|
|
105
|
+
return self._get_memory_path(scope) / f"{safe_key}.json"
|
|
106
|
+
|
|
107
|
+
async def get(self, key: str, scope: Scope = Scope.PROJECT) -> Optional[Any]:
|
|
108
|
+
path = self._get_key_path(key, scope)
|
|
109
|
+
if not path.exists():
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
with open(path, "r") as f:
|
|
113
|
+
data = json.load(f)
|
|
114
|
+
return data.get("value")
|
|
115
|
+
except (json.JSONDecodeError, IOError):
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
async def set(self, key: str, value: Any, scope: Scope = Scope.PROJECT) -> None:
|
|
119
|
+
path = self._get_key_path(key, scope)
|
|
120
|
+
_ensure_dir(path.parent)
|
|
121
|
+
with open(path, "w") as f:
|
|
122
|
+
f.write(_json_dumps({
|
|
123
|
+
"key": key,
|
|
124
|
+
"value": value,
|
|
125
|
+
"updated_at": datetime.utcnow(),
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
async def delete(self, key: str, scope: Scope = Scope.PROJECT) -> bool:
|
|
129
|
+
path = self._get_key_path(key, scope)
|
|
130
|
+
if path.exists():
|
|
131
|
+
path.unlink()
|
|
132
|
+
return True
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
async def list_keys(self, scope: Scope = Scope.PROJECT, prefix: Optional[str] = None) -> list[str]:
|
|
136
|
+
memory_path = self._get_memory_path(scope)
|
|
137
|
+
if not memory_path.exists():
|
|
138
|
+
return []
|
|
139
|
+
keys = []
|
|
140
|
+
for file in memory_path.glob("*.json"):
|
|
141
|
+
key = file.stem
|
|
142
|
+
if prefix is None or key.startswith(prefix):
|
|
143
|
+
keys.append(key)
|
|
144
|
+
return sorted(keys)
|
|
145
|
+
|
|
146
|
+
async def clear(self, scope: Scope = Scope.PROJECT) -> None:
|
|
147
|
+
memory_path = self._get_memory_path(scope)
|
|
148
|
+
if memory_path.exists():
|
|
149
|
+
for file in memory_path.glob("*.json"):
|
|
150
|
+
file.unlink()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class FileConversationStore(ConversationStore):
|
|
154
|
+
"""
|
|
155
|
+
File-based conversation store.
|
|
156
|
+
|
|
157
|
+
Stores conversations in JSON files:
|
|
158
|
+
- {base_path}/conversations/{conversation_id}.json
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(self, project_dir: Optional[Path] = None):
|
|
162
|
+
self._project_dir = project_dir
|
|
163
|
+
|
|
164
|
+
def _get_conversations_path(self, scope: Scope) -> Path:
|
|
165
|
+
return _get_base_path(scope, self._project_dir) / "conversations"
|
|
166
|
+
|
|
167
|
+
def _get_conversation_path(self, conversation_id: UUID, scope: Scope) -> Path:
|
|
168
|
+
return self._get_conversations_path(scope) / f"{conversation_id}.json"
|
|
169
|
+
|
|
170
|
+
def _serialize_conversation(self, conversation: Conversation) -> dict:
|
|
171
|
+
"""Serialize a conversation to a dict."""
|
|
172
|
+
return {
|
|
173
|
+
"id": str(conversation.id),
|
|
174
|
+
"title": conversation.title,
|
|
175
|
+
"messages": [self._serialize_message(m) for m in conversation.messages],
|
|
176
|
+
"created_at": conversation.created_at.isoformat(),
|
|
177
|
+
"updated_at": conversation.updated_at.isoformat(),
|
|
178
|
+
"metadata": conversation.metadata,
|
|
179
|
+
"agent_key": conversation.agent_key,
|
|
180
|
+
"summary": conversation.summary,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
def _serialize_message(self, message: ConversationMessage) -> dict:
|
|
184
|
+
"""Serialize a message to a dict."""
|
|
185
|
+
return {
|
|
186
|
+
"id": str(message.id),
|
|
187
|
+
"role": message.role,
|
|
188
|
+
"content": message.content,
|
|
189
|
+
"timestamp": message.timestamp.isoformat(),
|
|
190
|
+
"tool_calls": [
|
|
191
|
+
{
|
|
192
|
+
"id": tc.id,
|
|
193
|
+
"name": tc.name,
|
|
194
|
+
"arguments": tc.arguments,
|
|
195
|
+
"timestamp": tc.timestamp.isoformat(),
|
|
196
|
+
}
|
|
197
|
+
for tc in message.tool_calls
|
|
198
|
+
],
|
|
199
|
+
"tool_call_id": message.tool_call_id,
|
|
200
|
+
"model": message.model,
|
|
201
|
+
"usage": message.usage,
|
|
202
|
+
"metadata": message.metadata,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
def _deserialize_conversation(self, data: dict) -> Conversation:
|
|
206
|
+
"""Deserialize a conversation from a dict."""
|
|
207
|
+
return Conversation(
|
|
208
|
+
id=_parse_uuid(data["id"]),
|
|
209
|
+
title=data.get("title"),
|
|
210
|
+
messages=[self._deserialize_message(m) for m in data.get("messages", [])],
|
|
211
|
+
created_at=_parse_datetime(data["created_at"]),
|
|
212
|
+
updated_at=_parse_datetime(data["updated_at"]),
|
|
213
|
+
metadata=data.get("metadata", {}),
|
|
214
|
+
agent_key=data.get("agent_key"),
|
|
215
|
+
summary=data.get("summary"),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _deserialize_message(self, data: dict) -> ConversationMessage:
|
|
219
|
+
"""Deserialize a message from a dict."""
|
|
220
|
+
return ConversationMessage(
|
|
221
|
+
id=_parse_uuid(data["id"]),
|
|
222
|
+
role=data["role"],
|
|
223
|
+
content=data["content"],
|
|
224
|
+
timestamp=_parse_datetime(data["timestamp"]),
|
|
225
|
+
tool_calls=[
|
|
226
|
+
ToolCall(
|
|
227
|
+
id=tc["id"],
|
|
228
|
+
name=tc["name"],
|
|
229
|
+
arguments=tc["arguments"],
|
|
230
|
+
timestamp=_parse_datetime(tc["timestamp"]),
|
|
231
|
+
)
|
|
232
|
+
for tc in data.get("tool_calls", [])
|
|
233
|
+
],
|
|
234
|
+
tool_call_id=data.get("tool_call_id"),
|
|
235
|
+
model=data.get("model"),
|
|
236
|
+
usage=data.get("usage", {}),
|
|
237
|
+
metadata=data.get("metadata", {}),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
async def save(self, conversation: Conversation, scope: Scope = Scope.PROJECT) -> None:
|
|
241
|
+
path = self._get_conversation_path(conversation.id, scope)
|
|
242
|
+
_ensure_dir(path.parent)
|
|
243
|
+
conversation.updated_at = datetime.utcnow()
|
|
244
|
+
with open(path, "w") as f:
|
|
245
|
+
f.write(_json_dumps(self._serialize_conversation(conversation)))
|
|
246
|
+
|
|
247
|
+
async def get(self, conversation_id: UUID, scope: Scope = Scope.PROJECT) -> Optional[Conversation]:
|
|
248
|
+
path = self._get_conversation_path(conversation_id, scope)
|
|
249
|
+
if not path.exists():
|
|
250
|
+
return None
|
|
251
|
+
try:
|
|
252
|
+
with open(path, "r") as f:
|
|
253
|
+
data = json.load(f)
|
|
254
|
+
return self._deserialize_conversation(data)
|
|
255
|
+
except (json.JSONDecodeError, IOError):
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
async def delete(self, conversation_id: UUID, scope: Scope = Scope.PROJECT) -> bool:
|
|
259
|
+
path = self._get_conversation_path(conversation_id, scope)
|
|
260
|
+
if path.exists():
|
|
261
|
+
path.unlink()
|
|
262
|
+
return True
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
async def list_conversations(
|
|
266
|
+
self,
|
|
267
|
+
scope: Scope = Scope.PROJECT,
|
|
268
|
+
limit: int = 100,
|
|
269
|
+
offset: int = 0,
|
|
270
|
+
agent_key: Optional[str] = None,
|
|
271
|
+
) -> list[Conversation]:
|
|
272
|
+
conversations_path = self._get_conversations_path(scope)
|
|
273
|
+
if not conversations_path.exists():
|
|
274
|
+
return []
|
|
275
|
+
|
|
276
|
+
conversations = []
|
|
277
|
+
for file in conversations_path.glob("*.json"):
|
|
278
|
+
try:
|
|
279
|
+
with open(file, "r") as f:
|
|
280
|
+
data = json.load(f)
|
|
281
|
+
conv = self._deserialize_conversation(data)
|
|
282
|
+
if agent_key is None or conv.agent_key == agent_key:
|
|
283
|
+
conversations.append(conv)
|
|
284
|
+
except (json.JSONDecodeError, IOError):
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
# Sort by updated_at descending
|
|
288
|
+
conversations.sort(key=lambda c: c.updated_at, reverse=True)
|
|
289
|
+
return conversations[offset:offset + limit]
|
|
290
|
+
|
|
291
|
+
async def add_message(
|
|
292
|
+
self,
|
|
293
|
+
conversation_id: UUID,
|
|
294
|
+
message: ConversationMessage,
|
|
295
|
+
scope: Scope = Scope.PROJECT,
|
|
296
|
+
) -> None:
|
|
297
|
+
conversation = await self.get(conversation_id, scope)
|
|
298
|
+
if conversation is None:
|
|
299
|
+
raise ValueError(f"Conversation not found: {conversation_id}")
|
|
300
|
+
conversation.messages.append(message)
|
|
301
|
+
await self.save(conversation, scope)
|
|
302
|
+
|
|
303
|
+
async def get_messages(
|
|
304
|
+
self,
|
|
305
|
+
conversation_id: UUID,
|
|
306
|
+
scope: Scope = Scope.PROJECT,
|
|
307
|
+
limit: Optional[int] = None,
|
|
308
|
+
before: Optional[datetime] = None,
|
|
309
|
+
) -> list[ConversationMessage]:
|
|
310
|
+
conversation = await self.get(conversation_id, scope)
|
|
311
|
+
if conversation is None:
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
messages = conversation.messages
|
|
315
|
+
if before:
|
|
316
|
+
messages = [m for m in messages if m.timestamp < before]
|
|
317
|
+
if limit:
|
|
318
|
+
messages = messages[-limit:]
|
|
319
|
+
return messages
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class FileTaskStore(TaskStore):
|
|
323
|
+
"""
|
|
324
|
+
File-based task store.
|
|
325
|
+
|
|
326
|
+
Stores task lists in JSON files:
|
|
327
|
+
- {base_path}/tasks/{task_list_id}.json
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
def __init__(self, project_dir: Optional[Path] = None):
|
|
331
|
+
self._project_dir = project_dir
|
|
332
|
+
|
|
333
|
+
def _get_tasks_path(self, scope: Scope) -> Path:
|
|
334
|
+
return _get_base_path(scope, self._project_dir) / "tasks"
|
|
335
|
+
|
|
336
|
+
def _get_task_list_path(self, task_list_id: UUID, scope: Scope) -> Path:
|
|
337
|
+
return self._get_tasks_path(scope) / f"{task_list_id}.json"
|
|
338
|
+
|
|
339
|
+
def _serialize_task_list(self, task_list: TaskList) -> dict:
|
|
340
|
+
return {
|
|
341
|
+
"id": str(task_list.id),
|
|
342
|
+
"name": task_list.name,
|
|
343
|
+
"tasks": [
|
|
344
|
+
{
|
|
345
|
+
"id": str(t.id),
|
|
346
|
+
"name": t.name,
|
|
347
|
+
"description": t.description,
|
|
348
|
+
"state": t.state.value,
|
|
349
|
+
"parent_id": str(t.parent_id) if t.parent_id else None,
|
|
350
|
+
"created_at": t.created_at.isoformat(),
|
|
351
|
+
"updated_at": t.updated_at.isoformat(),
|
|
352
|
+
"metadata": t.metadata,
|
|
353
|
+
}
|
|
354
|
+
for t in task_list.tasks
|
|
355
|
+
],
|
|
356
|
+
"created_at": task_list.created_at.isoformat(),
|
|
357
|
+
"updated_at": task_list.updated_at.isoformat(),
|
|
358
|
+
"conversation_id": str(task_list.conversation_id) if task_list.conversation_id else None,
|
|
359
|
+
"run_id": str(task_list.run_id) if task_list.run_id else None,
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
def _deserialize_task_list(self, data: dict) -> TaskList:
|
|
363
|
+
return TaskList(
|
|
364
|
+
id=_parse_uuid(data["id"]),
|
|
365
|
+
name=data["name"],
|
|
366
|
+
tasks=[
|
|
367
|
+
Task(
|
|
368
|
+
id=_parse_uuid(t["id"]),
|
|
369
|
+
name=t["name"],
|
|
370
|
+
description=t.get("description", ""),
|
|
371
|
+
state=TaskState(t["state"]),
|
|
372
|
+
parent_id=_parse_uuid(t["parent_id"]) if t.get("parent_id") else None,
|
|
373
|
+
created_at=_parse_datetime(t["created_at"]),
|
|
374
|
+
updated_at=_parse_datetime(t["updated_at"]),
|
|
375
|
+
metadata=t.get("metadata", {}),
|
|
376
|
+
)
|
|
377
|
+
for t in data.get("tasks", [])
|
|
378
|
+
],
|
|
379
|
+
created_at=_parse_datetime(data["created_at"]),
|
|
380
|
+
updated_at=_parse_datetime(data["updated_at"]),
|
|
381
|
+
conversation_id=_parse_uuid(data["conversation_id"]) if data.get("conversation_id") else None,
|
|
382
|
+
run_id=_parse_uuid(data["run_id"]) if data.get("run_id") else None,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
async def save(self, task_list: TaskList, scope: Scope = Scope.PROJECT) -> None:
|
|
386
|
+
path = self._get_task_list_path(task_list.id, scope)
|
|
387
|
+
_ensure_dir(path.parent)
|
|
388
|
+
task_list.updated_at = datetime.utcnow()
|
|
389
|
+
with open(path, "w") as f:
|
|
390
|
+
f.write(_json_dumps(self._serialize_task_list(task_list)))
|
|
391
|
+
|
|
392
|
+
async def get(self, task_list_id: UUID, scope: Scope = Scope.PROJECT) -> Optional[TaskList]:
|
|
393
|
+
path = self._get_task_list_path(task_list_id, scope)
|
|
394
|
+
if not path.exists():
|
|
395
|
+
return None
|
|
396
|
+
try:
|
|
397
|
+
with open(path, "r") as f:
|
|
398
|
+
data = json.load(f)
|
|
399
|
+
return self._deserialize_task_list(data)
|
|
400
|
+
except (json.JSONDecodeError, IOError):
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
async def delete(self, task_list_id: UUID, scope: Scope = Scope.PROJECT) -> bool:
|
|
404
|
+
path = self._get_task_list_path(task_list_id, scope)
|
|
405
|
+
if path.exists():
|
|
406
|
+
path.unlink()
|
|
407
|
+
return True
|
|
408
|
+
return False
|
|
409
|
+
|
|
410
|
+
async def get_by_conversation(
|
|
411
|
+
self,
|
|
412
|
+
conversation_id: UUID,
|
|
413
|
+
scope: Scope = Scope.PROJECT,
|
|
414
|
+
) -> Optional[TaskList]:
|
|
415
|
+
tasks_path = self._get_tasks_path(scope)
|
|
416
|
+
if not tasks_path.exists():
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
for file in tasks_path.glob("*.json"):
|
|
420
|
+
try:
|
|
421
|
+
with open(file, "r") as f:
|
|
422
|
+
data = json.load(f)
|
|
423
|
+
if data.get("conversation_id") == str(conversation_id):
|
|
424
|
+
return self._deserialize_task_list(data)
|
|
425
|
+
except (json.JSONDecodeError, IOError):
|
|
426
|
+
continue
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
async def update_task(
|
|
430
|
+
self,
|
|
431
|
+
task_list_id: UUID,
|
|
432
|
+
task_id: UUID,
|
|
433
|
+
state: Optional[TaskState] = None,
|
|
434
|
+
name: Optional[str] = None,
|
|
435
|
+
description: Optional[str] = None,
|
|
436
|
+
scope: Scope = Scope.PROJECT,
|
|
437
|
+
) -> None:
|
|
438
|
+
task_list = await self.get(task_list_id, scope)
|
|
439
|
+
if task_list is None:
|
|
440
|
+
raise ValueError(f"Task list not found: {task_list_id}")
|
|
441
|
+
|
|
442
|
+
for task in task_list.tasks:
|
|
443
|
+
if task.id == task_id:
|
|
444
|
+
if state is not None:
|
|
445
|
+
task.state = state
|
|
446
|
+
if name is not None:
|
|
447
|
+
task.name = name
|
|
448
|
+
if description is not None:
|
|
449
|
+
task.description = description
|
|
450
|
+
task.updated_at = datetime.utcnow()
|
|
451
|
+
break
|
|
452
|
+
else:
|
|
453
|
+
raise ValueError(f"Task not found: {task_id}")
|
|
454
|
+
|
|
455
|
+
await self.save(task_list, scope)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class FilePreferencesStore(PreferencesStore):
|
|
460
|
+
"""
|
|
461
|
+
File-based preferences store.
|
|
462
|
+
|
|
463
|
+
Stores preferences in a single JSON file:
|
|
464
|
+
- {base_path}/preferences.json
|
|
465
|
+
"""
|
|
466
|
+
|
|
467
|
+
def __init__(self, project_dir: Optional[Path] = None):
|
|
468
|
+
self._project_dir = project_dir
|
|
469
|
+
|
|
470
|
+
def _get_preferences_path(self, scope: Scope) -> Path:
|
|
471
|
+
return _get_base_path(scope, self._project_dir) / "preferences.json"
|
|
472
|
+
|
|
473
|
+
async def _load_preferences(self, scope: Scope) -> dict:
|
|
474
|
+
path = self._get_preferences_path(scope)
|
|
475
|
+
if not path.exists():
|
|
476
|
+
return {}
|
|
477
|
+
try:
|
|
478
|
+
with open(path, "r") as f:
|
|
479
|
+
return json.load(f)
|
|
480
|
+
except (json.JSONDecodeError, IOError):
|
|
481
|
+
return {}
|
|
482
|
+
|
|
483
|
+
async def _save_preferences(self, preferences: dict, scope: Scope) -> None:
|
|
484
|
+
path = self._get_preferences_path(scope)
|
|
485
|
+
_ensure_dir(path.parent)
|
|
486
|
+
with open(path, "w") as f:
|
|
487
|
+
f.write(_json_dumps(preferences))
|
|
488
|
+
|
|
489
|
+
async def get(self, key: str, scope: Scope = Scope.GLOBAL) -> Optional[Any]:
|
|
490
|
+
preferences = await self._load_preferences(scope)
|
|
491
|
+
return preferences.get(key)
|
|
492
|
+
|
|
493
|
+
async def set(self, key: str, value: Any, scope: Scope = Scope.GLOBAL) -> None:
|
|
494
|
+
preferences = await self._load_preferences(scope)
|
|
495
|
+
preferences[key] = value
|
|
496
|
+
await self._save_preferences(preferences, scope)
|
|
497
|
+
|
|
498
|
+
async def delete(self, key: str, scope: Scope = Scope.GLOBAL) -> bool:
|
|
499
|
+
preferences = await self._load_preferences(scope)
|
|
500
|
+
if key in preferences:
|
|
501
|
+
del preferences[key]
|
|
502
|
+
await self._save_preferences(preferences, scope)
|
|
503
|
+
return True
|
|
504
|
+
return False
|
|
505
|
+
|
|
506
|
+
async def get_all(self, scope: Scope = Scope.GLOBAL) -> dict[str, Any]:
|
|
507
|
+
return await self._load_preferences(scope)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Persistence manager for coordinating storage backends.
|
|
3
|
+
|
|
4
|
+
The PersistenceManager provides a unified interface for accessing
|
|
5
|
+
all persistence stores with configurable backends.
|
|
6
|
+
|
|
7
|
+
For Django integration, you can either:
|
|
8
|
+
1. Pass pre-instantiated store instances
|
|
9
|
+
2. Pass store classes with appropriate kwargs
|
|
10
|
+
3. Use factory functions for request-scoped stores
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable, Optional, Type, Union
|
|
16
|
+
|
|
17
|
+
from agent_runtime.persistence.base import (
|
|
18
|
+
MemoryStore,
|
|
19
|
+
ConversationStore,
|
|
20
|
+
TaskStore,
|
|
21
|
+
PreferencesStore,
|
|
22
|
+
Scope,
|
|
23
|
+
)
|
|
24
|
+
from agent_runtime.persistence.file import (
|
|
25
|
+
FileMemoryStore,
|
|
26
|
+
FileConversationStore,
|
|
27
|
+
FileTaskStore,
|
|
28
|
+
FilePreferencesStore,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Type aliases for store factories
|
|
33
|
+
MemoryStoreFactory = Callable[[], MemoryStore]
|
|
34
|
+
ConversationStoreFactory = Callable[[], ConversationStore]
|
|
35
|
+
TaskStoreFactory = Callable[[], TaskStore]
|
|
36
|
+
PreferencesStoreFactory = Callable[[], PreferencesStore]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PersistenceConfig:
|
|
41
|
+
"""
|
|
42
|
+
Configuration for persistence backends.
|
|
43
|
+
|
|
44
|
+
Each store can be configured as:
|
|
45
|
+
- A class (will be instantiated with store_kwargs)
|
|
46
|
+
- A pre-instantiated store instance
|
|
47
|
+
- A factory function that returns a store instance
|
|
48
|
+
|
|
49
|
+
Example for Django:
|
|
50
|
+
from myapp.stores import DjangoMemoryStore, DjangoConversationStore
|
|
51
|
+
|
|
52
|
+
# Option 1: Pass classes with kwargs
|
|
53
|
+
config = PersistenceConfig(
|
|
54
|
+
memory_store_class=DjangoMemoryStore,
|
|
55
|
+
memory_store_kwargs={"user": request.user},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Option 2: Pass pre-instantiated stores
|
|
59
|
+
config = PersistenceConfig(
|
|
60
|
+
memory_store=DjangoMemoryStore(user=request.user),
|
|
61
|
+
conversation_store=DjangoConversationStore(user=request.user),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Option 3: Pass factory functions
|
|
65
|
+
config = PersistenceConfig(
|
|
66
|
+
memory_store_factory=lambda: DjangoMemoryStore(user=get_current_user()),
|
|
67
|
+
)
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
# Backend classes (can be swapped for custom implementations)
|
|
71
|
+
memory_store_class: Type[MemoryStore] = FileMemoryStore
|
|
72
|
+
conversation_store_class: Type[ConversationStore] = FileConversationStore
|
|
73
|
+
task_store_class: Type[TaskStore] = FileTaskStore
|
|
74
|
+
preferences_store_class: Type[PreferencesStore] = FilePreferencesStore
|
|
75
|
+
|
|
76
|
+
# Pre-instantiated store instances (takes precedence over classes)
|
|
77
|
+
memory_store: Optional[MemoryStore] = None
|
|
78
|
+
conversation_store: Optional[ConversationStore] = None
|
|
79
|
+
task_store: Optional[TaskStore] = None
|
|
80
|
+
preferences_store: Optional[PreferencesStore] = None
|
|
81
|
+
|
|
82
|
+
# Factory functions (takes precedence over classes, but not instances)
|
|
83
|
+
memory_store_factory: Optional[MemoryStoreFactory] = None
|
|
84
|
+
conversation_store_factory: Optional[ConversationStoreFactory] = None
|
|
85
|
+
task_store_factory: Optional[TaskStoreFactory] = None
|
|
86
|
+
preferences_store_factory: Optional[PreferencesStoreFactory] = None
|
|
87
|
+
|
|
88
|
+
# Kwargs passed to store class constructors (only used with classes)
|
|
89
|
+
memory_store_kwargs: dict = field(default_factory=dict)
|
|
90
|
+
conversation_store_kwargs: dict = field(default_factory=dict)
|
|
91
|
+
task_store_kwargs: dict = field(default_factory=dict)
|
|
92
|
+
preferences_store_kwargs: dict = field(default_factory=dict)
|
|
93
|
+
|
|
94
|
+
# Project directory (convenience for file-based stores)
|
|
95
|
+
# Only used if store_kwargs doesn't already have project_dir
|
|
96
|
+
project_dir: Optional[Path] = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PersistenceManager:
|
|
100
|
+
"""
|
|
101
|
+
Unified manager for all persistence stores.
|
|
102
|
+
|
|
103
|
+
Provides access to memory, conversations, tasks, and preferences
|
|
104
|
+
with pluggable backends.
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
# Use default file-based storage
|
|
108
|
+
manager = PersistenceManager()
|
|
109
|
+
|
|
110
|
+
# Store global memory
|
|
111
|
+
await manager.memory.set("user_name", "Alice", scope=Scope.GLOBAL)
|
|
112
|
+
|
|
113
|
+
# Store project-specific memory
|
|
114
|
+
await manager.memory.set("project_type", "python", scope=Scope.PROJECT)
|
|
115
|
+
|
|
116
|
+
# Save a conversation
|
|
117
|
+
await manager.conversations.save(conversation)
|
|
118
|
+
|
|
119
|
+
# Use custom backends (Django example)
|
|
120
|
+
config = PersistenceConfig(
|
|
121
|
+
memory_store=DjangoMemoryStore(user=request.user),
|
|
122
|
+
conversation_store=DjangoConversationStore(user=request.user),
|
|
123
|
+
)
|
|
124
|
+
manager = PersistenceManager(config)
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, config: Optional[PersistenceConfig] = None):
|
|
128
|
+
self._config = config or PersistenceConfig()
|
|
129
|
+
self._memory: Optional[MemoryStore] = None
|
|
130
|
+
self._conversations: Optional[ConversationStore] = None
|
|
131
|
+
self._tasks: Optional[TaskStore] = None
|
|
132
|
+
self._preferences: Optional[PreferencesStore] = None
|
|
133
|
+
|
|
134
|
+
def _build_kwargs(self, store_kwargs: dict) -> dict:
|
|
135
|
+
"""Build kwargs for store instantiation."""
|
|
136
|
+
kwargs = {}
|
|
137
|
+
# Only add project_dir if not already in store_kwargs
|
|
138
|
+
if self._config.project_dir and "project_dir" not in store_kwargs:
|
|
139
|
+
kwargs["project_dir"] = self._config.project_dir
|
|
140
|
+
kwargs.update(store_kwargs)
|
|
141
|
+
return kwargs
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def memory(self) -> MemoryStore:
|
|
145
|
+
"""Get the memory store."""
|
|
146
|
+
if self._memory is None:
|
|
147
|
+
# Priority: instance > factory > class
|
|
148
|
+
if self._config.memory_store is not None:
|
|
149
|
+
self._memory = self._config.memory_store
|
|
150
|
+
elif self._config.memory_store_factory is not None:
|
|
151
|
+
self._memory = self._config.memory_store_factory()
|
|
152
|
+
else:
|
|
153
|
+
kwargs = self._build_kwargs(self._config.memory_store_kwargs)
|
|
154
|
+
self._memory = self._config.memory_store_class(**kwargs)
|
|
155
|
+
return self._memory
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def conversations(self) -> ConversationStore:
|
|
159
|
+
"""Get the conversation store."""
|
|
160
|
+
if self._conversations is None:
|
|
161
|
+
if self._config.conversation_store is not None:
|
|
162
|
+
self._conversations = self._config.conversation_store
|
|
163
|
+
elif self._config.conversation_store_factory is not None:
|
|
164
|
+
self._conversations = self._config.conversation_store_factory()
|
|
165
|
+
else:
|
|
166
|
+
kwargs = self._build_kwargs(self._config.conversation_store_kwargs)
|
|
167
|
+
self._conversations = self._config.conversation_store_class(**kwargs)
|
|
168
|
+
return self._conversations
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def tasks(self) -> TaskStore:
|
|
172
|
+
"""Get the task store."""
|
|
173
|
+
if self._tasks is None:
|
|
174
|
+
if self._config.task_store is not None:
|
|
175
|
+
self._tasks = self._config.task_store
|
|
176
|
+
elif self._config.task_store_factory is not None:
|
|
177
|
+
self._tasks = self._config.task_store_factory()
|
|
178
|
+
else:
|
|
179
|
+
kwargs = self._build_kwargs(self._config.task_store_kwargs)
|
|
180
|
+
self._tasks = self._config.task_store_class(**kwargs)
|
|
181
|
+
return self._tasks
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def preferences(self) -> PreferencesStore:
|
|
185
|
+
"""Get the preferences store."""
|
|
186
|
+
if self._preferences is None:
|
|
187
|
+
if self._config.preferences_store is not None:
|
|
188
|
+
self._preferences = self._config.preferences_store
|
|
189
|
+
elif self._config.preferences_store_factory is not None:
|
|
190
|
+
self._preferences = self._config.preferences_store_factory()
|
|
191
|
+
else:
|
|
192
|
+
kwargs = self._build_kwargs(self._config.preferences_store_kwargs)
|
|
193
|
+
self._preferences = self._config.preferences_store_class(**kwargs)
|
|
194
|
+
return self._preferences
|
|
195
|
+
|
|
196
|
+
async def close(self) -> None:
|
|
197
|
+
"""Close all stores."""
|
|
198
|
+
if self._memory:
|
|
199
|
+
await self._memory.close()
|
|
200
|
+
if self._conversations:
|
|
201
|
+
await self._conversations.close()
|
|
202
|
+
if self._tasks:
|
|
203
|
+
await self._tasks.close()
|
|
204
|
+
if self._preferences:
|
|
205
|
+
await self._preferences.close()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# Global manager instance
|
|
209
|
+
_manager: Optional[PersistenceManager] = None
|
|
210
|
+
_config: Optional[PersistenceConfig] = None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def configure_persistence(
|
|
214
|
+
memory_store_class: Optional[Type[MemoryStore]] = None,
|
|
215
|
+
conversation_store_class: Optional[Type[ConversationStore]] = None,
|
|
216
|
+
task_store_class: Optional[Type[TaskStore]] = None,
|
|
217
|
+
preferences_store_class: Optional[Type[PreferencesStore]] = None,
|
|
218
|
+
project_dir: Optional[Path] = None,
|
|
219
|
+
**kwargs,
|
|
220
|
+
) -> PersistenceConfig:
|
|
221
|
+
"""
|
|
222
|
+
Configure the global persistence manager.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
memory_store_class: Custom memory store implementation
|
|
226
|
+
conversation_store_class: Custom conversation store implementation
|
|
227
|
+
task_store_class: Custom task store implementation
|
|
228
|
+
preferences_store_class: Custom preferences store implementation
|
|
229
|
+
project_dir: Project directory for PROJECT scope
|
|
230
|
+
**kwargs: Additional store-specific configuration
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
The configured PersistenceConfig
|
|
234
|
+
"""
|
|
235
|
+
global _config, _manager
|
|
236
|
+
|
|
237
|
+
config = PersistenceConfig(project_dir=project_dir)
|
|
238
|
+
|
|
239
|
+
if memory_store_class:
|
|
240
|
+
config.memory_store_class = memory_store_class
|
|
241
|
+
if conversation_store_class:
|
|
242
|
+
config.conversation_store_class = conversation_store_class
|
|
243
|
+
if task_store_class:
|
|
244
|
+
config.task_store_class = task_store_class
|
|
245
|
+
if preferences_store_class:
|
|
246
|
+
config.preferences_store_class = preferences_store_class
|
|
247
|
+
|
|
248
|
+
_config = config
|
|
249
|
+
_manager = None # Reset manager to use new config
|
|
250
|
+
|
|
251
|
+
return config
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def get_persistence_manager() -> PersistenceManager:
|
|
255
|
+
"""
|
|
256
|
+
Get the global persistence manager.
|
|
257
|
+
|
|
258
|
+
Creates a new manager with default config if not configured.
|
|
259
|
+
"""
|
|
260
|
+
global _manager
|
|
261
|
+
|
|
262
|
+
if _manager is None:
|
|
263
|
+
_manager = PersistenceManager(_config)
|
|
264
|
+
|
|
265
|
+
return _manager
|
|
266
|
+
|
|
@@ -37,7 +37,7 @@ class LangfuseTraceSink(TraceSink):
|
|
|
37
37
|
if Langfuse is None:
|
|
38
38
|
raise ImportError(
|
|
39
39
|
"langfuse package is required for LangfuseTraceSink. "
|
|
40
|
-
"Install with: pip install
|
|
40
|
+
"Install with: pip install agent-runtime-core[langfuse]"
|
|
41
41
|
)
|
|
42
42
|
|
|
43
43
|
self._client = Langfuse(
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-runtime-core
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Framework-agnostic Python library for executing AI agents with consistent patterns
|
|
5
|
-
Project-URL: Homepage, https://github.com/colstrom/
|
|
6
|
-
Project-URL: Repository, https://github.com/colstrom/
|
|
5
|
+
Project-URL: Homepage, https://github.com/colstrom/agent_runtime_core
|
|
6
|
+
Project-URL: Repository, https://github.com/colstrom/agent_runtime_core
|
|
7
7
|
Author: Chris Olstrom
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
agent_runtime/__init__.py,sha256=
|
|
1
|
+
agent_runtime/__init__.py,sha256=d-9hrmPS2JmaaDdZRfO5_B1HiJxLU_hMrXY-COXOxxs,3591
|
|
2
2
|
agent_runtime/config.py,sha256=ZRjpILjsjeh_kl7873DtV2g_zaTrfdkb3NgdQ6ndb5Y,4981
|
|
3
3
|
agent_runtime/interfaces.py,sha256=-VGZJHUkyF8kdO-BDkURyc-sLbObIHErIFw1Hzn3n14,10434
|
|
4
4
|
agent_runtime/registry.py,sha256=sa0speDFxFCZlXoCge8cPNqWYUeWHyazs6tBer5Jg1w,1471
|
|
@@ -10,9 +10,13 @@ agent_runtime/events/memory.py,sha256=7qseR6RtdaP833FxEHwyPw5TC7l4brJHr8uEx0mLc1
|
|
|
10
10
|
agent_runtime/events/redis.py,sha256=Z6WEvp_6jcIPi4ZgkGk5J61qxgGqllwk7jqJM4jcTXk,5869
|
|
11
11
|
agent_runtime/events/sqlite.py,sha256=EiX1BMOqeS7BelmD8A6cvhz3fE4w7vJ2Wg4pFu1V2u0,5092
|
|
12
12
|
agent_runtime/llm/__init__.py,sha256=JEk1Q2H6U9_Uid48YVm1wYR1W7po0vtjfjf2TTmQe_A,2431
|
|
13
|
-
agent_runtime/llm/anthropic.py,sha256=
|
|
14
|
-
agent_runtime/llm/litellm_client.py,sha256=
|
|
15
|
-
agent_runtime/llm/openai.py,sha256=
|
|
13
|
+
agent_runtime/llm/anthropic.py,sha256=ho3psMYAARpXyzqejgA1dx6Nk8io0TwCKb0mp_wsGyM,7496
|
|
14
|
+
agent_runtime/llm/litellm_client.py,sha256=Pic3N4CHVoqzdHUbKlizBcuPP0xCKoeY6U2ZjsZIgWg,5183
|
|
15
|
+
agent_runtime/llm/openai.py,sha256=cRk4WBpiVknqsy_cgPAGdC8Zj250nasqo1dNQN0uxlw,6842
|
|
16
|
+
agent_runtime/persistence/__init__.py,sha256=YynbxYtCtaSurQS9Ikenj9n_4xSEh0T9K0kOpg3IW04,2009
|
|
17
|
+
agent_runtime/persistence/base.py,sha256=7p9HYWYY4pjmDvQgLh11Fie_XmMnkimkCG7tUQt0zUQ,9444
|
|
18
|
+
agent_runtime/persistence/file.py,sha256=uMNSNfXvHPMJzl3QTrU-CPt1cLCH8J9rkaThbq7kLf0,17780
|
|
19
|
+
agent_runtime/persistence/manager.py,sha256=PP-imx6ygIlUSID8rMwR9CTeD02HjZ3mdqczgZvF1es,9682
|
|
16
20
|
agent_runtime/queue/__init__.py,sha256=78k29iEl8brp71LrOnmTHhQzPMHkzGre-Xqdl1NlNr0,1502
|
|
17
21
|
agent_runtime/queue/base.py,sha256=QW1eWbwBX_tmVD8yJobFJtlxLd_RtUWHTuXGessuxy8,3959
|
|
18
22
|
agent_runtime/queue/memory.py,sha256=n7kiE0Fw_BFUdzMyoO1QPO0ATzz8zBYaMQex7GdceZw,5411
|
|
@@ -24,9 +28,9 @@ agent_runtime/state/memory.py,sha256=xOnlqM3ArXDKAdPx3PxdS9IGgJDSM-EKp_S1Hsit180
|
|
|
24
28
|
agent_runtime/state/redis.py,sha256=-lPi_2xKm7Bc4DVMJfSEAF7wJHctLV3ZMM9AYBeQKZU,3425
|
|
25
29
|
agent_runtime/state/sqlite.py,sha256=NwuiTBXELb2tyOoH91MZqRJaCk9h8PskyY2VUc5EMr0,4868
|
|
26
30
|
agent_runtime/tracing/__init__.py,sha256=m4WzfgJpnV5XCCoMpBYZdJU_JTkAdhEhl7M7tpf62RY,1246
|
|
27
|
-
agent_runtime/tracing/langfuse.py,sha256=
|
|
31
|
+
agent_runtime/tracing/langfuse.py,sha256=Z-2eEUHlxCC82JtXOAaoi-1zI6tQwEOWdpJgfCXcZH0,3655
|
|
28
32
|
agent_runtime/tracing/noop.py,sha256=MOm5eTrnf3d4WhiWrwVU5Kd3GmJ1903V0U7U3Qwho7U,746
|
|
29
|
-
agent_runtime_core-0.1.
|
|
30
|
-
agent_runtime_core-0.1.
|
|
31
|
-
agent_runtime_core-0.1.
|
|
32
|
-
agent_runtime_core-0.1.
|
|
33
|
+
agent_runtime_core-0.2.1.dist-info/METADATA,sha256=_YZWAHr9zovJKJ0d0Xhk04lPfU9PeaN9gmUFFvaQM58,12488
|
|
34
|
+
agent_runtime_core-0.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
35
|
+
agent_runtime_core-0.2.1.dist-info/licenses/LICENSE,sha256=PcOO8aiOZ4H2MWYeKIis3o6xTCT1hNkDyCxHZhh1NeM,1070
|
|
36
|
+
agent_runtime_core-0.2.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|