@elizaos/python 2.0.0-alpha.10

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 (197) hide show
  1. package/LICENSE +26 -0
  2. package/README.md +239 -0
  3. package/elizaos/__init__.py +280 -0
  4. package/elizaos/action_docs.py +149 -0
  5. package/elizaos/advanced_capabilities/__init__.py +85 -0
  6. package/elizaos/advanced_capabilities/actions/__init__.py +54 -0
  7. package/elizaos/advanced_capabilities/actions/add_contact.py +139 -0
  8. package/elizaos/advanced_capabilities/actions/follow_room.py +151 -0
  9. package/elizaos/advanced_capabilities/actions/image_generation.py +148 -0
  10. package/elizaos/advanced_capabilities/actions/mute_room.py +164 -0
  11. package/elizaos/advanced_capabilities/actions/remove_contact.py +145 -0
  12. package/elizaos/advanced_capabilities/actions/roles.py +207 -0
  13. package/elizaos/advanced_capabilities/actions/schedule_follow_up.py +154 -0
  14. package/elizaos/advanced_capabilities/actions/search_contacts.py +145 -0
  15. package/elizaos/advanced_capabilities/actions/send_message.py +187 -0
  16. package/elizaos/advanced_capabilities/actions/settings.py +151 -0
  17. package/elizaos/advanced_capabilities/actions/unfollow_room.py +164 -0
  18. package/elizaos/advanced_capabilities/actions/unmute_room.py +164 -0
  19. package/elizaos/advanced_capabilities/actions/update_contact.py +164 -0
  20. package/elizaos/advanced_capabilities/actions/update_entity.py +161 -0
  21. package/elizaos/advanced_capabilities/evaluators/__init__.py +18 -0
  22. package/elizaos/advanced_capabilities/evaluators/reflection.py +134 -0
  23. package/elizaos/advanced_capabilities/evaluators/relationship_extraction.py +203 -0
  24. package/elizaos/advanced_capabilities/providers/__init__.py +36 -0
  25. package/elizaos/advanced_capabilities/providers/agent_settings.py +60 -0
  26. package/elizaos/advanced_capabilities/providers/contacts.py +77 -0
  27. package/elizaos/advanced_capabilities/providers/facts.py +82 -0
  28. package/elizaos/advanced_capabilities/providers/follow_ups.py +113 -0
  29. package/elizaos/advanced_capabilities/providers/knowledge.py +83 -0
  30. package/elizaos/advanced_capabilities/providers/relationships.py +112 -0
  31. package/elizaos/advanced_capabilities/providers/roles.py +97 -0
  32. package/elizaos/advanced_capabilities/providers/settings.py +51 -0
  33. package/elizaos/advanced_capabilities/services/__init__.py +18 -0
  34. package/elizaos/advanced_capabilities/services/follow_up.py +138 -0
  35. package/elizaos/advanced_capabilities/services/rolodex.py +244 -0
  36. package/elizaos/advanced_memory/__init__.py +3 -0
  37. package/elizaos/advanced_memory/evaluators.py +97 -0
  38. package/elizaos/advanced_memory/memory_service.py +556 -0
  39. package/elizaos/advanced_memory/plugin.py +30 -0
  40. package/elizaos/advanced_memory/prompts.py +12 -0
  41. package/elizaos/advanced_memory/providers.py +90 -0
  42. package/elizaos/advanced_memory/types.py +65 -0
  43. package/elizaos/advanced_planning/__init__.py +10 -0
  44. package/elizaos/advanced_planning/actions.py +145 -0
  45. package/elizaos/advanced_planning/message_classifier.py +127 -0
  46. package/elizaos/advanced_planning/planning_service.py +712 -0
  47. package/elizaos/advanced_planning/plugin.py +40 -0
  48. package/elizaos/advanced_planning/prompts.py +4 -0
  49. package/elizaos/basic_capabilities/__init__.py +66 -0
  50. package/elizaos/basic_capabilities/actions/__init__.py +24 -0
  51. package/elizaos/basic_capabilities/actions/choice.py +140 -0
  52. package/elizaos/basic_capabilities/actions/ignore.py +66 -0
  53. package/elizaos/basic_capabilities/actions/none.py +56 -0
  54. package/elizaos/basic_capabilities/actions/reply.py +120 -0
  55. package/elizaos/basic_capabilities/providers/__init__.py +54 -0
  56. package/elizaos/basic_capabilities/providers/action_state.py +113 -0
  57. package/elizaos/basic_capabilities/providers/actions.py +263 -0
  58. package/elizaos/basic_capabilities/providers/attachments.py +76 -0
  59. package/elizaos/basic_capabilities/providers/capabilities.py +62 -0
  60. package/elizaos/basic_capabilities/providers/character.py +113 -0
  61. package/elizaos/basic_capabilities/providers/choice.py +73 -0
  62. package/elizaos/basic_capabilities/providers/context_bench.py +44 -0
  63. package/elizaos/basic_capabilities/providers/current_time.py +58 -0
  64. package/elizaos/basic_capabilities/providers/entities.py +99 -0
  65. package/elizaos/basic_capabilities/providers/evaluators.py +54 -0
  66. package/elizaos/basic_capabilities/providers/providers_list.py +55 -0
  67. package/elizaos/basic_capabilities/providers/recent_messages.py +85 -0
  68. package/elizaos/basic_capabilities/providers/time.py +45 -0
  69. package/elizaos/basic_capabilities/providers/world.py +93 -0
  70. package/elizaos/basic_capabilities/services/__init__.py +18 -0
  71. package/elizaos/basic_capabilities/services/embedding.py +122 -0
  72. package/elizaos/basic_capabilities/services/task.py +178 -0
  73. package/elizaos/bootstrap/__init__.py +12 -0
  74. package/elizaos/bootstrap/actions/__init__.py +68 -0
  75. package/elizaos/bootstrap/actions/add_contact.py +149 -0
  76. package/elizaos/bootstrap/actions/choice.py +147 -0
  77. package/elizaos/bootstrap/actions/follow_room.py +151 -0
  78. package/elizaos/bootstrap/actions/ignore.py +80 -0
  79. package/elizaos/bootstrap/actions/image_generation.py +135 -0
  80. package/elizaos/bootstrap/actions/mute_room.py +151 -0
  81. package/elizaos/bootstrap/actions/none.py +71 -0
  82. package/elizaos/bootstrap/actions/remove_contact.py +159 -0
  83. package/elizaos/bootstrap/actions/reply.py +140 -0
  84. package/elizaos/bootstrap/actions/roles.py +193 -0
  85. package/elizaos/bootstrap/actions/schedule_follow_up.py +164 -0
  86. package/elizaos/bootstrap/actions/search_contacts.py +159 -0
  87. package/elizaos/bootstrap/actions/send_message.py +173 -0
  88. package/elizaos/bootstrap/actions/settings.py +165 -0
  89. package/elizaos/bootstrap/actions/unfollow_room.py +151 -0
  90. package/elizaos/bootstrap/actions/unmute_room.py +151 -0
  91. package/elizaos/bootstrap/actions/update_contact.py +178 -0
  92. package/elizaos/bootstrap/actions/update_entity.py +175 -0
  93. package/elizaos/bootstrap/autonomy/__init__.py +18 -0
  94. package/elizaos/bootstrap/autonomy/action.py +197 -0
  95. package/elizaos/bootstrap/autonomy/providers.py +165 -0
  96. package/elizaos/bootstrap/autonomy/routes.py +171 -0
  97. package/elizaos/bootstrap/autonomy/service.py +562 -0
  98. package/elizaos/bootstrap/autonomy/types.py +18 -0
  99. package/elizaos/bootstrap/evaluators/__init__.py +19 -0
  100. package/elizaos/bootstrap/evaluators/reflection.py +118 -0
  101. package/elizaos/bootstrap/evaluators/relationship_extraction.py +192 -0
  102. package/elizaos/bootstrap/plugin.py +140 -0
  103. package/elizaos/bootstrap/providers/__init__.py +80 -0
  104. package/elizaos/bootstrap/providers/action_state.py +71 -0
  105. package/elizaos/bootstrap/providers/actions.py +256 -0
  106. package/elizaos/bootstrap/providers/agent_settings.py +63 -0
  107. package/elizaos/bootstrap/providers/attachments.py +76 -0
  108. package/elizaos/bootstrap/providers/capabilities.py +66 -0
  109. package/elizaos/bootstrap/providers/character.py +128 -0
  110. package/elizaos/bootstrap/providers/choice.py +77 -0
  111. package/elizaos/bootstrap/providers/contacts.py +78 -0
  112. package/elizaos/bootstrap/providers/context_bench.py +49 -0
  113. package/elizaos/bootstrap/providers/current_time.py +56 -0
  114. package/elizaos/bootstrap/providers/entities.py +99 -0
  115. package/elizaos/bootstrap/providers/evaluators.py +58 -0
  116. package/elizaos/bootstrap/providers/facts.py +86 -0
  117. package/elizaos/bootstrap/providers/follow_ups.py +116 -0
  118. package/elizaos/bootstrap/providers/knowledge.py +73 -0
  119. package/elizaos/bootstrap/providers/providers_list.py +59 -0
  120. package/elizaos/bootstrap/providers/recent_messages.py +85 -0
  121. package/elizaos/bootstrap/providers/relationships.py +106 -0
  122. package/elizaos/bootstrap/providers/roles.py +95 -0
  123. package/elizaos/bootstrap/providers/settings.py +55 -0
  124. package/elizaos/bootstrap/providers/time.py +45 -0
  125. package/elizaos/bootstrap/providers/world.py +97 -0
  126. package/elizaos/bootstrap/services/__init__.py +26 -0
  127. package/elizaos/bootstrap/services/embedding.py +122 -0
  128. package/elizaos/bootstrap/services/follow_up.py +138 -0
  129. package/elizaos/bootstrap/services/rolodex.py +244 -0
  130. package/elizaos/bootstrap/services/task.py +585 -0
  131. package/elizaos/bootstrap/types.py +54 -0
  132. package/elizaos/bootstrap/utils/__init__.py +7 -0
  133. package/elizaos/bootstrap/utils/xml.py +69 -0
  134. package/elizaos/character.py +149 -0
  135. package/elizaos/logger.py +179 -0
  136. package/elizaos/media/__init__.py +45 -0
  137. package/elizaos/media/mime.py +315 -0
  138. package/elizaos/media/search.py +161 -0
  139. package/elizaos/media/tests/__init__.py +1 -0
  140. package/elizaos/media/tests/test_mime.py +117 -0
  141. package/elizaos/media/tests/test_search.py +156 -0
  142. package/elizaos/plugin.py +191 -0
  143. package/elizaos/prompts.py +1071 -0
  144. package/elizaos/py.typed +0 -0
  145. package/elizaos/runtime.py +2572 -0
  146. package/elizaos/services/__init__.py +49 -0
  147. package/elizaos/services/hook_service.py +511 -0
  148. package/elizaos/services/message_service.py +1248 -0
  149. package/elizaos/settings.py +182 -0
  150. package/elizaos/streaming_context.py +159 -0
  151. package/elizaos/trajectory_context.py +18 -0
  152. package/elizaos/types/__init__.py +512 -0
  153. package/elizaos/types/agent.py +31 -0
  154. package/elizaos/types/components.py +208 -0
  155. package/elizaos/types/database.py +64 -0
  156. package/elizaos/types/environment.py +46 -0
  157. package/elizaos/types/events.py +47 -0
  158. package/elizaos/types/memory.py +45 -0
  159. package/elizaos/types/model.py +393 -0
  160. package/elizaos/types/plugin.py +188 -0
  161. package/elizaos/types/primitives.py +100 -0
  162. package/elizaos/types/runtime.py +460 -0
  163. package/elizaos/types/service.py +113 -0
  164. package/elizaos/types/service_interfaces.py +244 -0
  165. package/elizaos/types/state.py +188 -0
  166. package/elizaos/types/task.py +29 -0
  167. package/elizaos/utils/__init__.py +108 -0
  168. package/elizaos/utils/spec_examples.py +48 -0
  169. package/elizaos/utils/streaming.py +426 -0
  170. package/elizaos_atropos_shared/__init__.py +1 -0
  171. package/elizaos_atropos_shared/canonical_eliza.py +282 -0
  172. package/package.json +19 -0
  173. package/pyproject.toml +143 -0
  174. package/requirements-dev.in +11 -0
  175. package/requirements-dev.lock +134 -0
  176. package/requirements.in +9 -0
  177. package/requirements.lock +64 -0
  178. package/tests/__init__.py +0 -0
  179. package/tests/test_action_parameters.py +154 -0
  180. package/tests/test_actions_provider_examples.py +39 -0
  181. package/tests/test_advanced_memory_behavior.py +96 -0
  182. package/tests/test_advanced_memory_flag.py +30 -0
  183. package/tests/test_advanced_planning_behavior.py +225 -0
  184. package/tests/test_advanced_planning_flag.py +26 -0
  185. package/tests/test_autonomy.py +445 -0
  186. package/tests/test_bootstrap_initialize.py +37 -0
  187. package/tests/test_character.py +163 -0
  188. package/tests/test_character_provider.py +231 -0
  189. package/tests/test_dynamic_prompt_exec.py +561 -0
  190. package/tests/test_logger_redaction.py +43 -0
  191. package/tests/test_plugin.py +117 -0
  192. package/tests/test_runtime.py +422 -0
  193. package/tests/test_salt_production_enforcement.py +22 -0
  194. package/tests/test_settings_crypto.py +118 -0
  195. package/tests/test_streaming.py +295 -0
  196. package/tests/test_types.py +221 -0
  197. package/tests/test_uuid_parity.py +46 -0
