agent-runtime-core 0.1.5__py3-none-any.whl → 0.3.0__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.
Files changed (37) hide show
  1. {agent_runtime → agent_runtime_core}/__init__.py +59 -7
  2. {agent_runtime → agent_runtime_core}/config.py +1 -1
  3. {agent_runtime → agent_runtime_core}/events/__init__.py +5 -5
  4. {agent_runtime → agent_runtime_core}/events/memory.py +1 -1
  5. {agent_runtime → agent_runtime_core}/events/redis.py +1 -1
  6. {agent_runtime → agent_runtime_core}/events/sqlite.py +1 -1
  7. {agent_runtime → agent_runtime_core}/llm/__init__.py +6 -6
  8. {agent_runtime → agent_runtime_core}/llm/anthropic.py +4 -4
  9. {agent_runtime → agent_runtime_core}/llm/litellm_client.py +2 -2
  10. {agent_runtime → agent_runtime_core}/llm/openai.py +4 -4
  11. agent_runtime_core/persistence/__init__.py +88 -0
  12. agent_runtime_core/persistence/base.py +332 -0
  13. agent_runtime_core/persistence/file.py +507 -0
  14. agent_runtime_core/persistence/manager.py +266 -0
  15. {agent_runtime → agent_runtime_core}/queue/__init__.py +5 -5
  16. {agent_runtime → agent_runtime_core}/queue/memory.py +1 -1
  17. {agent_runtime → agent_runtime_core}/queue/redis.py +1 -1
  18. {agent_runtime → agent_runtime_core}/queue/sqlite.py +1 -1
  19. {agent_runtime → agent_runtime_core}/registry.py +1 -1
  20. {agent_runtime → agent_runtime_core}/runner.py +6 -6
  21. {agent_runtime → agent_runtime_core}/state/__init__.py +5 -5
  22. {agent_runtime → agent_runtime_core}/state/memory.py +1 -1
  23. {agent_runtime → agent_runtime_core}/state/redis.py +1 -1
  24. {agent_runtime → agent_runtime_core}/state/sqlite.py +1 -1
  25. {agent_runtime → agent_runtime_core}/testing.py +1 -1
  26. {agent_runtime → agent_runtime_core}/tracing/__init__.py +4 -4
  27. {agent_runtime → agent_runtime_core}/tracing/langfuse.py +1 -1
  28. {agent_runtime → agent_runtime_core}/tracing/noop.py +1 -1
  29. {agent_runtime_core-0.1.5.dist-info → agent_runtime_core-0.3.0.dist-info}/METADATA +1 -1
  30. agent_runtime_core-0.3.0.dist-info/RECORD +36 -0
  31. agent_runtime_core-0.1.5.dist-info/RECORD +0 -32
  32. {agent_runtime → agent_runtime_core}/events/base.py +0 -0
  33. {agent_runtime → agent_runtime_core}/interfaces.py +0 -0
  34. {agent_runtime → agent_runtime_core}/queue/base.py +0 -0
  35. {agent_runtime → agent_runtime_core}/state/base.py +0 -0
  36. {agent_runtime_core-0.1.5.dist-info → agent_runtime_core-0.3.0.dist-info}/WHEEL +0 -0
  37. {agent_runtime_core-0.1.5.dist-info → agent_runtime_core-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -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_core.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)