@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,712 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import re
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from uuid import UUID, uuid4
9
+
10
+ from google.protobuf.json_format import MessageToDict
11
+
12
+ from elizaos.logger import Logger
13
+ from elizaos.types.components import ActionContext, ActionResult, HandlerCallback, HandlerOptions
14
+ from elizaos.types.memory import Memory
15
+ from elizaos.types.primitives import Content
16
+ from elizaos.types.service import Service
17
+ from elizaos.types.state import State
18
+
19
+
20
+ @dataclass
21
+ class RetryPolicy:
22
+ max_retries: int = 2
23
+ backoff_ms: int = 1000
24
+ backoff_multiplier: int = 2
25
+ on_error: str = "abort" # abort | continue | skip
26
+
27
+
28
+ @dataclass
29
+ class ActionStep:
30
+ id: UUID
31
+ action_name: str
32
+ parameters: dict[str, object]
33
+ dependencies: list[UUID]
34
+ retry_policy: RetryPolicy = field(default_factory=RetryPolicy)
35
+ on_error: str | None = None
36
+
37
+
38
+ @dataclass
39
+ class PlanState:
40
+ status: str = "pending" # pending | running | completed | failed | cancelled
41
+ start_time: float | None = None
42
+ end_time: float | None = None
43
+ current_step_index: int = 0
44
+ error: str | None = None
45
+
46
+
47
+ @dataclass
48
+ class ActionPlan:
49
+ id: UUID
50
+ goal: str
51
+ thought: str
52
+ total_steps: int
53
+ current_step: int
54
+ steps: list[ActionStep]
55
+ execution_model: str = "sequential"
56
+ state: PlanState = field(default_factory=PlanState)
57
+ metadata: dict[str, object] | None = None
58
+
59
+
60
+ @dataclass
61
+ class PlanExecutionResult:
62
+ plan_id: UUID
63
+ success: bool
64
+ completed_steps: int
65
+ total_steps: int
66
+ results: list[ActionResult]
67
+ errors: list[str] | None = None
68
+ duration_ms: float | None = None
69
+
70
+
71
+ @dataclass
72
+ class PlanExecution:
73
+ state: PlanState
74
+ working_memory: dict[str, object]
75
+ results: list[ActionResult]
76
+ abort_event: asyncio.Event = field(default_factory=asyncio.Event)
77
+
78
+
79
+ class PlanningService(Service):
80
+ service_type = "planning"
81
+
82
+ def __init__(self, runtime=None) -> None:
83
+ super().__init__(runtime=runtime)
84
+ self._active_plans: dict[UUID, ActionPlan] = {}
85
+ self._executions: dict[UUID, PlanExecution] = {}
86
+
87
+ @property
88
+ def capability_description(self) -> str:
89
+ return "Planning and action coordination"
90
+
91
+ @classmethod
92
+ async def start(cls, runtime):
93
+ service = cls(runtime=runtime)
94
+ runtime.logger.info("PlanningService started successfully", src="service:planning")
95
+ return service
96
+
97
+ async def stop(self) -> None:
98
+ for execution in self._executions.values():
99
+ execution.abort_event.set()
100
+ execution.state.status = "cancelled"
101
+ execution.state.end_time = time.time()
102
+ self._active_plans.clear()
103
+ self._executions.clear()
104
+
105
+ async def create_simple_plan(
106
+ self,
107
+ message: Memory,
108
+ state: State | None = None,
109
+ response_content: Content | None = None,
110
+ ) -> ActionPlan | None:
111
+ _ = state
112
+
113
+ text = (message.content.text or "").lower()
114
+ actions: list[str]
115
+ if response_content and response_content.actions:
116
+ actions = [a for a in response_content.actions if isinstance(a, str)]
117
+ elif "email" in text:
118
+ actions = ["SEND_EMAIL"]
119
+ elif "research" in text and ("send" in text or "summary" in text):
120
+ actions = ["SEARCH", "REPLY"]
121
+ elif any(word in text for word in ["search", "find", "research"]):
122
+ actions = ["SEARCH"]
123
+ elif "analyze" in text:
124
+ actions = ["THINK", "REPLY"]
125
+ else:
126
+ actions = ["REPLY"]
127
+
128
+ plan_id = uuid4()
129
+ steps: list[ActionStep] = []
130
+ prev: UUID | None = None
131
+ for action_name in actions:
132
+ step_id = uuid4()
133
+ deps = [prev] if prev else []
134
+ steps.append(
135
+ ActionStep(
136
+ id=step_id,
137
+ action_name=action_name,
138
+ parameters={
139
+ "message": response_content.text
140
+ if response_content
141
+ else (message.content.text or ""),
142
+ "thought": response_content.thought if response_content else None,
143
+ "providers": response_content.providers if response_content else [],
144
+ },
145
+ dependencies=[d for d in deps if d is not None],
146
+ )
147
+ )
148
+ prev = step_id
149
+
150
+ plan = ActionPlan(
151
+ id=plan_id,
152
+ goal=response_content.text
153
+ if response_content and response_content.text
154
+ else (message.content.text or "Execute plan"),
155
+ thought=response_content.thought
156
+ if response_content and response_content.thought
157
+ else f"Executing {len(steps)} action(s)",
158
+ total_steps=len(steps),
159
+ current_step=0,
160
+ steps=steps,
161
+ execution_model="sequential",
162
+ state=PlanState(status="pending"),
163
+ metadata={"createdAt": int(time.time() * 1000)},
164
+ )
165
+ self._active_plans[plan_id] = plan
166
+ return plan
167
+
168
+ def _build_planning_prompt(
169
+ self,
170
+ context: dict[str, object],
171
+ message: Memory | None,
172
+ state: State | None,
173
+ ) -> str:
174
+ goal = str(context.get("goal") or "")
175
+ available_actions = (
176
+ context.get("available_actions") or context.get("availableActions") or []
177
+ )
178
+ available_providers = (
179
+ context.get("available_providers") or context.get("availableProviders") or []
180
+ )
181
+ constraints_obj = context.get("constraints") or []
182
+ preferences = (
183
+ context.get("preferences") if isinstance(context.get("preferences"), dict) else {}
184
+ )
185
+
186
+ execution_model = "sequential"
187
+ max_steps = 10
188
+ if isinstance(preferences, dict):
189
+ execution_model = str(
190
+ preferences.get("execution_model")
191
+ or preferences.get("executionModel")
192
+ or "sequential"
193
+ )
194
+ max_steps = int(preferences.get("max_steps") or preferences.get("maxSteps") or 10)
195
+
196
+ if isinstance(available_actions, list):
197
+ actions_text = ", ".join(str(a) for a in available_actions)
198
+ else:
199
+ actions_text = ""
200
+
201
+ providers_text = (
202
+ ", ".join(str(p) for p in available_providers)
203
+ if isinstance(available_providers, list)
204
+ else ""
205
+ )
206
+ constraints_text = ""
207
+ if isinstance(constraints_obj, list):
208
+ parts: list[str] = []
209
+ for c in constraints_obj:
210
+ if isinstance(c, dict):
211
+ c_type = str(c.get("type") or "custom")
212
+ c_desc = c.get("description")
213
+ c_val = c.get("value")
214
+ parts.append(f"{c_type}: {c_desc or c_val}")
215
+ constraints_text = ", ".join(parts)
216
+
217
+ msg_text = (
218
+ f"CONTEXT MESSAGE: {message.content.text}" if message and message.content.text else ""
219
+ )
220
+ state_text = f"CURRENT STATE: {json.dumps(state.values)}" if state else ""
221
+
222
+ return f"""You are an expert AI planning system. Create a comprehensive action plan to achieve the following goal.
223
+
224
+ GOAL: {goal}
225
+
226
+ AVAILABLE ACTIONS: {actions_text}
227
+ AVAILABLE PROVIDERS: {providers_text}
228
+ CONSTRAINTS: {constraints_text}
229
+
230
+ EXECUTION MODEL: {execution_model}
231
+ MAX STEPS: {max_steps}
232
+
233
+ {msg_text}
234
+ {state_text}
235
+
236
+ Create a detailed plan with the following structure:
237
+ <plan>
238
+ <goal>{goal}</goal>
239
+ <execution_model>{execution_model}</execution_model>
240
+ <steps>
241
+ <step>
242
+ <id>step_1</id>
243
+ <action>ACTION_NAME</action>
244
+ <parameters>{{"key": "value"}}</parameters>
245
+ <dependencies>[]</dependencies>
246
+ </step>
247
+ </steps>
248
+ </plan>
249
+
250
+ Focus on:
251
+ 1. Breaking down the goal into logical, executable steps
252
+ 2. Ensuring each step uses available actions
253
+ 3. Managing dependencies between steps
254
+ 4. Providing realistic time estimates
255
+ 5. Including error handling considerations"""
256
+
257
+ def _parse_plan(self, response: str, goal: str) -> ActionPlan:
258
+ plan_id = uuid4()
259
+ steps: list[ActionStep] = []
260
+ step_id_map: dict[str, UUID] = {}
261
+
262
+ step_matches = re.findall(r"<step>(.*?)</step>", response, flags=re.DOTALL)
263
+ for step_match in step_matches:
264
+ id_match = re.search(r"<id>(.*?)</id>", step_match)
265
+ action_match = re.search(r"<action>(.*?)</action>", step_match)
266
+ params_match = re.search(r"<parameters>(.*?)</parameters>", step_match)
267
+ deps_match = re.search(r"<dependencies>(.*?)</dependencies>", step_match)
268
+ if not id_match or not action_match:
269
+ continue
270
+
271
+ orig_id = id_match.group(1).strip()
272
+ actual_id = uuid4()
273
+ step_id_map[orig_id] = actual_id
274
+
275
+ parameters: dict[str, object] = {}
276
+ if params_match:
277
+ try:
278
+ parsed = json.loads(params_match.group(1))
279
+ if isinstance(parsed, dict):
280
+ parameters = parsed
281
+ except Exception:
282
+ parameters = {}
283
+
284
+ dep_strings: list[str] = []
285
+ if deps_match:
286
+ try:
287
+ parsed_deps = json.loads(deps_match.group(1))
288
+ if isinstance(parsed_deps, list):
289
+ dep_strings = [str(d).strip() for d in parsed_deps if str(d).strip()]
290
+ except Exception:
291
+ dep_strings = []
292
+
293
+ steps.append(
294
+ ActionStep(
295
+ id=actual_id,
296
+ action_name=action_match.group(1).strip(),
297
+ parameters=parameters,
298
+ dependencies=[],
299
+ )
300
+ )
301
+ # stash original dependency strings on the parameters dict (private key)
302
+ steps[-1].parameters["_depStrings"] = dep_strings
303
+
304
+ # resolve dependencies
305
+ for step in steps:
306
+ dep_strings_raw = step.parameters.pop("_depStrings", [])
307
+ step_dep_strings: list[str] = []
308
+ if isinstance(dep_strings_raw, list):
309
+ step_dep_strings = [str(d).strip() for d in dep_strings_raw if str(d).strip()]
310
+ deps: list[UUID] = []
311
+ for d in step_dep_strings:
312
+ if d in step_id_map:
313
+ deps.append(step_id_map[d])
314
+ step.dependencies = deps
315
+
316
+ if not steps:
317
+ steps = [
318
+ ActionStep(
319
+ id=uuid4(),
320
+ action_name="REPLY",
321
+ parameters={"text": "I will help you with this request step by step."},
322
+ dependencies=[],
323
+ )
324
+ ]
325
+
326
+ plan = ActionPlan(
327
+ id=plan_id,
328
+ goal=goal,
329
+ thought=f"Plan to achieve: {goal}",
330
+ total_steps=len(steps),
331
+ current_step=0,
332
+ steps=steps,
333
+ execution_model="sequential",
334
+ state=PlanState(status="pending"),
335
+ metadata={"createdAt": int(time.time() * 1000)},
336
+ )
337
+ self._active_plans[plan_id] = plan
338
+ return plan
339
+
340
+ def _normalize_action_name(self, name: str) -> str:
341
+ return re.sub(r"[_\s]+", "", name.strip()).lower()
342
+
343
+ def _build_action_lookup(self) -> dict[str, object]:
344
+ lookup: dict[str, object] = {}
345
+ for action in self.runtime.actions:
346
+ name_norm = self._normalize_action_name(action.name)
347
+ lookup.setdefault(name_norm, action)
348
+ if action.similes:
349
+ for s in action.similes:
350
+ simile_norm = self._normalize_action_name(s)
351
+ lookup.setdefault(simile_norm, action)
352
+ return lookup
353
+
354
+ def _find_action(self, action_name: str, action_lookup: dict[str, object] | None = None):
355
+ target_norm = self._normalize_action_name(action_name)
356
+ if action_lookup is not None:
357
+ return action_lookup.get(target_norm)
358
+
359
+ for action in self.runtime.actions:
360
+ name_norm = self._normalize_action_name(action.name)
361
+ if name_norm == target_norm:
362
+ return action
363
+ if action.similes:
364
+ for s in action.similes:
365
+ if self._normalize_action_name(s) == target_norm:
366
+ return action
367
+ return None
368
+
369
+ async def create_comprehensive_plan(
370
+ self,
371
+ context: dict[str, object],
372
+ message: Memory | None = None,
373
+ state: State | None = None,
374
+ ) -> ActionPlan:
375
+ goal = str(context.get("goal") or "")
376
+ if not goal.strip():
377
+ raise ValueError("Planning context must have a non-empty goal")
378
+
379
+ prompt = self._build_planning_prompt(context, message, state)
380
+ action_lookup = self._build_action_lookup()
381
+ try:
382
+ response = await self.runtime.use_model(
383
+ "TEXT_LARGE",
384
+ {"prompt": prompt, "temperature": 0.3, "maxTokens": 2000},
385
+ )
386
+ plan = self._parse_plan(str(response), goal=goal)
387
+ # enhance plan by downgrading unknown actions to REPLY
388
+ for step in plan.steps:
389
+ if not self._find_action(step.action_name, action_lookup):
390
+ missing = step.action_name
391
+ step.action_name = "REPLY"
392
+ step.parameters = {"text": f"Unable to find action: {missing}"}
393
+ return plan
394
+ except Exception as e:
395
+ logger: Logger = self.runtime.logger
396
+ logger.error(f"Failed to create comprehensive plan: {e}", src="service:planning")
397
+ return self._parse_plan("", goal=goal)
398
+
399
+ async def validate_plan(self, plan: ActionPlan) -> tuple[bool, list[str], list[str]]:
400
+ errors: list[str] = []
401
+ warnings: list[str] = []
402
+
403
+ if not plan.goal or not plan.steps:
404
+ errors.append("Plan missing required fields (goal or steps)")
405
+ if len(plan.steps) == 0:
406
+ errors.append("Plan has no steps")
407
+
408
+ step_ids = {s.id for s in plan.steps}
409
+ action_lookup = self._build_action_lookup()
410
+ for step in plan.steps:
411
+ if not step.action_name:
412
+ errors.append(f"Step {step.id} missing action_name")
413
+ continue
414
+ if self._find_action(step.action_name, action_lookup) is None:
415
+ errors.append(f"Action '{step.action_name}' not found in runtime")
416
+ for dep in step.dependencies:
417
+ if dep not in step_ids:
418
+ errors.append(f"Step '{step.id}' has invalid dependency '{dep}'")
419
+
420
+ if plan.execution_model == "dag":
421
+ if self._detect_cycles(plan.steps):
422
+ errors.append("Plan has circular dependencies")
423
+
424
+ return (len(errors) == 0, errors, warnings)
425
+
426
+ def _detect_cycles(self, steps: list[ActionStep]) -> bool:
427
+ visited: set[UUID] = set()
428
+ stack: set[UUID] = set()
429
+
430
+ by_id = {s.id: s for s in steps}
431
+
432
+ def dfs(step_id: UUID) -> bool:
433
+ if step_id in stack:
434
+ return True
435
+ if step_id in visited:
436
+ return False
437
+ visited.add(step_id)
438
+ stack.add(step_id)
439
+ step = by_id.get(step_id)
440
+ if step:
441
+ for dep in step.dependencies:
442
+ if dfs(dep):
443
+ return True
444
+ stack.discard(step_id)
445
+ return False
446
+
447
+ return any(dfs(s.id) for s in steps)
448
+
449
+ async def execute_plan(
450
+ self,
451
+ plan: ActionPlan,
452
+ message: Memory,
453
+ state: State | None = None,
454
+ callback: HandlerCallback | None = None,
455
+ ) -> PlanExecutionResult:
456
+ start = time.time()
457
+ working_memory: dict[str, object] = {}
458
+ results: list[ActionResult] = []
459
+ errors: list[str] = []
460
+
461
+ execution_state = PlanState(status="running", start_time=start, current_step_index=0)
462
+ execution = PlanExecution(
463
+ state=execution_state, working_memory=working_memory, results=results
464
+ )
465
+ action_lookup = self._build_action_lookup()
466
+ self._executions[plan.id] = execution
467
+
468
+ try:
469
+ if plan.execution_model == "parallel":
470
+ await self._execute_parallel(
471
+ plan, message, state, callback, execution, action_lookup
472
+ )
473
+ elif plan.execution_model == "dag":
474
+ await self._execute_dag(plan, message, state, callback, execution, action_lookup)
475
+ else:
476
+ await self._execute_sequential(
477
+ plan, message, state, callback, execution, action_lookup
478
+ )
479
+
480
+ execution_state.status = "failed" if errors else "completed"
481
+ execution_state.end_time = time.time()
482
+ return PlanExecutionResult(
483
+ plan_id=plan.id,
484
+ success=len(errors) == 0,
485
+ completed_steps=len(results),
486
+ total_steps=len(plan.steps),
487
+ results=results,
488
+ errors=errors if errors else None,
489
+ duration_ms=(time.time() - start) * 1000,
490
+ )
491
+ except Exception as e:
492
+ execution_state.status = "failed"
493
+ execution_state.end_time = time.time()
494
+ execution_state.error = str(e)
495
+ return PlanExecutionResult(
496
+ plan_id=plan.id,
497
+ success=False,
498
+ completed_steps=len(results),
499
+ total_steps=len(plan.steps),
500
+ results=results,
501
+ errors=[str(e), *errors],
502
+ duration_ms=(time.time() - start) * 1000,
503
+ )
504
+ finally:
505
+ self._executions.pop(plan.id, None)
506
+
507
+ async def _execute_sequential(
508
+ self,
509
+ plan: ActionPlan,
510
+ message: Memory,
511
+ state: State | None,
512
+ callback: HandlerCallback | None,
513
+ execution: PlanExecution,
514
+ action_lookup: dict[str, object],
515
+ ) -> None:
516
+ for i, step in enumerate(plan.steps):
517
+ if execution.abort_event.is_set():
518
+ raise RuntimeError("Plan execution aborted")
519
+ result = await self._execute_step(
520
+ step, message, state, callback, execution, action_lookup
521
+ )
522
+ if result is not None:
523
+ execution.results.append(result)
524
+ execution.state.current_step_index = i + 1
525
+
526
+ async def _execute_parallel(
527
+ self,
528
+ plan: ActionPlan,
529
+ message: Memory,
530
+ state: State | None,
531
+ callback: HandlerCallback | None,
532
+ execution: PlanExecution,
533
+ action_lookup: dict[str, object],
534
+ ) -> None:
535
+ tasks = [
536
+ self._execute_step(step, message, state, callback, execution, action_lookup)
537
+ for step in plan.steps
538
+ ]
539
+ results = await asyncio.gather(*tasks, return_exceptions=True)
540
+ for r in results:
541
+ if isinstance(r, ActionResult):
542
+ execution.results.append(r)
543
+
544
+ async def _execute_dag(
545
+ self,
546
+ plan: ActionPlan,
547
+ message: Memory,
548
+ state: State | None,
549
+ callback: HandlerCallback | None,
550
+ execution: PlanExecution,
551
+ action_lookup: dict[str, object],
552
+ ) -> None:
553
+ import heapq
554
+
555
+ by_id: dict[UUID, ActionStep] = {s.id: s for s in plan.steps}
556
+ in_degree: dict[UUID, int] = {s.id: len(s.dependencies) for s in plan.steps}
557
+ dependents: dict[UUID, list[UUID]] = {}
558
+ index_by_id: dict[UUID, int] = {}
559
+ for idx, step in enumerate(plan.steps):
560
+ index_by_id[step.id] = idx
561
+ for dep in step.dependencies:
562
+ dependents.setdefault(dep, []).append(step.id)
563
+
564
+ ready_heap: list[tuple[int, UUID]] = [
565
+ (index_by_id[sid], sid) for sid, count in in_degree.items() if count == 0
566
+ ]
567
+ heapq.heapify(ready_heap)
568
+
569
+ completed_count = 0
570
+ while ready_heap and not execution.abort_event.is_set():
571
+ ready_batch: list[ActionStep] = []
572
+ while ready_heap:
573
+ _, sid = heapq.heappop(ready_heap)
574
+ ready_batch.append(by_id[sid])
575
+
576
+ if not ready_batch:
577
+ break
578
+
579
+ results = await asyncio.gather(
580
+ *[
581
+ self._execute_step(step, message, state, callback, execution, action_lookup)
582
+ for step in ready_batch
583
+ ],
584
+ return_exceptions=True,
585
+ )
586
+
587
+ for step, r in zip(ready_batch, results, strict=False):
588
+ completed_count += 1
589
+ if isinstance(r, ActionResult):
590
+ execution.results.append(r)
591
+
592
+ for nxt in dependents.get(step.id, []):
593
+ remaining = in_degree.get(nxt, 0)
594
+ if remaining > 0:
595
+ remaining -= 1
596
+ in_degree[nxt] = remaining
597
+ if remaining == 0:
598
+ heapq.heappush(ready_heap, (index_by_id[nxt], nxt))
599
+
600
+ if completed_count != len(plan.steps):
601
+ raise RuntimeError("No steps ready to execute - possible circular dependency")
602
+
603
+ async def _execute_step(
604
+ self,
605
+ step: ActionStep,
606
+ message: Memory,
607
+ state: State | None,
608
+ callback: HandlerCallback | None,
609
+ execution: PlanExecution,
610
+ action_lookup: dict[str, object],
611
+ ) -> ActionResult | None:
612
+ action = self._find_action(step.action_name, action_lookup)
613
+ if action is None:
614
+ raise RuntimeError(f"Action '{step.action_name}' not found")
615
+
616
+ previous_results = execution.results
617
+ action_context = ActionContext(previous_results=previous_results)
618
+
619
+ retries = 0
620
+ max_retries = step.retry_policy.max_retries if step.retry_policy else 0
621
+ while retries <= max_retries:
622
+ if execution.abort_event.is_set():
623
+ raise RuntimeError("Plan execution aborted")
624
+ try:
625
+ options = HandlerOptions(
626
+ action_context=action_context,
627
+ parameters=step.parameters,
628
+ )
629
+ # Attach extra execution context (allowed by extra="allow")
630
+ options.previous_results = previous_results # type: ignore[attr-defined]
631
+ options.context = {"workingMemory": execution.working_memory} # type: ignore[attr-defined]
632
+
633
+ validate_fn = getattr(action, "validate", None) or getattr(
634
+ action, "validate_fn", None
635
+ )
636
+ ok = await validate_fn(self.runtime, message, state) if validate_fn else True
637
+ if not ok:
638
+ return None
639
+
640
+ result = await action.handler(self.runtime, message, state, options, callback, None)
641
+ if result is None:
642
+ return None
643
+
644
+ if result.data is None:
645
+ result.data = {}
646
+ if isinstance(result.data, dict):
647
+ result.data["stepId"] = str(step.id)
648
+ result.data["actionName"] = step.action_name
649
+ result.data["executedAt"] = int(time.time() * 1000)
650
+ return result
651
+ except Exception as e:
652
+ retries += 1
653
+ if retries > max_retries:
654
+ raise e
655
+ backoff = step.retry_policy.backoff_ms * (
656
+ step.retry_policy.backoff_multiplier ** (retries - 1)
657
+ )
658
+ await asyncio.sleep(backoff / 1000.0)
659
+
660
+ return None
661
+
662
+ async def get_plan_status(self, plan_id: UUID) -> PlanState | None:
663
+ execution = self._executions.get(plan_id)
664
+ return execution.state if execution else None
665
+
666
+ async def cancel_plan(self, plan_id: UUID) -> bool:
667
+ execution = self._executions.get(plan_id)
668
+ if not execution:
669
+ return False
670
+ execution.abort_event.set()
671
+ execution.state.status = "cancelled"
672
+ execution.state.end_time = time.time()
673
+ return True
674
+
675
+ async def adapt_plan(
676
+ self,
677
+ plan: ActionPlan,
678
+ current_step_index: int,
679
+ results: list[ActionResult],
680
+ error: Exception | None = None,
681
+ ) -> ActionPlan:
682
+ # For now, keep parity with TS structure by asking the model to return new steps.
683
+ prompt = f"""You are an expert AI adaptation system. A plan execution has encountered an issue and needs adaptation.
684
+
685
+ ORIGINAL PLAN: {json.dumps({"id": str(plan.id), "goal": plan.goal, "steps": [{"id": str(s.id), "action": s.action_name} for s in plan.steps]}, indent=2)}
686
+ CURRENT STEP INDEX: {current_step_index}
687
+ COMPLETED RESULTS: {json.dumps([MessageToDict(r, preserving_proto_field_name=False) for r in results], indent=2)}
688
+ {f"ERROR: {str(error)}" if error else ""}
689
+
690
+ Return the adapted plan in the same XML format as the original planning response."""
691
+
692
+ try:
693
+ response = await self.runtime.use_model(
694
+ "TEXT_LARGE",
695
+ {"prompt": prompt, "temperature": 0.4, "maxTokens": 1500},
696
+ )
697
+ adapted = self._parse_plan(str(response), goal=plan.goal)
698
+ new_steps = plan.steps[:current_step_index] + adapted.steps
699
+ plan.steps = new_steps
700
+ plan.total_steps = len(new_steps)
701
+ return plan
702
+ except Exception:
703
+ # Fallback: append a REPLY step
704
+ fallback = ActionStep(
705
+ id=uuid4(),
706
+ action_name="REPLY",
707
+ parameters={"text": "Plan adaptation completed successfully"},
708
+ dependencies=[],
709
+ )
710
+ plan.steps = plan.steps[:current_step_index] + [fallback]
711
+ plan.total_steps = len(plan.steps)
712
+ return plan