@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.
- package/LICENSE +26 -0
- package/README.md +239 -0
- package/elizaos/__init__.py +280 -0
- package/elizaos/action_docs.py +149 -0
- package/elizaos/advanced_capabilities/__init__.py +85 -0
- package/elizaos/advanced_capabilities/actions/__init__.py +54 -0
- package/elizaos/advanced_capabilities/actions/add_contact.py +139 -0
- package/elizaos/advanced_capabilities/actions/follow_room.py +151 -0
- package/elizaos/advanced_capabilities/actions/image_generation.py +148 -0
- package/elizaos/advanced_capabilities/actions/mute_room.py +164 -0
- package/elizaos/advanced_capabilities/actions/remove_contact.py +145 -0
- package/elizaos/advanced_capabilities/actions/roles.py +207 -0
- package/elizaos/advanced_capabilities/actions/schedule_follow_up.py +154 -0
- package/elizaos/advanced_capabilities/actions/search_contacts.py +145 -0
- package/elizaos/advanced_capabilities/actions/send_message.py +187 -0
- package/elizaos/advanced_capabilities/actions/settings.py +151 -0
- package/elizaos/advanced_capabilities/actions/unfollow_room.py +164 -0
- package/elizaos/advanced_capabilities/actions/unmute_room.py +164 -0
- package/elizaos/advanced_capabilities/actions/update_contact.py +164 -0
- package/elizaos/advanced_capabilities/actions/update_entity.py +161 -0
- package/elizaos/advanced_capabilities/evaluators/__init__.py +18 -0
- package/elizaos/advanced_capabilities/evaluators/reflection.py +134 -0
- package/elizaos/advanced_capabilities/evaluators/relationship_extraction.py +203 -0
- package/elizaos/advanced_capabilities/providers/__init__.py +36 -0
- package/elizaos/advanced_capabilities/providers/agent_settings.py +60 -0
- package/elizaos/advanced_capabilities/providers/contacts.py +77 -0
- package/elizaos/advanced_capabilities/providers/facts.py +82 -0
- package/elizaos/advanced_capabilities/providers/follow_ups.py +113 -0
- package/elizaos/advanced_capabilities/providers/knowledge.py +83 -0
- package/elizaos/advanced_capabilities/providers/relationships.py +112 -0
- package/elizaos/advanced_capabilities/providers/roles.py +97 -0
- package/elizaos/advanced_capabilities/providers/settings.py +51 -0
- package/elizaos/advanced_capabilities/services/__init__.py +18 -0
- package/elizaos/advanced_capabilities/services/follow_up.py +138 -0
- package/elizaos/advanced_capabilities/services/rolodex.py +244 -0
- package/elizaos/advanced_memory/__init__.py +3 -0
- package/elizaos/advanced_memory/evaluators.py +97 -0
- package/elizaos/advanced_memory/memory_service.py +556 -0
- package/elizaos/advanced_memory/plugin.py +30 -0
- package/elizaos/advanced_memory/prompts.py +12 -0
- package/elizaos/advanced_memory/providers.py +90 -0
- package/elizaos/advanced_memory/types.py +65 -0
- package/elizaos/advanced_planning/__init__.py +10 -0
- package/elizaos/advanced_planning/actions.py +145 -0
- package/elizaos/advanced_planning/message_classifier.py +127 -0
- package/elizaos/advanced_planning/planning_service.py +712 -0
- package/elizaos/advanced_planning/plugin.py +40 -0
- package/elizaos/advanced_planning/prompts.py +4 -0
- package/elizaos/basic_capabilities/__init__.py +66 -0
- package/elizaos/basic_capabilities/actions/__init__.py +24 -0
- package/elizaos/basic_capabilities/actions/choice.py +140 -0
- package/elizaos/basic_capabilities/actions/ignore.py +66 -0
- package/elizaos/basic_capabilities/actions/none.py +56 -0
- package/elizaos/basic_capabilities/actions/reply.py +120 -0
- package/elizaos/basic_capabilities/providers/__init__.py +54 -0
- package/elizaos/basic_capabilities/providers/action_state.py +113 -0
- package/elizaos/basic_capabilities/providers/actions.py +263 -0
- package/elizaos/basic_capabilities/providers/attachments.py +76 -0
- package/elizaos/basic_capabilities/providers/capabilities.py +62 -0
- package/elizaos/basic_capabilities/providers/character.py +113 -0
- package/elizaos/basic_capabilities/providers/choice.py +73 -0
- package/elizaos/basic_capabilities/providers/context_bench.py +44 -0
- package/elizaos/basic_capabilities/providers/current_time.py +58 -0
- package/elizaos/basic_capabilities/providers/entities.py +99 -0
- package/elizaos/basic_capabilities/providers/evaluators.py +54 -0
- package/elizaos/basic_capabilities/providers/providers_list.py +55 -0
- package/elizaos/basic_capabilities/providers/recent_messages.py +85 -0
- package/elizaos/basic_capabilities/providers/time.py +45 -0
- package/elizaos/basic_capabilities/providers/world.py +93 -0
- package/elizaos/basic_capabilities/services/__init__.py +18 -0
- package/elizaos/basic_capabilities/services/embedding.py +122 -0
- package/elizaos/basic_capabilities/services/task.py +178 -0
- package/elizaos/bootstrap/__init__.py +12 -0
- package/elizaos/bootstrap/actions/__init__.py +68 -0
- package/elizaos/bootstrap/actions/add_contact.py +149 -0
- package/elizaos/bootstrap/actions/choice.py +147 -0
- package/elizaos/bootstrap/actions/follow_room.py +151 -0
- package/elizaos/bootstrap/actions/ignore.py +80 -0
- package/elizaos/bootstrap/actions/image_generation.py +135 -0
- package/elizaos/bootstrap/actions/mute_room.py +151 -0
- package/elizaos/bootstrap/actions/none.py +71 -0
- package/elizaos/bootstrap/actions/remove_contact.py +159 -0
- package/elizaos/bootstrap/actions/reply.py +140 -0
- package/elizaos/bootstrap/actions/roles.py +193 -0
- package/elizaos/bootstrap/actions/schedule_follow_up.py +164 -0
- package/elizaos/bootstrap/actions/search_contacts.py +159 -0
- package/elizaos/bootstrap/actions/send_message.py +173 -0
- package/elizaos/bootstrap/actions/settings.py +165 -0
- package/elizaos/bootstrap/actions/unfollow_room.py +151 -0
- package/elizaos/bootstrap/actions/unmute_room.py +151 -0
- package/elizaos/bootstrap/actions/update_contact.py +178 -0
- package/elizaos/bootstrap/actions/update_entity.py +175 -0
- package/elizaos/bootstrap/autonomy/__init__.py +18 -0
- package/elizaos/bootstrap/autonomy/action.py +197 -0
- package/elizaos/bootstrap/autonomy/providers.py +165 -0
- package/elizaos/bootstrap/autonomy/routes.py +171 -0
- package/elizaos/bootstrap/autonomy/service.py +562 -0
- package/elizaos/bootstrap/autonomy/types.py +18 -0
- package/elizaos/bootstrap/evaluators/__init__.py +19 -0
- package/elizaos/bootstrap/evaluators/reflection.py +118 -0
- package/elizaos/bootstrap/evaluators/relationship_extraction.py +192 -0
- package/elizaos/bootstrap/plugin.py +140 -0
- package/elizaos/bootstrap/providers/__init__.py +80 -0
- package/elizaos/bootstrap/providers/action_state.py +71 -0
- package/elizaos/bootstrap/providers/actions.py +256 -0
- package/elizaos/bootstrap/providers/agent_settings.py +63 -0
- package/elizaos/bootstrap/providers/attachments.py +76 -0
- package/elizaos/bootstrap/providers/capabilities.py +66 -0
- package/elizaos/bootstrap/providers/character.py +128 -0
- package/elizaos/bootstrap/providers/choice.py +77 -0
- package/elizaos/bootstrap/providers/contacts.py +78 -0
- package/elizaos/bootstrap/providers/context_bench.py +49 -0
- package/elizaos/bootstrap/providers/current_time.py +56 -0
- package/elizaos/bootstrap/providers/entities.py +99 -0
- package/elizaos/bootstrap/providers/evaluators.py +58 -0
- package/elizaos/bootstrap/providers/facts.py +86 -0
- package/elizaos/bootstrap/providers/follow_ups.py +116 -0
- package/elizaos/bootstrap/providers/knowledge.py +73 -0
- package/elizaos/bootstrap/providers/providers_list.py +59 -0
- package/elizaos/bootstrap/providers/recent_messages.py +85 -0
- package/elizaos/bootstrap/providers/relationships.py +106 -0
- package/elizaos/bootstrap/providers/roles.py +95 -0
- package/elizaos/bootstrap/providers/settings.py +55 -0
- package/elizaos/bootstrap/providers/time.py +45 -0
- package/elizaos/bootstrap/providers/world.py +97 -0
- package/elizaos/bootstrap/services/__init__.py +26 -0
- package/elizaos/bootstrap/services/embedding.py +122 -0
- package/elizaos/bootstrap/services/follow_up.py +138 -0
- package/elizaos/bootstrap/services/rolodex.py +244 -0
- package/elizaos/bootstrap/services/task.py +585 -0
- package/elizaos/bootstrap/types.py +54 -0
- package/elizaos/bootstrap/utils/__init__.py +7 -0
- package/elizaos/bootstrap/utils/xml.py +69 -0
- package/elizaos/character.py +149 -0
- package/elizaos/logger.py +179 -0
- package/elizaos/media/__init__.py +45 -0
- package/elizaos/media/mime.py +315 -0
- package/elizaos/media/search.py +161 -0
- package/elizaos/media/tests/__init__.py +1 -0
- package/elizaos/media/tests/test_mime.py +117 -0
- package/elizaos/media/tests/test_search.py +156 -0
- package/elizaos/plugin.py +191 -0
- package/elizaos/prompts.py +1071 -0
- package/elizaos/py.typed +0 -0
- package/elizaos/runtime.py +2572 -0
- package/elizaos/services/__init__.py +49 -0
- package/elizaos/services/hook_service.py +511 -0
- package/elizaos/services/message_service.py +1248 -0
- package/elizaos/settings.py +182 -0
- package/elizaos/streaming_context.py +159 -0
- package/elizaos/trajectory_context.py +18 -0
- package/elizaos/types/__init__.py +512 -0
- package/elizaos/types/agent.py +31 -0
- package/elizaos/types/components.py +208 -0
- package/elizaos/types/database.py +64 -0
- package/elizaos/types/environment.py +46 -0
- package/elizaos/types/events.py +47 -0
- package/elizaos/types/memory.py +45 -0
- package/elizaos/types/model.py +393 -0
- package/elizaos/types/plugin.py +188 -0
- package/elizaos/types/primitives.py +100 -0
- package/elizaos/types/runtime.py +460 -0
- package/elizaos/types/service.py +113 -0
- package/elizaos/types/service_interfaces.py +244 -0
- package/elizaos/types/state.py +188 -0
- package/elizaos/types/task.py +29 -0
- package/elizaos/utils/__init__.py +108 -0
- package/elizaos/utils/spec_examples.py +48 -0
- package/elizaos/utils/streaming.py +426 -0
- package/elizaos_atropos_shared/__init__.py +1 -0
- package/elizaos_atropos_shared/canonical_eliza.py +282 -0
- package/package.json +19 -0
- package/pyproject.toml +143 -0
- package/requirements-dev.in +11 -0
- package/requirements-dev.lock +134 -0
- package/requirements.in +9 -0
- package/requirements.lock +64 -0
- package/tests/__init__.py +0 -0
- package/tests/test_action_parameters.py +154 -0
- package/tests/test_actions_provider_examples.py +39 -0
- package/tests/test_advanced_memory_behavior.py +96 -0
- package/tests/test_advanced_memory_flag.py +30 -0
- package/tests/test_advanced_planning_behavior.py +225 -0
- package/tests/test_advanced_planning_flag.py +26 -0
- package/tests/test_autonomy.py +445 -0
- package/tests/test_bootstrap_initialize.py +37 -0
- package/tests/test_character.py +163 -0
- package/tests/test_character_provider.py +231 -0
- package/tests/test_dynamic_prompt_exec.py +561 -0
- package/tests/test_logger_redaction.py +43 -0
- package/tests/test_plugin.py +117 -0
- package/tests/test_runtime.py +422 -0
- package/tests/test_salt_production_enforcement.py +22 -0
- package/tests/test_settings_crypto.py +118 -0
- package/tests/test_streaming.py +295 -0
- package/tests/test_types.py +221 -0
- 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)
|