@elizaos/python 2.0.0-alpha.10 → 2.0.0-alpha.26
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.
- package/elizaos/__init__.py +0 -1
- package/elizaos/advanced_capabilities/__init__.py +6 -41
- package/elizaos/advanced_capabilities/actions/__init__.py +1 -21
- package/elizaos/advanced_capabilities/actions/add_contact.py +21 -11
- package/elizaos/advanced_capabilities/actions/follow_room.py +28 -28
- package/elizaos/advanced_capabilities/actions/image_generation.py +13 -26
- package/elizaos/advanced_capabilities/actions/mute_room.py +13 -26
- package/elizaos/advanced_capabilities/actions/remove_contact.py +16 -2
- package/elizaos/advanced_capabilities/actions/roles.py +13 -27
- package/elizaos/advanced_capabilities/actions/schedule_follow_up.py +70 -15
- package/elizaos/advanced_capabilities/actions/search_contacts.py +17 -3
- package/elizaos/advanced_capabilities/actions/send_message.py +183 -50
- package/elizaos/advanced_capabilities/actions/settings.py +16 -2
- package/elizaos/advanced_capabilities/actions/unfollow_room.py +13 -26
- package/elizaos/advanced_capabilities/actions/unmute_room.py +13 -26
- package/elizaos/advanced_capabilities/actions/update_contact.py +16 -2
- package/elizaos/advanced_capabilities/actions/update_entity.py +16 -2
- package/elizaos/advanced_capabilities/evaluators/__init__.py +2 -9
- package/elizaos/advanced_capabilities/evaluators/reflection.py +3 -132
- package/elizaos/advanced_capabilities/evaluators/relationship_extraction.py +5 -201
- package/elizaos/advanced_capabilities/providers/__init__.py +1 -12
- package/elizaos/advanced_capabilities/providers/knowledge.py +24 -3
- package/elizaos/advanced_capabilities/services/__init__.py +2 -9
- package/elizaos/advanced_memory/actions/reset_session.py +11 -0
- package/elizaos/advanced_memory/evaluators/reflection.py +134 -0
- package/elizaos/advanced_memory/evaluators/relationship_extraction.py +203 -0
- package/elizaos/advanced_memory/memory_service.py +15 -17
- package/elizaos/advanced_memory/test_advanced_memory.py +357 -0
- package/elizaos/advanced_planning/actions/schedule_follow_up.py +222 -0
- package/elizaos/advanced_planning/planning_service.py +26 -14
- package/elizaos/basic_capabilities/__init__.py +0 -2
- package/elizaos/basic_capabilities/providers/__init__.py +0 -3
- package/elizaos/basic_capabilities/providers/actions.py +118 -29
- package/elizaos/basic_capabilities/providers/agent_settings.py +64 -0
- package/elizaos/basic_capabilities/providers/character.py +19 -21
- package/elizaos/basic_capabilities/providers/contacts.py +79 -0
- package/elizaos/basic_capabilities/providers/current_time.py +7 -4
- package/elizaos/basic_capabilities/providers/facts.py +87 -0
- package/elizaos/basic_capabilities/providers/follow_ups.py +117 -0
- package/elizaos/basic_capabilities/providers/knowledge.py +97 -0
- package/elizaos/basic_capabilities/providers/relationships.py +107 -0
- package/elizaos/basic_capabilities/providers/roles.py +96 -0
- package/elizaos/basic_capabilities/providers/settings.py +56 -0
- package/elizaos/basic_capabilities/providers/time.py +7 -4
- package/elizaos/bootstrap/__init__.py +21 -2
- package/elizaos/bootstrap/actions/schedule_follow_up.py +65 -7
- package/elizaos/bootstrap/actions/send_message.py +162 -15
- package/elizaos/bootstrap/autonomy/__init__.py +5 -1
- package/elizaos/bootstrap/autonomy/action.py +161 -0
- package/elizaos/bootstrap/autonomy/evaluators.py +217 -0
- package/elizaos/bootstrap/autonomy/service.py +238 -28
- package/elizaos/bootstrap/plugin.py +7 -0
- package/elizaos/bootstrap/providers/actions.py +118 -27
- package/elizaos/bootstrap/providers/agent_settings.py +1 -0
- package/elizaos/bootstrap/providers/attachments.py +1 -0
- package/elizaos/bootstrap/providers/capabilities.py +1 -0
- package/elizaos/bootstrap/providers/character.py +1 -0
- package/elizaos/bootstrap/providers/choice.py +1 -0
- package/elizaos/bootstrap/providers/contacts.py +1 -0
- package/elizaos/bootstrap/providers/current_time.py +8 -2
- package/elizaos/bootstrap/providers/entities.py +1 -0
- package/elizaos/bootstrap/providers/evaluators.py +1 -0
- package/elizaos/bootstrap/providers/facts.py +1 -0
- package/elizaos/bootstrap/providers/follow_ups.py +1 -0
- package/elizaos/bootstrap/providers/knowledge.py +27 -3
- package/elizaos/bootstrap/providers/providers_list.py +1 -0
- package/elizaos/bootstrap/providers/relationships.py +1 -0
- package/elizaos/bootstrap/providers/roles.py +1 -0
- package/elizaos/bootstrap/providers/settings.py +1 -0
- package/elizaos/bootstrap/providers/time.py +8 -4
- package/elizaos/bootstrap/providers/world.py +1 -0
- package/elizaos/bootstrap/services/embedding.py +156 -1
- package/elizaos/deterministic.py +193 -0
- package/elizaos/generated/__init__.py +1 -0
- package/elizaos/generated/action_docs.py +3181 -0
- package/elizaos/generated/spec_helpers.py +175 -0
- package/elizaos/media/mime.py +2 -2
- package/elizaos/media/search.py +23 -23
- package/elizaos/runtime.py +215 -57
- package/elizaos/services/message_service.py +175 -29
- package/elizaos/types/components.py +2 -2
- package/elizaos/types/generated/__init__.py +12 -0
- package/elizaos/types/generated/eliza/v1/agent_pb2.py +63 -0
- package/elizaos/types/generated/eliza/v1/agent_pb2.pyi +159 -0
- package/elizaos/types/generated/eliza/v1/components_pb2.py +65 -0
- package/elizaos/types/generated/eliza/v1/components_pb2.pyi +160 -0
- package/elizaos/types/generated/eliza/v1/database_pb2.py +78 -0
- package/elizaos/types/generated/eliza/v1/database_pb2.pyi +305 -0
- package/elizaos/types/generated/eliza/v1/environment_pb2.py +58 -0
- package/elizaos/types/generated/eliza/v1/environment_pb2.pyi +135 -0
- package/elizaos/types/generated/eliza/v1/events_pb2.py +82 -0
- package/elizaos/types/generated/eliza/v1/events_pb2.pyi +322 -0
- package/elizaos/types/generated/eliza/v1/ipc_pb2.py +113 -0
- package/elizaos/types/generated/eliza/v1/ipc_pb2.pyi +367 -0
- package/elizaos/types/generated/eliza/v1/knowledge_pb2.py +41 -0
- package/elizaos/types/generated/eliza/v1/knowledge_pb2.pyi +26 -0
- package/elizaos/types/generated/eliza/v1/memory_pb2.py +55 -0
- package/elizaos/types/generated/eliza/v1/memory_pb2.pyi +111 -0
- package/elizaos/types/generated/eliza/v1/message_service_pb2.py +48 -0
- package/elizaos/types/generated/eliza/v1/message_service_pb2.pyi +69 -0
- package/elizaos/types/generated/eliza/v1/messaging_pb2.py +51 -0
- package/elizaos/types/generated/eliza/v1/messaging_pb2.pyi +97 -0
- package/elizaos/types/generated/eliza/v1/model_pb2.py +84 -0
- package/elizaos/types/generated/eliza/v1/model_pb2.pyi +280 -0
- package/elizaos/types/generated/eliza/v1/payment_pb2.py +44 -0
- package/elizaos/types/generated/eliza/v1/payment_pb2.pyi +70 -0
- package/elizaos/types/generated/eliza/v1/plugin_pb2.py +68 -0
- package/elizaos/types/generated/eliza/v1/plugin_pb2.pyi +145 -0
- package/elizaos/types/generated/eliza/v1/primitives_pb2.py +48 -0
- package/elizaos/types/generated/eliza/v1/primitives_pb2.pyi +92 -0
- package/elizaos/types/generated/eliza/v1/prompts_pb2.py +52 -0
- package/elizaos/types/generated/eliza/v1/prompts_pb2.pyi +74 -0
- package/elizaos/types/generated/eliza/v1/service_interfaces_pb2.py +211 -0
- package/elizaos/types/generated/eliza/v1/service_interfaces_pb2.pyi +1296 -0
- package/elizaos/types/generated/eliza/v1/service_pb2.py +42 -0
- package/elizaos/types/generated/eliza/v1/service_pb2.pyi +69 -0
- package/elizaos/types/generated/eliza/v1/settings_pb2.py +58 -0
- package/elizaos/types/generated/eliza/v1/settings_pb2.pyi +85 -0
- package/elizaos/types/generated/eliza/v1/state_pb2.py +60 -0
- package/elizaos/types/generated/eliza/v1/state_pb2.pyi +114 -0
- package/elizaos/types/generated/eliza/v1/task_pb2.py +42 -0
- package/elizaos/types/generated/eliza/v1/task_pb2.pyi +58 -0
- package/elizaos/types/generated/eliza/v1/tee_pb2.py +52 -0
- package/elizaos/types/generated/eliza/v1/tee_pb2.pyi +90 -0
- package/elizaos/types/generated/eliza/v1/testing_pb2.py +39 -0
- package/elizaos/types/generated/eliza/v1/testing_pb2.pyi +23 -0
- package/elizaos/types/model.py +30 -0
- package/elizaos/types/runtime.py +6 -2
- package/elizaos/utils/validation.py +76 -0
- package/package.json +3 -2
- package/tests/test_action_parameters.py +2 -3
- package/tests/test_actions_provider_examples.py +58 -1
- package/tests/test_advanced_memory_behavior.py +0 -2
- package/tests/test_advanced_memory_flag.py +0 -2
- package/tests/test_advanced_planning_behavior.py +11 -5
- package/tests/test_async_embedding.py +124 -0
- package/tests/test_autonomy.py +24 -3
- package/tests/test_runtime.py +8 -17
- package/tests/test_schedule_follow_up_action.py +260 -0
- package/tests/test_send_message_action_targets.py +114 -0
- package/tests/test_settings_crypto.py +0 -2
- package/tests/test_validation.py +141 -0
- package/tests/verify_memory_architecture.py +192 -0
- package/uv.lock +1565 -0
- package/elizaos/basic_capabilities/providers/capabilities.py +0 -62
|
@@ -95,38 +95,36 @@ class MemoryService(Service):
|
|
|
95
95
|
@classmethod
|
|
96
96
|
async def start(cls, runtime):
|
|
97
97
|
svc = cls(runtime=runtime)
|
|
98
|
-
|
|
99
|
-
settings = runtime.character.settings or {}
|
|
100
|
-
if (v := settings.get("MEMORY_SUMMARIZATION_THRESHOLD")) is not None and isinstance(
|
|
98
|
+
if (v := runtime.get_setting("MEMORY_SUMMARIZATION_THRESHOLD")) is not None and isinstance(
|
|
101
99
|
v, (int, float, str)
|
|
102
100
|
):
|
|
103
101
|
svc._config.short_term_summarization_threshold = int(v)
|
|
104
|
-
if (v :=
|
|
102
|
+
if (v := runtime.get_setting("MEMORY_RETAIN_RECENT")) is not None and isinstance(
|
|
105
103
|
v, (int, float, str)
|
|
106
104
|
):
|
|
107
105
|
svc._config.short_term_retain_recent = int(v)
|
|
108
|
-
if (v :=
|
|
106
|
+
if (v := runtime.get_setting("MEMORY_SUMMARIZATION_INTERVAL")) is not None and isinstance(
|
|
109
107
|
v, (int, float, str)
|
|
110
108
|
):
|
|
111
109
|
svc._config.short_term_summarization_interval = int(v)
|
|
112
|
-
if (v :=
|
|
110
|
+
if (v := runtime.get_setting("MEMORY_MAX_NEW_MESSAGES")) is not None and isinstance(
|
|
113
111
|
v, (int, float, str)
|
|
114
112
|
):
|
|
115
113
|
svc._config.summary_max_new_messages = int(v)
|
|
116
|
-
if (v :=
|
|
114
|
+
if (v := runtime.get_setting("MEMORY_LONG_TERM_ENABLED")) is not None:
|
|
117
115
|
if str(v).lower() == "false":
|
|
118
116
|
svc._config.long_term_extraction_enabled = False
|
|
119
117
|
elif str(v).lower() == "true":
|
|
120
118
|
svc._config.long_term_extraction_enabled = True
|
|
121
|
-
if (v :=
|
|
119
|
+
if (v := runtime.get_setting("MEMORY_CONFIDENCE_THRESHOLD")) is not None and isinstance(
|
|
122
120
|
v, (int, float, str)
|
|
123
121
|
):
|
|
124
122
|
svc._config.long_term_confidence_threshold = float(v)
|
|
125
|
-
if (v :=
|
|
123
|
+
if (v := runtime.get_setting("MEMORY_EXTRACTION_THRESHOLD")) is not None and isinstance(
|
|
126
124
|
v, (int, float, str)
|
|
127
125
|
):
|
|
128
126
|
svc._config.long_term_extraction_threshold = int(v)
|
|
129
|
-
if (v :=
|
|
127
|
+
if (v := runtime.get_setting("MEMORY_EXTRACTION_INTERVAL")) is not None and isinstance(
|
|
130
128
|
v, (int, float, str)
|
|
131
129
|
):
|
|
132
130
|
svc._config.long_term_extraction_interval = int(v)
|
|
@@ -146,7 +144,7 @@ class MemoryService(Service):
|
|
|
146
144
|
return f"memory:extraction:{entity_id}:{room_id}"
|
|
147
145
|
|
|
148
146
|
async def get_last_extraction_checkpoint(self, entity_id: UUID, room_id: UUID) -> int:
|
|
149
|
-
runtime = self.
|
|
147
|
+
runtime = self._runtime
|
|
150
148
|
key = self._checkpoint_key(entity_id, room_id)
|
|
151
149
|
if runtime is not None and getattr(runtime, "_adapter", None) is not None:
|
|
152
150
|
cached = await runtime.get_cache(key)
|
|
@@ -163,7 +161,7 @@ class MemoryService(Service):
|
|
|
163
161
|
async def set_last_extraction_checkpoint(
|
|
164
162
|
self, entity_id: UUID, room_id: UUID, message_count: int
|
|
165
163
|
) -> None:
|
|
166
|
-
runtime = self.
|
|
164
|
+
runtime = self._runtime
|
|
167
165
|
key = self._checkpoint_key(entity_id, room_id)
|
|
168
166
|
if runtime is not None and getattr(runtime, "_adapter", None) is not None:
|
|
169
167
|
_ = await runtime.set_cache(key, int(message_count))
|
|
@@ -182,7 +180,7 @@ class MemoryService(Service):
|
|
|
182
180
|
return current_cp > last_cp
|
|
183
181
|
|
|
184
182
|
async def get_current_session_summary(self, room_id: UUID) -> SessionSummary | None:
|
|
185
|
-
runtime = self.
|
|
183
|
+
runtime = self._runtime
|
|
186
184
|
if runtime is None:
|
|
187
185
|
return None
|
|
188
186
|
|
|
@@ -234,7 +232,7 @@ class MemoryService(Service):
|
|
|
234
232
|
topics: list[str] | None = None,
|
|
235
233
|
metadata: dict[str, object] | None = None,
|
|
236
234
|
) -> SessionSummary:
|
|
237
|
-
runtime = self.
|
|
235
|
+
runtime = self._runtime
|
|
238
236
|
s = SessionSummary(
|
|
239
237
|
id=uuid4(),
|
|
240
238
|
agent_id=agent_id,
|
|
@@ -279,7 +277,7 @@ class MemoryService(Service):
|
|
|
279
277
|
async def update_session_summary(
|
|
280
278
|
self, summary_id: UUID, room_id: UUID, **updates: object
|
|
281
279
|
) -> None:
|
|
282
|
-
runtime = self.
|
|
280
|
+
runtime = self._runtime
|
|
283
281
|
if runtime is not None and getattr(runtime, "_adapter", None) is not None:
|
|
284
282
|
existing = await self.get_current_session_summary(room_id)
|
|
285
283
|
if not existing or existing.id != summary_id:
|
|
@@ -350,7 +348,7 @@ class MemoryService(Service):
|
|
|
350
348
|
source: str | None = None,
|
|
351
349
|
metadata: dict[str, object] | None = None,
|
|
352
350
|
) -> LongTermMemory:
|
|
353
|
-
runtime = self.
|
|
351
|
+
runtime = self._runtime
|
|
354
352
|
m = LongTermMemory(
|
|
355
353
|
id=uuid4(),
|
|
356
354
|
agent_id=agent_id,
|
|
@@ -394,7 +392,7 @@ class MemoryService(Service):
|
|
|
394
392
|
) -> list[LongTermMemory]:
|
|
395
393
|
if limit <= 0:
|
|
396
394
|
return []
|
|
397
|
-
runtime = self.
|
|
395
|
+
runtime = self._runtime
|
|
398
396
|
if runtime is not None and getattr(runtime, "_adapter", None) is not None:
|
|
399
397
|
db_mems = await runtime.get_memories(
|
|
400
398
|
{
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Tests for the advanced memory module.
|
|
2
|
+
|
|
3
|
+
Covers XML parsing, extraction checkpointing, config management,
|
|
4
|
+
confidence sorting, and formatted memory output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from .memory_service import (
|
|
14
|
+
MemoryService,
|
|
15
|
+
_parse_memory_extraction_xml,
|
|
16
|
+
_parse_summary_xml,
|
|
17
|
+
_top_k_by_confidence,
|
|
18
|
+
)
|
|
19
|
+
from .types import (
|
|
20
|
+
LongTermMemory,
|
|
21
|
+
LongTermMemoryCategory,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# ============================================================================
|
|
25
|
+
# XML Parsing: Summary
|
|
26
|
+
# ============================================================================
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestParseSummaryXml:
|
|
30
|
+
def test_valid_xml(self):
|
|
31
|
+
xml = """
|
|
32
|
+
<summary>
|
|
33
|
+
<text>The user discussed their favorite coffee.</text>
|
|
34
|
+
<topics>coffee, preferences, beverages</topics>
|
|
35
|
+
<key_points>
|
|
36
|
+
<point>User prefers dark roast</point>
|
|
37
|
+
<point>User drinks 3 cups daily</point>
|
|
38
|
+
</key_points>
|
|
39
|
+
</summary>"""
|
|
40
|
+
result = _parse_summary_xml(xml)
|
|
41
|
+
assert result.summary == "The user discussed their favorite coffee."
|
|
42
|
+
assert result.topics == ["coffee", "preferences", "beverages"]
|
|
43
|
+
assert len(result.key_points) == 2
|
|
44
|
+
assert result.key_points[0] == "User prefers dark roast"
|
|
45
|
+
assert result.key_points[1] == "User drinks 3 cups daily"
|
|
46
|
+
|
|
47
|
+
def test_malformed_xml(self):
|
|
48
|
+
result = _parse_summary_xml("This is not XML at all")
|
|
49
|
+
assert result.summary == "Summary not available"
|
|
50
|
+
assert result.topics == []
|
|
51
|
+
assert result.key_points == []
|
|
52
|
+
|
|
53
|
+
def test_partial_tags(self):
|
|
54
|
+
result = _parse_summary_xml("<text>Just a summary</text>")
|
|
55
|
+
assert result.summary == "Just a summary"
|
|
56
|
+
assert result.topics == []
|
|
57
|
+
assert result.key_points == []
|
|
58
|
+
|
|
59
|
+
def test_empty_topics(self):
|
|
60
|
+
result = _parse_summary_xml("<text>Summary here</text><topics></topics>")
|
|
61
|
+
assert result.summary == "Summary here"
|
|
62
|
+
assert result.topics == []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ============================================================================
|
|
66
|
+
# XML Parsing: Memory Extraction
|
|
67
|
+
# ============================================================================
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestParseMemoryExtractionXml:
|
|
71
|
+
def test_valid_multiple(self):
|
|
72
|
+
xml = """
|
|
73
|
+
<memories>
|
|
74
|
+
<memory>
|
|
75
|
+
<category>semantic</category>
|
|
76
|
+
<content>User works as a software engineer</content>
|
|
77
|
+
<confidence>0.95</confidence>
|
|
78
|
+
</memory>
|
|
79
|
+
<memory>
|
|
80
|
+
<category>episodic</category>
|
|
81
|
+
<content>User had a meeting yesterday</content>
|
|
82
|
+
<confidence>0.8</confidence>
|
|
83
|
+
</memory>
|
|
84
|
+
<memory>
|
|
85
|
+
<category>procedural</category>
|
|
86
|
+
<content>User prefers TypeScript for backend</content>
|
|
87
|
+
<confidence>0.9</confidence>
|
|
88
|
+
</memory>
|
|
89
|
+
</memories>"""
|
|
90
|
+
extractions = _parse_memory_extraction_xml(xml)
|
|
91
|
+
assert len(extractions) == 3
|
|
92
|
+
assert extractions[0].category == LongTermMemoryCategory.SEMANTIC
|
|
93
|
+
assert extractions[0].content == "User works as a software engineer"
|
|
94
|
+
assert abs(extractions[0].confidence - 0.95) < 0.001
|
|
95
|
+
assert extractions[1].category == LongTermMemoryCategory.EPISODIC
|
|
96
|
+
assert extractions[2].category == LongTermMemoryCategory.PROCEDURAL
|
|
97
|
+
|
|
98
|
+
def test_invalid_category_skipped(self):
|
|
99
|
+
xml = """
|
|
100
|
+
<memories>
|
|
101
|
+
<memory>
|
|
102
|
+
<category>invalid_type</category>
|
|
103
|
+
<content>This should be skipped</content>
|
|
104
|
+
<confidence>0.9</confidence>
|
|
105
|
+
</memory>
|
|
106
|
+
<memory>
|
|
107
|
+
<category>semantic</category>
|
|
108
|
+
<content>This should be kept</content>
|
|
109
|
+
<confidence>0.85</confidence>
|
|
110
|
+
</memory>
|
|
111
|
+
</memories>"""
|
|
112
|
+
extractions = _parse_memory_extraction_xml(xml)
|
|
113
|
+
assert len(extractions) == 1
|
|
114
|
+
assert extractions[0].content == "This should be kept"
|
|
115
|
+
|
|
116
|
+
def test_bad_confidence_skipped(self):
|
|
117
|
+
xml = """
|
|
118
|
+
<memory>
|
|
119
|
+
<category>semantic</category>
|
|
120
|
+
<content>Bad confidence</content>
|
|
121
|
+
<confidence>not_a_number</confidence>
|
|
122
|
+
</memory>"""
|
|
123
|
+
extractions = _parse_memory_extraction_xml(xml)
|
|
124
|
+
assert len(extractions) == 0
|
|
125
|
+
|
|
126
|
+
def test_empty_input(self):
|
|
127
|
+
assert _parse_memory_extraction_xml("") == []
|
|
128
|
+
|
|
129
|
+
def test_no_memories(self):
|
|
130
|
+
assert _parse_memory_extraction_xml("The model didn't return structured data.") == []
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ============================================================================
|
|
134
|
+
# top_k_by_confidence
|
|
135
|
+
# ============================================================================
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TestTopKByConfidence:
|
|
139
|
+
def test_sorts_by_confidence(self):
|
|
140
|
+
entity_id = uuid4()
|
|
141
|
+
agent_id = uuid4()
|
|
142
|
+
memories = [
|
|
143
|
+
LongTermMemory(
|
|
144
|
+
id=uuid4(),
|
|
145
|
+
agent_id=agent_id,
|
|
146
|
+
entity_id=entity_id,
|
|
147
|
+
category=LongTermMemoryCategory.SEMANTIC,
|
|
148
|
+
content="low",
|
|
149
|
+
confidence=0.5,
|
|
150
|
+
),
|
|
151
|
+
LongTermMemory(
|
|
152
|
+
id=uuid4(),
|
|
153
|
+
agent_id=agent_id,
|
|
154
|
+
entity_id=entity_id,
|
|
155
|
+
category=LongTermMemoryCategory.SEMANTIC,
|
|
156
|
+
content="high",
|
|
157
|
+
confidence=0.95,
|
|
158
|
+
),
|
|
159
|
+
LongTermMemory(
|
|
160
|
+
id=uuid4(),
|
|
161
|
+
agent_id=agent_id,
|
|
162
|
+
entity_id=entity_id,
|
|
163
|
+
category=LongTermMemoryCategory.SEMANTIC,
|
|
164
|
+
content="mid",
|
|
165
|
+
confidence=0.8,
|
|
166
|
+
),
|
|
167
|
+
]
|
|
168
|
+
result = _top_k_by_confidence(memories, 2)
|
|
169
|
+
assert len(result) == 2
|
|
170
|
+
assert result[0].content == "high"
|
|
171
|
+
assert result[1].content == "mid"
|
|
172
|
+
|
|
173
|
+
def test_zero_limit(self):
|
|
174
|
+
entity_id = uuid4()
|
|
175
|
+
agent_id = uuid4()
|
|
176
|
+
memories = [
|
|
177
|
+
LongTermMemory(
|
|
178
|
+
id=uuid4(),
|
|
179
|
+
agent_id=agent_id,
|
|
180
|
+
entity_id=entity_id,
|
|
181
|
+
category=LongTermMemoryCategory.SEMANTIC,
|
|
182
|
+
content="x",
|
|
183
|
+
confidence=0.9,
|
|
184
|
+
),
|
|
185
|
+
]
|
|
186
|
+
assert _top_k_by_confidence(memories, 0) == []
|
|
187
|
+
|
|
188
|
+
def test_empty_list(self):
|
|
189
|
+
assert _top_k_by_confidence([], 5) == []
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ============================================================================
|
|
193
|
+
# Config Management
|
|
194
|
+
# ============================================================================
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class TestConfigManagement:
|
|
198
|
+
def test_defaults_are_sensible(self):
|
|
199
|
+
svc = MemoryService()
|
|
200
|
+
config = svc.get_config()
|
|
201
|
+
assert config.short_term_summarization_threshold > 0
|
|
202
|
+
assert config.long_term_extraction_threshold > 0
|
|
203
|
+
assert config.long_term_extraction_interval > 0
|
|
204
|
+
assert config.long_term_confidence_threshold > 0.0
|
|
205
|
+
|
|
206
|
+
def test_get_config_returns_copy(self):
|
|
207
|
+
svc = MemoryService()
|
|
208
|
+
c1 = svc.get_config()
|
|
209
|
+
c1.short_term_summarization_threshold = 99999
|
|
210
|
+
c2 = svc.get_config()
|
|
211
|
+
assert c2.short_term_summarization_threshold != 99999
|
|
212
|
+
|
|
213
|
+
def test_update_config_partial(self):
|
|
214
|
+
svc = MemoryService()
|
|
215
|
+
original = svc.get_config()
|
|
216
|
+
svc._config.short_term_summarization_threshold = 999
|
|
217
|
+
updated = svc.get_config()
|
|
218
|
+
assert updated.short_term_summarization_threshold == 999
|
|
219
|
+
# Other fields preserved
|
|
220
|
+
assert updated.long_term_extraction_threshold == original.long_term_extraction_threshold
|
|
221
|
+
assert updated.long_term_extraction_interval == original.long_term_extraction_interval
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ============================================================================
|
|
225
|
+
# Extraction Checkpointing
|
|
226
|
+
# ============================================================================
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class TestExtractionCheckpointing:
|
|
230
|
+
@pytest.mark.asyncio
|
|
231
|
+
async def test_below_threshold_does_not_run(self):
|
|
232
|
+
svc = MemoryService()
|
|
233
|
+
entity = uuid4()
|
|
234
|
+
room = uuid4()
|
|
235
|
+
assert not await svc.should_run_extraction(entity, room, 1)
|
|
236
|
+
|
|
237
|
+
@pytest.mark.asyncio
|
|
238
|
+
async def test_at_threshold_runs_first_time(self):
|
|
239
|
+
svc = MemoryService()
|
|
240
|
+
config = svc.get_config()
|
|
241
|
+
entity = uuid4()
|
|
242
|
+
room = uuid4()
|
|
243
|
+
assert await svc.should_run_extraction(entity, room, config.long_term_extraction_threshold)
|
|
244
|
+
|
|
245
|
+
@pytest.mark.asyncio
|
|
246
|
+
async def test_checkpoint_prevents_rerun(self):
|
|
247
|
+
svc = MemoryService()
|
|
248
|
+
config = svc.get_config()
|
|
249
|
+
threshold = config.long_term_extraction_threshold
|
|
250
|
+
entity = uuid4()
|
|
251
|
+
room = uuid4()
|
|
252
|
+
await svc.set_last_extraction_checkpoint(entity, room, threshold)
|
|
253
|
+
assert not await svc.should_run_extraction(entity, room, threshold)
|
|
254
|
+
|
|
255
|
+
@pytest.mark.asyncio
|
|
256
|
+
async def test_next_interval_runs(self):
|
|
257
|
+
svc = MemoryService()
|
|
258
|
+
config = svc.get_config()
|
|
259
|
+
threshold = config.long_term_extraction_threshold
|
|
260
|
+
interval = config.long_term_extraction_interval
|
|
261
|
+
entity = uuid4()
|
|
262
|
+
room = uuid4()
|
|
263
|
+
await svc.set_last_extraction_checkpoint(entity, room, threshold)
|
|
264
|
+
assert await svc.should_run_extraction(entity, room, threshold + interval)
|
|
265
|
+
|
|
266
|
+
@pytest.mark.asyncio
|
|
267
|
+
async def test_independent_entity_room_pairs(self):
|
|
268
|
+
svc = MemoryService()
|
|
269
|
+
config = svc.get_config()
|
|
270
|
+
threshold = config.long_term_extraction_threshold
|
|
271
|
+
entity_a = uuid4()
|
|
272
|
+
entity_b = uuid4()
|
|
273
|
+
room = uuid4()
|
|
274
|
+
await svc.set_last_extraction_checkpoint(entity_a, room, threshold)
|
|
275
|
+
# entity_b should still be eligible
|
|
276
|
+
assert await svc.should_run_extraction(entity_b, room, threshold)
|
|
277
|
+
# entity_a should not
|
|
278
|
+
assert not await svc.should_run_extraction(entity_a, room, threshold)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ============================================================================
|
|
282
|
+
# Formatted Memory Output
|
|
283
|
+
# ============================================================================
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class TestFormattedLongTermMemories:
|
|
287
|
+
@pytest.mark.asyncio
|
|
288
|
+
async def test_groups_by_category(self):
|
|
289
|
+
svc = MemoryService()
|
|
290
|
+
entity_id = uuid4()
|
|
291
|
+
agent_id = uuid4()
|
|
292
|
+
|
|
293
|
+
# Manually inject memories into fallback storage
|
|
294
|
+
key = str(entity_id)
|
|
295
|
+
svc._long_term[key] = [
|
|
296
|
+
LongTermMemory(
|
|
297
|
+
id=uuid4(),
|
|
298
|
+
agent_id=agent_id,
|
|
299
|
+
entity_id=entity_id,
|
|
300
|
+
category=LongTermMemoryCategory.SEMANTIC,
|
|
301
|
+
content="Likes coffee",
|
|
302
|
+
confidence=0.9,
|
|
303
|
+
),
|
|
304
|
+
LongTermMemory(
|
|
305
|
+
id=uuid4(),
|
|
306
|
+
agent_id=agent_id,
|
|
307
|
+
entity_id=entity_id,
|
|
308
|
+
category=LongTermMemoryCategory.EPISODIC,
|
|
309
|
+
content="Had meeting",
|
|
310
|
+
confidence=0.85,
|
|
311
|
+
),
|
|
312
|
+
LongTermMemory(
|
|
313
|
+
id=uuid4(),
|
|
314
|
+
agent_id=agent_id,
|
|
315
|
+
entity_id=entity_id,
|
|
316
|
+
category=LongTermMemoryCategory.SEMANTIC,
|
|
317
|
+
content="Prefers dark mode",
|
|
318
|
+
confidence=0.88,
|
|
319
|
+
),
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
result = await svc.get_formatted_long_term_memories(entity_id)
|
|
323
|
+
assert "**Semantic**:" in result
|
|
324
|
+
assert "**Episodic**:" in result
|
|
325
|
+
assert "- Likes coffee" in result
|
|
326
|
+
assert "- Prefers dark mode" in result
|
|
327
|
+
assert "- Had meeting" in result
|
|
328
|
+
|
|
329
|
+
@pytest.mark.asyncio
|
|
330
|
+
async def test_empty_returns_empty_string(self):
|
|
331
|
+
svc = MemoryService()
|
|
332
|
+
entity_id = uuid4()
|
|
333
|
+
result = await svc.get_formatted_long_term_memories(entity_id)
|
|
334
|
+
assert result == ""
|
|
335
|
+
|
|
336
|
+
@pytest.mark.asyncio
|
|
337
|
+
async def test_single_category(self):
|
|
338
|
+
svc = MemoryService()
|
|
339
|
+
entity_id = uuid4()
|
|
340
|
+
agent_id = uuid4()
|
|
341
|
+
|
|
342
|
+
svc._long_term[str(entity_id)] = [
|
|
343
|
+
LongTermMemory(
|
|
344
|
+
id=uuid4(),
|
|
345
|
+
agent_id=agent_id,
|
|
346
|
+
entity_id=entity_id,
|
|
347
|
+
category=LongTermMemoryCategory.PROCEDURAL,
|
|
348
|
+
content="Knows how to ride a bike",
|
|
349
|
+
confidence=0.95,
|
|
350
|
+
),
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
result = await svc.get_formatted_long_term_memories(entity_id)
|
|
354
|
+
assert "**Procedural**:" in result
|
|
355
|
+
assert "- Knows how to ride a bike" in result
|
|
356
|
+
assert "**Semantic**:" not in result
|
|
357
|
+
assert "**Episodic**:" not in result
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
from uuid import UUID as StdUUID
|
|
7
|
+
|
|
8
|
+
from elizaos.bootstrap.utils.xml import parse_key_value_xml
|
|
9
|
+
from elizaos.deterministic import get_prompt_reference_datetime
|
|
10
|
+
from elizaos.generated.spec_helpers import require_action_spec
|
|
11
|
+
from elizaos.prompts import SCHEDULE_FOLLOW_UP_TEMPLATE
|
|
12
|
+
from elizaos.types import (
|
|
13
|
+
Action,
|
|
14
|
+
ActionExample,
|
|
15
|
+
ActionResult,
|
|
16
|
+
Content,
|
|
17
|
+
ModelType,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from elizaos.types import (
|
|
22
|
+
HandlerCallback,
|
|
23
|
+
HandlerOptions,
|
|
24
|
+
IAgentRuntime,
|
|
25
|
+
Memory,
|
|
26
|
+
State,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Get text content from centralized specs
|
|
30
|
+
_spec = require_action_spec("SCHEDULE_FOLLOW_UP")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _convert_spec_examples() -> list[list[ActionExample]]:
|
|
34
|
+
"""Convert spec examples to ActionExample format."""
|
|
35
|
+
spec_examples = _spec.get("examples", [])
|
|
36
|
+
if spec_examples:
|
|
37
|
+
return [
|
|
38
|
+
[
|
|
39
|
+
ActionExample(
|
|
40
|
+
name=msg.get("name", ""),
|
|
41
|
+
content=Content(
|
|
42
|
+
text=msg.get("content", {}).get("text", ""),
|
|
43
|
+
actions=msg.get("content", {}).get("actions"),
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
for msg in example
|
|
47
|
+
]
|
|
48
|
+
for example in spec_examples
|
|
49
|
+
]
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalize_priority(raw_priority: str) -> str:
|
|
54
|
+
normalized = raw_priority.strip().lower()
|
|
55
|
+
if normalized in {"high", "medium", "low"}:
|
|
56
|
+
return normalized
|
|
57
|
+
return "medium"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _coerce_uuid(value: object | None) -> StdUUID | None:
|
|
61
|
+
if value is None:
|
|
62
|
+
return None
|
|
63
|
+
text = str(value).strip()
|
|
64
|
+
if not text:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
return StdUUID(text)
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class ScheduleFollowUpAction:
|
|
74
|
+
name: str = _spec["name"]
|
|
75
|
+
similes: list[str] = field(default_factory=lambda: list(_spec.get("similes", [])))
|
|
76
|
+
description: str = _spec["description"]
|
|
77
|
+
|
|
78
|
+
async def validate(
|
|
79
|
+
self, runtime: IAgentRuntime, _message: Memory, _state: State | None = None
|
|
80
|
+
) -> bool:
|
|
81
|
+
rolodex_service = runtime.get_service("rolodex")
|
|
82
|
+
follow_up_service = runtime.get_service("follow_up")
|
|
83
|
+
return rolodex_service is not None and follow_up_service is not None
|
|
84
|
+
|
|
85
|
+
async def handler(
|
|
86
|
+
self,
|
|
87
|
+
runtime: IAgentRuntime,
|
|
88
|
+
message: Memory,
|
|
89
|
+
state: State | None = None,
|
|
90
|
+
options: HandlerOptions | None = None,
|
|
91
|
+
callback: HandlerCallback | None = None,
|
|
92
|
+
responses: list[Memory] | None = None,
|
|
93
|
+
) -> ActionResult:
|
|
94
|
+
from elizaos.bootstrap.services.follow_up import FollowUpService
|
|
95
|
+
from elizaos.bootstrap.services.rolodex import RolodexService
|
|
96
|
+
|
|
97
|
+
rolodex_service = runtime.get_service("rolodex")
|
|
98
|
+
follow_up_service = runtime.get_service("follow_up")
|
|
99
|
+
|
|
100
|
+
if not rolodex_service or not isinstance(rolodex_service, RolodexService):
|
|
101
|
+
return ActionResult(
|
|
102
|
+
text="Rolodex service not available",
|
|
103
|
+
success=False,
|
|
104
|
+
values={"error": True},
|
|
105
|
+
data={"error": "RolodexService not available"},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if not follow_up_service or not isinstance(follow_up_service, FollowUpService):
|
|
109
|
+
return ActionResult(
|
|
110
|
+
text="Follow-up service not available",
|
|
111
|
+
success=False,
|
|
112
|
+
values={"error": True},
|
|
113
|
+
data={"error": "FollowUpService not available"},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
state = await runtime.compose_state(message, ["RECENT_MESSAGES", "ENTITIES"])
|
|
117
|
+
state.values["currentDateTime"] = get_prompt_reference_datetime(
|
|
118
|
+
runtime,
|
|
119
|
+
message,
|
|
120
|
+
state,
|
|
121
|
+
"action:schedule_follow_up",
|
|
122
|
+
).isoformat()
|
|
123
|
+
|
|
124
|
+
prompt = runtime.compose_prompt_from_state(
|
|
125
|
+
state=state,
|
|
126
|
+
template=SCHEDULE_FOLLOW_UP_TEMPLATE,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
response = await runtime.use_model(ModelType.TEXT_SMALL, {"prompt": prompt})
|
|
130
|
+
parsed = parse_key_value_xml(response)
|
|
131
|
+
|
|
132
|
+
if not parsed or not parsed.get("contactName"):
|
|
133
|
+
return ActionResult(
|
|
134
|
+
text="Could not extract follow-up information",
|
|
135
|
+
success=False,
|
|
136
|
+
values={"error": True},
|
|
137
|
+
data={"error": "Failed to parse follow-up info"},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
contact_name = str(parsed.get("contactName", ""))
|
|
141
|
+
scheduled_at_str = str(parsed.get("scheduledAt", ""))
|
|
142
|
+
reason = str(parsed.get("reason", "Follow-up"))
|
|
143
|
+
priority = _normalize_priority(str(parsed.get("priority", "medium")))
|
|
144
|
+
follow_up_message = str(parsed.get("message", ""))
|
|
145
|
+
parsed_entity_id = _coerce_uuid(parsed.get("entityId"))
|
|
146
|
+
message_entity_id = _coerce_uuid(message.entity_id)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
scheduled_at = datetime.fromisoformat(scheduled_at_str.replace("Z", "+00:00"))
|
|
150
|
+
except ValueError:
|
|
151
|
+
return ActionResult(
|
|
152
|
+
text="Could not parse the follow-up date/time",
|
|
153
|
+
success=False,
|
|
154
|
+
values={"error": True},
|
|
155
|
+
data={"error": "Invalid follow-up datetime"},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
entity_id_uuid = parsed_entity_id or message_entity_id
|
|
159
|
+
if entity_id_uuid is None and contact_name:
|
|
160
|
+
contacts = await rolodex_service.search_contacts(search_term=contact_name)
|
|
161
|
+
if contacts:
|
|
162
|
+
entity_id_uuid = contacts[0].entity_id
|
|
163
|
+
|
|
164
|
+
if entity_id_uuid is None:
|
|
165
|
+
return ActionResult(
|
|
166
|
+
text=f"Could not determine which contact to schedule for ({contact_name}).",
|
|
167
|
+
success=False,
|
|
168
|
+
values={"error": True},
|
|
169
|
+
data={"error": "Missing contact entity id"},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
contact = await rolodex_service.get_contact(entity_id_uuid)
|
|
173
|
+
if contact is None:
|
|
174
|
+
return ActionResult(
|
|
175
|
+
text=f"Contact '{contact_name}' was not found in the rolodex.",
|
|
176
|
+
success=False,
|
|
177
|
+
values={"error": True},
|
|
178
|
+
data={"error": "Contact not found"},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
await follow_up_service.schedule_follow_up(
|
|
182
|
+
entity_id=entity_id_uuid,
|
|
183
|
+
scheduled_at=scheduled_at,
|
|
184
|
+
reason=reason,
|
|
185
|
+
priority=priority,
|
|
186
|
+
message=follow_up_message,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
response_text = f"I've scheduled a follow-up with {contact_name} for {scheduled_at.strftime('%B %d, %Y')}. Reason: {reason}"
|
|
190
|
+
|
|
191
|
+
if callback:
|
|
192
|
+
await callback(Content(text=response_text, actions=["SCHEDULE_FOLLOW_UP"]))
|
|
193
|
+
|
|
194
|
+
return ActionResult(
|
|
195
|
+
text=response_text,
|
|
196
|
+
success=True,
|
|
197
|
+
values={
|
|
198
|
+
"contactId": str(entity_id_uuid),
|
|
199
|
+
"scheduledAt": scheduled_at.isoformat(),
|
|
200
|
+
},
|
|
201
|
+
data={
|
|
202
|
+
"contactId": str(entity_id_uuid),
|
|
203
|
+
"contactName": contact_name,
|
|
204
|
+
"scheduledAt": scheduled_at.isoformat(),
|
|
205
|
+
"reason": reason,
|
|
206
|
+
"priority": priority,
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def examples(self) -> list[list[ActionExample]]:
|
|
212
|
+
return _convert_spec_examples()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
schedule_follow_up_action = Action(
|
|
216
|
+
name=ScheduleFollowUpAction.name,
|
|
217
|
+
similes=ScheduleFollowUpAction().similes,
|
|
218
|
+
description=ScheduleFollowUpAction.description,
|
|
219
|
+
validate=ScheduleFollowUpAction().validate,
|
|
220
|
+
handler=ScheduleFollowUpAction().handler,
|
|
221
|
+
examples=ScheduleFollowUpAction().examples,
|
|
222
|
+
)
|