@@ -0,0 +1,426 @@
1
+ """Streaming utilities for validation-aware content extraction.
2
+
3
+ This module provides streaming extractors that mirror the TypeScript implementation
4
+ for cross-language parity. These are used by dynamicPromptExecFromState to enable
5
+ real-time streaming while detecting context truncation.
6
+
7
+ WHY THIS EXISTS:
8
+ LLMs can silently truncate output when they hit token limits. This is catastrophic
9
+ for structured outputs - you might get half a JSON object. Traditional streaming
10
+ has no validation - you might stream half a broken response.
11
+
12
+ These extractors bridge the gap: they enable streaming while detecting truncation.
13
+ They use "validation codes" - random UUIDs that the LLM must echo. If the echoed
14
+ code matches, we know that part wasn't truncated.
15
+ """
16
+
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, field
19
+ from enum import Enum
20
+
21
+ from elizaos.types.state import SchemaRow, StreamEvent
22
+
23
+ # Maximum allowed chunk size to prevent memory issues
24
+ MAX_CHUNK_SIZE = 1024 * 1024 # 1MB
25
+
26
+
27
+ class ChunkSizeError(ValueError):
28
+ """Error raised when a chunk exceeds the maximum allowed size."""
29
+
30
+ pass
31
+
32
+
33
+ def validate_chunk_size(chunk: str) -> None:
34
+ """Validate that a chunk doesn't exceed the maximum size."""
35
+ if len(chunk) > MAX_CHUNK_SIZE:
36
+ raise ChunkSizeError(f"Chunk size {len(chunk)} exceeds maximum {MAX_CHUNK_SIZE}")
37
+
38
+
39
+ class IStreamExtractor:
40
+ """Interface for stream extractors.
41
+
42
+ Stream extractors process incoming chunks and extract relevant content.
43
+ They track completion state and can be reset for retry scenarios.
44
+ """
45
+
46
+ @property
47
+ def done(self) -> bool:
48
+ """Whether extraction is complete."""
49
+ raise NotImplementedError
50
+
51
+ def push(self, chunk: str) -> str:
52
+ """Process an incoming chunk and return extracted content."""
53
+ raise NotImplementedError
54
+
55
+ def flush(self) -> str:
56
+ """Flush any remaining buffered content."""
57
+ raise NotImplementedError
58
+
59
+ def reset(self) -> None:
60
+ """Reset extractor state for retry."""
61
+ raise NotImplementedError
62
+
63
+
64
+ class MarkableExtractor(IStreamExtractor):
65
+ """Passthrough extractor that can be marked complete externally.
66
+
67
+ WHY: When using ValidationStreamExtractor inside dynamic_prompt_exec_from_state,
68
+ extraction/completion is handled internally. But the outer streaming context
69
+ still needs to know when streaming is complete for retry/fallback logic.
70
+
71
+ This extractor passes through all content and provides a mark_complete() method
72
+ that the caller can invoke when the underlying operation completes successfully.
73
+
74
+ Example:
75
+ extractor = MarkableExtractor()
76
+ ctx = create_streaming_context(extractor, callback)
77
+
78
+ result = await dynamic_prompt_exec_from_state(...)
79
+ if result:
80
+ extractor.mark_complete() # Signal success
81
+
82
+ if ctx.is_complete():
83
+ # Now returns True after mark_complete()
84
+ """
85
+
86
+ def __init__(self) -> None:
87
+ self._done = False
88
+
89
+ @property
90
+ def done(self) -> bool:
91
+ return self._done
92
+
93
+ def push(self, chunk: str) -> str:
94
+ validate_chunk_size(chunk)
95
+ return chunk # Pass through everything
96
+
97
+ def flush(self) -> str:
98
+ return ""
99
+
100
+ def reset(self) -> None:
101
+ self._done = False
102
+
103
+ def mark_complete(self) -> None:
104
+ """Mark the extractor as complete.
105
+
106
+ WHY: Called by the outer code when the underlying operation completes
107
+ successfully. This allows is_complete() to return True for retry/fallback logic.
108
+ """
109
+ self._done = True
110
+
111
+
112
+ class ExtractorState(str, Enum):
113
+ """Extractor state machine for validation-aware streaming."""
114
+
115
+ STREAMING = "streaming" # Normal operation - actively receiving chunks
116
+ VALIDATING = "validating" # Stream ended, checking validation codes
117
+ RETRYING = "retrying" # Validation failed, preparing for retry
118
+ COMPLETE = "complete" # Successfully finished
119
+ FAILED = "failed" # Unrecoverable error
120
+
121
+
122
+ class FieldState(str, Enum):
123
+ """Per-field state tracking for progressive validation."""
124
+
125
+ PENDING = "pending" # Haven't seen this field yet
126
+ PARTIAL = "partial" # Found opening tag but no closing tag
127
+ COMPLETE = "complete" # Found both tags, content extracted
128
+ INVALID = "invalid" # Validation codes didn't match
129
+
130
+
131
+ @dataclass
132
+ class ValidationStreamExtractorConfig:
133
+ """Configuration for ValidationStreamExtractor."""
134
+
135
+ level: int # Validation level (0-3)
136
+ schema: list[SchemaRow]
137
+ stream_fields: list[str]
138
+ expected_codes: dict[str, str] # field -> expected validation code
139
+ on_chunk: Callable[[str, str | None], None] # chunk, field -> None
140
+ on_event: Callable[[StreamEvent], None] | None = None
141
+ abort_signal: Callable[[], bool] | None = None # Returns True if aborted
142
+ has_rich_consumer: bool = False
143
+
144
+
145
+ @dataclass
146
+ class ValidationDiagnosis:
147
+ """Diagnosis result for error analysis."""
148
+
149
+ missing_fields: list[str] = field(default_factory=list) # Never started
150
+ invalid_fields: list[str] = field(default_factory=list) # Wrong validation codes
151
+ incomplete_fields: list[str] = field(default_factory=list) # Started but not completed
152
+
153
+
154
+ class ValidationStreamExtractor(IStreamExtractor):
155
+ """Validation-aware stream extractor for dynamic_prompt_exec_from_state.
156
+
157
+ WHY THIS EXISTS:
158
+ LLMs can silently truncate output when they hit token limits. This is catastrophic
159
+ for structured outputs - you might get half a JSON object. Traditional streaming
160
+ has no validation - you might stream half a broken response.
161
+
162
+ This extractor bridges the gap: it enables streaming while detecting truncation.
163
+ It uses "validation codes" - random UUIDs that the LLM must echo. If the echoed
164
+ code matches, we know that part wasn't truncated.
165
+
166
+ VALIDATION LEVELS:
167
+ - Level 0 (Trusted): No codes, stream immediately. Fast but no safety.
168
+ - Level 1 (Progressive): Per-field codes, emit as each field validates.
169
+ - Level 2 (First Checkpoint): Code at start only, buffer until validated.
170
+ - Level 3 (Full): Codes at start AND end, maximum safety.
171
+ """
172
+
173
+ def __init__(self, config: ValidationStreamExtractorConfig) -> None:
174
+ self.config = config
175
+ self.buffer = ""
176
+ self.field_contents: dict[str, str] = {}
177
+ self.validated_fields: set[str] = set()
178
+ self.emitted_content: dict[str, str] = {}
179
+ self.field_states: dict[str, FieldState] = {}
180
+ self._state = ExtractorState.STREAMING
181
+
182
+ for field_name in config.stream_fields:
183
+ self.field_states[field_name] = FieldState.PENDING
184
+
185
+ @property
186
+ def done(self) -> bool:
187
+ return self._state in (ExtractorState.COMPLETE, ExtractorState.FAILED)
188
+
189
+ def push(self, chunk: str) -> str:
190
+ # Check for cancellation
191
+ if self.config.abort_signal and self.config.abort_signal():
192
+ if self._state not in (ExtractorState.COMPLETE, ExtractorState.FAILED):
193
+ self._state = ExtractorState.FAILED
194
+ self._emit_event(StreamEvent.error_event("Cancelled by user"))
195
+ return ""
196
+
197
+ if self._state != ExtractorState.STREAMING:
198
+ return ""
199
+
200
+ validate_chunk_size(chunk)
201
+ self.buffer += chunk
202
+
203
+ # Extract field contents from buffer
204
+ self._extract_field_contents()
205
+
206
+ # For levels 0-1, check if we can emit validated content
207
+ if self.config.level <= 1:
208
+ self._check_per_field_emission()
209
+
210
+ return "" # We emit via callbacks, not return value
211
+
212
+ def flush(self) -> str:
213
+ # Don't overwrite failed state (e.g., from abort)
214
+ if self._state == ExtractorState.FAILED:
215
+ return ""
216
+
217
+ # For levels 2-3, emit all buffered content when validation passes
218
+ if self.config.level >= 2:
219
+ for field_name in self.config.stream_fields:
220
+ content = self.field_contents.get(field_name, "")
221
+ if content:
222
+ self._emit_field_content(field_name, content)
223
+
224
+ self._state = ExtractorState.COMPLETE
225
+ self._emit_event(StreamEvent.complete_event())
226
+ return ""
227
+
228
+ def reset(self) -> None:
229
+ self.buffer = ""
230
+ self.field_contents.clear()
231
+ self.validated_fields.clear()
232
+ self.emitted_content.clear()
233
+ for field_name in self.config.stream_fields:
234
+ self.field_states[field_name] = FieldState.PENDING
235
+ self._state = ExtractorState.STREAMING
236
+
237
+ def signal_retry(self, retry_count: int) -> dict[str, list[str]]:
238
+ """Signal a retry attempt. Returns info about validated fields for smart retry prompts."""
239
+ self._state = ExtractorState.RETRYING
240
+
241
+ # Emit separator for simple consumers
242
+ if not self.config.has_rich_consumer:
243
+ self.config.on_chunk("\n-- that's not right, let me start again:\n", None)
244
+
245
+ self._emit_event(StreamEvent.retry_start_event(retry_count))
246
+
247
+ return {"validated_fields": list(self.validated_fields)}
248
+
249
+ def signal_error(self, message: str) -> None:
250
+ """Signal an unrecoverable error."""
251
+ self._state = ExtractorState.FAILED
252
+ self._emit_event(StreamEvent.error_event(message))
253
+
254
+ def get_validated_fields(self) -> dict[str, str]:
255
+ """Get fields that passed validation (for smart retry context)."""
256
+ result = {}
257
+ for field_name in self.validated_fields:
258
+ content = self.field_contents.get(field_name)
259
+ if content:
260
+ result[field_name] = content
261
+ return result
262
+
263
+ def diagnose(self) -> ValidationDiagnosis:
264
+ """Diagnose what went wrong for error reporting."""
265
+ missing_fields = []
266
+ invalid_fields = []
267
+ incomplete_fields = []
268
+
269
+ for row in self.config.schema:
270
+ state = self.field_states.get(row.field)
271
+ if state == FieldState.PENDING:
272
+ missing_fields.append(row.field)
273
+ elif state == FieldState.INVALID:
274
+ invalid_fields.append(row.field)
275
+ elif state == FieldState.PARTIAL:
276
+ incomplete_fields.append(row.field)
277
+
278
+ return ValidationDiagnosis(
279
+ missing_fields=missing_fields,
280
+ invalid_fields=invalid_fields,
281
+ incomplete_fields=incomplete_fields,
282
+ )
283
+
284
+ def get_state(self) -> ExtractorState:
285
+ """Get current extractor state."""
286
+ return self._state
287
+
288
+ # Private helpers
289
+
290
+ def _extract_field_contents(self) -> None:
291
+ """Extract field contents from the buffer."""
292
+ # Pre-compute all field tags for boundary detection
293
+ all_open_tags = [f"<{row.field}>" for row in self.config.schema]
294
+
295
+ for row in self.config.schema:
296
+ field_name = row.field
297
+ open_tag = f"<{field_name}>"
298
+ close_tag = f"</{field_name}>"
299
+
300
+ open_idx = self.buffer.find(open_tag)
301
+ if open_idx == -1:
302
+ continue
303
+
304
+ content_start = open_idx + len(open_tag)
305
+ close_idx = self.buffer.find(close_tag, content_start)
306
+
307
+ if close_idx != -1:
308
+ # Complete field found
309
+ content = self.buffer[content_start:close_idx]
310
+ self.field_contents[field_name] = content
311
+ self.field_states[field_name] = FieldState.COMPLETE
312
+ elif self.field_states.get(field_name) != FieldState.COMPLETE:
313
+ # Partial field - still streaming
314
+ self.field_states[field_name] = FieldState.PARTIAL
315
+
316
+ # Find the end boundary for partial content
317
+ partial_end = len(self.buffer)
318
+ for other_tag in all_open_tags:
319
+ if other_tag == open_tag:
320
+ continue # Skip self
321
+ other_idx = self.buffer.find(other_tag, content_start)
322
+ if other_idx != -1 and other_idx < partial_end:
323
+ partial_end = other_idx
324
+
325
+ partial_content = self.buffer[content_start:partial_end]
326
+ self.field_contents[field_name] = partial_content
327
+
328
+ def _check_per_field_emission(self) -> None:
329
+ """Check and emit validated content for levels 0-1."""
330
+ for field_name in self.config.stream_fields:
331
+ state = self.field_states.get(field_name)
332
+ if state == FieldState.INVALID:
333
+ continue # Skip already invalid fields
334
+
335
+ content = self.field_contents.get(field_name)
336
+ if not content:
337
+ continue
338
+
339
+ # Check validation codes if required
340
+ expected_code = self.config.expected_codes.get(field_name)
341
+ if expected_code:
342
+ start_code_valid = self._check_validation_code(field_name, "start", expected_code)
343
+ end_code_valid = self._check_validation_code(field_name, "end", expected_code)
344
+
345
+ if state == FieldState.COMPLETE:
346
+ if start_code_valid and end_code_valid:
347
+ self.validated_fields.add(field_name)
348
+ self._emit_field_content(field_name, content)
349
+ self._emit_event(StreamEvent.field_validated_event(field_name))
350
+ elif start_code_valid and not end_code_valid:
351
+ # Start valid but end invalid
352
+ self.field_states[field_name] = FieldState.INVALID
353
+ self._emit_event(
354
+ StreamEvent.error_event(
355
+ f"End validation code mismatch for {field_name}"
356
+ )
357
+ )
358
+ else:
359
+ self.field_states[field_name] = FieldState.INVALID
360
+ self._emit_event(
361
+ StreamEvent.error_event(f"Validation codes mismatch for {field_name}")
362
+ )
363
+ else:
364
+ # No validation codes for this field
365
+ if self.config.level == 0:
366
+ # Level 0: Stream immediately as content arrives (no validation)
367
+ self._emit_field_content(field_name, content)
368
+ elif state == FieldState.COMPLETE:
369
+ # Levels 1-3: Stream when field is complete
370
+ self._emit_field_content(field_name, content)
371
+
372
+ def _check_validation_code(self, field_name: str, position: str, expected_code: str) -> bool:
373
+ """Check if a validation code matches."""
374
+ code_field = f"code_{field_name}_{position}"
375
+ open_tag = f"<{code_field}>"
376
+ close_tag = f"</{code_field}>"
377
+
378
+ open_idx = self.buffer.find(open_tag)
379
+ if open_idx == -1:
380
+ return False
381
+
382
+ content_start = open_idx + len(open_tag)
383
+ close_idx = self.buffer.find(close_tag, content_start)
384
+ if close_idx == -1:
385
+ return False
386
+
387
+ actual_code = self.buffer[content_start:close_idx].strip()
388
+ return actual_code == expected_code
389
+
390
+ def _emit_field_content(self, field_name: str, content: str) -> None:
391
+ """Emit new content for a field, tracking what's already been emitted."""
392
+ previously_emitted = self.emitted_content.get(field_name, "")
393
+
394
+ # Defensive check: if content shrinks, reset and emit full content
395
+ if len(content) < len(previously_emitted):
396
+ self.emitted_content[field_name] = content
397
+ if content:
398
+ self.config.on_chunk(content, field_name)
399
+ self._emit_event(StreamEvent.chunk_event(field_name, content))
400
+ return
401
+
402
+ # Emit only the new portion
403
+ if len(content) > len(previously_emitted):
404
+ new_content = content[len(previously_emitted) :]
405
+ self.emitted_content[field_name] = content
406
+ self.config.on_chunk(new_content, field_name)
407
+ self._emit_event(StreamEvent.chunk_event(field_name, new_content))
408
+
409
+ def _emit_event(self, event: StreamEvent) -> None:
410
+ """Emit a rich event to the consumer if they support it."""
411
+ if self.config.on_event:
412
+ self.config.on_event(event)
413
+
414
+
415
+ __all__ = [
416
+ "IStreamExtractor",
417
+ "MarkableExtractor",
418
+ "ValidationStreamExtractor",
419
+ "ValidationStreamExtractorConfig",
420
+ "ValidationDiagnosis",
421
+ "ExtractorState",
422
+ "FieldState",
423
+ "validate_chunk_size",
424
+ "ChunkSizeError",
425
+ "MAX_CHUNK_SIZE",
426
+ ]
@@ -0,0 +1 @@
1
+ """Shared utilities for ElizaOS Atropos examples."""
@@ -0,0 +1,282 @@
1
+ """
2
+ Shared helpers for integrating Atropos environments with the canonical ElizaOS pipeline.
3
+
4
+ Key idea:
5
+ - Store per-step environment context in a typed ContextStore
6
+ - Providers/actions read/write through that store
7
+ - Agents trigger decisions via runtime.message_service.handle_message(...)
8
+ - Trajectory logging is linked via MessageMetadata.trajectoryStepId when provided
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uuid
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Generic, TypeVar
17
+
18
+ from elizaos.services.message_service import MessageProcessingResult
19
+ from elizaos.types import (
20
+ Action,
21
+ ActionParameter,
22
+ ActionParameterSchema,
23
+ ActionResult,
24
+ Character,
25
+ Content,
26
+ HandlerOptions,
27
+ Plugin,
28
+ Provider,
29
+ ProviderResult,
30
+ )
31
+ from elizaos.types.memory import Memory, MessageMetadata
32
+ from elizaos.types.primitives import as_uuid
33
+
34
+ if TYPE_CHECKING:
35
+ from elizaos.runtime import AgentRuntime
36
+ from elizaos.types import State
37
+
38
+
39
+ JsonScalar = str | int | float | bool | None
40
+ JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
41
+
42
+ TContext = TypeVar("TContext")
43
+
44
+
45
+ class ContextStore(Generic[TContext]):
46
+ """A typed in-memory store for the current decision context."""
47
+
48
+ def __init__(self) -> None:
49
+ self._ctx: TContext | None = None
50
+
51
+ def set(self, ctx: TContext | None) -> None:
52
+ self._ctx = ctx
53
+
54
+ def get(self) -> TContext | None:
55
+ return self._ctx
56
+
57
+ def clear(self) -> None:
58
+ self._ctx = None
59
+
60
+
61
+ def make_decision_message(
62
+ *,
63
+ source: str,
64
+ text: str,
65
+ trajectory_step_id: str | None = None,
66
+ ) -> Memory:
67
+ meta = MessageMetadata(source=source)
68
+ if trajectory_step_id is not None:
69
+ # MessageMetadata allows extra fields.
70
+ meta.trajectoryStepId = trajectory_step_id
71
+
72
+ return Memory(
73
+ entity_id=as_uuid(str(uuid.uuid4())),
74
+ room_id=as_uuid(str(uuid.uuid4())),
75
+ content=Content(
76
+ text=text,
77
+ source=source,
78
+ channel_type="API",
79
+ ),
80
+ metadata=meta,
81
+ )
82
+
83
+
84
+ async def handle_decision_message(
85
+ runtime: AgentRuntime,
86
+ *,
87
+ source: str,
88
+ text: str,
89
+ trajectory_step_id: str | None = None,
90
+ ) -> MessageProcessingResult:
91
+ message = make_decision_message(source=source, text=text, trajectory_step_id=trajectory_step_id)
92
+ return await runtime.message_service.handle_message(runtime, message)
93
+
94
+
95
+ async def run_with_context(
96
+ runtime: AgentRuntime,
97
+ store: ContextStore[TContext],
98
+ ctx: TContext,
99
+ *,
100
+ source: str,
101
+ text: str,
102
+ trajectory_step_id: str | None = None,
103
+ ) -> tuple[MessageProcessingResult, TContext]:
104
+ """
105
+ Run one canonical ElizaOS message loop with a context set.
106
+
107
+ Returns:
108
+ (MessageProcessingResult, context_after_actions)
109
+ """
110
+ store.set(ctx)
111
+ try:
112
+ result = await handle_decision_message(
113
+ runtime, source=source, text=text, trajectory_step_id=trajectory_step_id
114
+ )
115
+ ctx_after = store.get() or ctx
116
+ return result, ctx_after
117
+ finally:
118
+ store.clear()
119
+
120
+
121
+ ProviderRenderFn = Callable[[TContext], ProviderResult]
122
+
123
+
124
+ def create_provider_from_store(
125
+ *,
126
+ name: str,
127
+ description: str,
128
+ store: ContextStore[TContext],
129
+ render: ProviderRenderFn[TContext],
130
+ position: int = -10,
131
+ ) -> Provider:
132
+ async def _get(
133
+ _runtime: AgentRuntime, _message: Memory, _state: State | None = None
134
+ ) -> ProviderResult:
135
+ ctx = store.get()
136
+ if ctx is None:
137
+ return ProviderResult(
138
+ text=f"No active {name} context.", values={f"has_{name}": False}, data={}
139
+ )
140
+ return render(ctx)
141
+
142
+ return Provider(name=name, description=description, get=_get, dynamic=True, position=position)
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class CaptureActionResponse:
147
+ ok: bool
148
+ error: str | None = None
149
+ values: dict[str, JsonValue] | None = None
150
+ data: dict[str, JsonValue] | None = None
151
+ text: str | None = None
152
+
153
+
154
+ def ok_capture(
155
+ *,
156
+ values: dict[str, JsonValue] | None = None,
157
+ data: dict[str, JsonValue] | None = None,
158
+ text: str | None = None,
159
+ ) -> CaptureActionResponse:
160
+ return CaptureActionResponse(ok=True, values=values, data=data, text=text)
161
+
162
+
163
+ def err_capture(error: str) -> CaptureActionResponse:
164
+ return CaptureActionResponse(ok=False, error=error)
165
+
166
+
167
+ ApplyParamFn = Callable[[TContext, str], CaptureActionResponse]
168
+
169
+
170
+ def create_capture_action_from_store(
171
+ *,
172
+ name: str,
173
+ description: str,
174
+ store: ContextStore[TContext],
175
+ param_name: str,
176
+ schema: ActionParameterSchema,
177
+ apply_param: ApplyParamFn[TContext],
178
+ ) -> Action:
179
+ async def validate(
180
+ _runtime: AgentRuntime, _message: Memory, _state: State | None = None
181
+ ) -> bool:
182
+ return store.get() is not None
183
+
184
+ async def handler(
185
+ _runtime: AgentRuntime,
186
+ _message: Memory,
187
+ _state: State | None = None,
188
+ options: HandlerOptions | None = None,
189
+ _callback=None,
190
+ _responses=None,
191
+ ) -> ActionResult:
192
+ ctx = store.get()
193
+ if ctx is None:
194
+ return ActionResult(success=False, error=f"No {name} context")
195
+
196
+ raw = ""
197
+ if options is not None and options.parameters is not None:
198
+ val = options.parameters.get(param_name)
199
+ raw = str(val) if val is not None else ""
200
+ raw = raw.strip()
201
+ if not raw:
202
+ return ActionResult(success=False, error=f"Missing {param_name}")
203
+
204
+ resp = apply_param(ctx, raw)
205
+ if not resp.ok:
206
+ return ActionResult(success=False, error=resp.error or "Invalid param")
207
+
208
+ return ActionResult(success=True, values=resp.values, data=resp.data, text=resp.text)
209
+
210
+ return Action(
211
+ name=name,
212
+ description=description,
213
+ validate=validate,
214
+ handler=handler,
215
+ parameters=[
216
+ ActionParameter(
217
+ name=param_name,
218
+ description=f"{param_name} parameter",
219
+ required=True,
220
+ schema=schema,
221
+ )
222
+ ],
223
+ )
224
+
225
+
226
+ def create_action_only_template(
227
+ *,
228
+ task: str,
229
+ instructions: str,
230
+ action_name: str,
231
+ param_name: str,
232
+ param_placeholder: str,
233
+ ) -> str:
234
+ return f"""<task>{task}</task>
235
+
236
+ <providers>
237
+ {{{{providers}}}}
238
+ </providers>
239
+
240
+ <instructions>
241
+ {instructions}
242
+ </instructions>
243
+
244
+ <output>
245
+ Return XML only:
246
+ <response>
247
+ <thought>brief</thought>
248
+ <actions>{action_name}</actions>
249
+ <params>
250
+ <{action_name}>
251
+ <{param_name}>{param_placeholder}</{param_name}>
252
+ </{action_name}>
253
+ </params>
254
+ <text>short</text>
255
+ </response>
256
+ </output>"""
257
+
258
+
259
+ def create_basic_character(
260
+ *,
261
+ name: str,
262
+ bio: list[str],
263
+ system: str,
264
+ template: str,
265
+ ) -> Character:
266
+ return Character(
267
+ name=name,
268
+ bio=bio,
269
+ system=system,
270
+ templates={"messageHandlerTemplate": template},
271
+ settings={"checkShouldRespond": False},
272
+ )
273
+
274
+
275
+ def create_simple_plugin(
276
+ *,
277
+ name: str,
278
+ description: str,
279
+ providers: list[Provider],
280
+ actions: list[Action],
281
+ ) -> Plugin:
282
+ return Plugin(name=name, description=description, providers=providers, actions=actions)