@elizaos/python 2.0.0-alpha.10 → 2.0.0-alpha.11

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 (99) hide show
  1. package/elizaos/__init__.py +0 -1
  2. package/elizaos/advanced_capabilities/actions/schedule_follow_up.py +70 -15
  3. package/elizaos/advanced_capabilities/actions/send_message.py +9 -184
  4. package/elizaos/advanced_memory/memory_service.py +15 -17
  5. package/elizaos/advanced_planning/planning_service.py +26 -14
  6. package/elizaos/basic_capabilities/providers/actions.py +118 -29
  7. package/elizaos/basic_capabilities/providers/character.py +19 -21
  8. package/elizaos/basic_capabilities/providers/current_time.py +7 -4
  9. package/elizaos/basic_capabilities/providers/time.py +7 -4
  10. package/elizaos/bootstrap/__init__.py +21 -2
  11. package/elizaos/bootstrap/actions/schedule_follow_up.py +65 -7
  12. package/elizaos/bootstrap/actions/send_message.py +162 -15
  13. package/elizaos/bootstrap/autonomy/service.py +230 -28
  14. package/elizaos/bootstrap/providers/actions.py +118 -27
  15. package/elizaos/bootstrap/providers/agent_settings.py +1 -0
  16. package/elizaos/bootstrap/providers/attachments.py +1 -0
  17. package/elizaos/bootstrap/providers/capabilities.py +1 -0
  18. package/elizaos/bootstrap/providers/character.py +1 -0
  19. package/elizaos/bootstrap/providers/choice.py +1 -0
  20. package/elizaos/bootstrap/providers/contacts.py +1 -0
  21. package/elizaos/bootstrap/providers/current_time.py +8 -2
  22. package/elizaos/bootstrap/providers/entities.py +1 -0
  23. package/elizaos/bootstrap/providers/evaluators.py +1 -0
  24. package/elizaos/bootstrap/providers/facts.py +1 -0
  25. package/elizaos/bootstrap/providers/follow_ups.py +1 -0
  26. package/elizaos/bootstrap/providers/knowledge.py +1 -0
  27. package/elizaos/bootstrap/providers/providers_list.py +1 -0
  28. package/elizaos/bootstrap/providers/relationships.py +1 -0
  29. package/elizaos/bootstrap/providers/roles.py +1 -0
  30. package/elizaos/bootstrap/providers/settings.py +1 -0
  31. package/elizaos/bootstrap/providers/time.py +8 -4
  32. package/elizaos/bootstrap/providers/world.py +1 -0
  33. package/elizaos/deterministic.py +193 -0
  34. package/elizaos/generated/__init__.py +1 -0
  35. package/elizaos/generated/action_docs.py +3181 -0
  36. package/elizaos/generated/spec_helpers.py +175 -0
  37. package/elizaos/media/mime.py +2 -2
  38. package/elizaos/media/search.py +23 -23
  39. package/elizaos/runtime.py +152 -39
  40. package/elizaos/services/message_service.py +2 -6
  41. package/elizaos/types/components.py +2 -2
  42. package/elizaos/types/generated/__init__.py +12 -0
  43. package/elizaos/types/generated/eliza/v1/agent_pb2.py +63 -0
  44. package/elizaos/types/generated/eliza/v1/agent_pb2.pyi +161 -0
  45. package/elizaos/types/generated/eliza/v1/components_pb2.py +65 -0
  46. package/elizaos/types/generated/eliza/v1/components_pb2.pyi +160 -0
  47. package/elizaos/types/generated/eliza/v1/database_pb2.py +78 -0
  48. package/elizaos/types/generated/eliza/v1/database_pb2.pyi +305 -0
  49. package/elizaos/types/generated/eliza/v1/environment_pb2.py +58 -0
  50. package/elizaos/types/generated/eliza/v1/environment_pb2.pyi +135 -0
  51. package/elizaos/types/generated/eliza/v1/events_pb2.py +82 -0
  52. package/elizaos/types/generated/eliza/v1/events_pb2.pyi +322 -0
  53. package/elizaos/types/generated/eliza/v1/ipc_pb2.py +113 -0
  54. package/elizaos/types/generated/eliza/v1/ipc_pb2.pyi +367 -0
  55. package/elizaos/types/generated/eliza/v1/knowledge_pb2.py +41 -0
  56. package/elizaos/types/generated/eliza/v1/knowledge_pb2.pyi +26 -0
  57. package/elizaos/types/generated/eliza/v1/memory_pb2.py +55 -0
  58. package/elizaos/types/generated/eliza/v1/memory_pb2.pyi +111 -0
  59. package/elizaos/types/generated/eliza/v1/message_service_pb2.py +48 -0
  60. package/elizaos/types/generated/eliza/v1/message_service_pb2.pyi +69 -0
  61. package/elizaos/types/generated/eliza/v1/messaging_pb2.py +51 -0
  62. package/elizaos/types/generated/eliza/v1/messaging_pb2.pyi +97 -0
  63. package/elizaos/types/generated/eliza/v1/model_pb2.py +84 -0
  64. package/elizaos/types/generated/eliza/v1/model_pb2.pyi +280 -0
  65. package/elizaos/types/generated/eliza/v1/payment_pb2.py +44 -0
  66. package/elizaos/types/generated/eliza/v1/payment_pb2.pyi +70 -0
  67. package/elizaos/types/generated/eliza/v1/plugin_pb2.py +68 -0
  68. package/elizaos/types/generated/eliza/v1/plugin_pb2.pyi +145 -0
  69. package/elizaos/types/generated/eliza/v1/primitives_pb2.py +48 -0
  70. package/elizaos/types/generated/eliza/v1/primitives_pb2.pyi +92 -0
  71. package/elizaos/types/generated/eliza/v1/prompts_pb2.py +52 -0
  72. package/elizaos/types/generated/eliza/v1/prompts_pb2.pyi +74 -0
  73. package/elizaos/types/generated/eliza/v1/service_interfaces_pb2.py +211 -0
  74. package/elizaos/types/generated/eliza/v1/service_interfaces_pb2.pyi +1296 -0
  75. package/elizaos/types/generated/eliza/v1/service_pb2.py +42 -0
  76. package/elizaos/types/generated/eliza/v1/service_pb2.pyi +69 -0
  77. package/elizaos/types/generated/eliza/v1/settings_pb2.py +58 -0
  78. package/elizaos/types/generated/eliza/v1/settings_pb2.pyi +85 -0
  79. package/elizaos/types/generated/eliza/v1/state_pb2.py +60 -0
  80. package/elizaos/types/generated/eliza/v1/state_pb2.pyi +114 -0
  81. package/elizaos/types/generated/eliza/v1/task_pb2.py +42 -0
  82. package/elizaos/types/generated/eliza/v1/task_pb2.pyi +58 -0
  83. package/elizaos/types/generated/eliza/v1/tee_pb2.py +52 -0
  84. package/elizaos/types/generated/eliza/v1/tee_pb2.pyi +90 -0
  85. package/elizaos/types/generated/eliza/v1/testing_pb2.py +39 -0
  86. package/elizaos/types/generated/eliza/v1/testing_pb2.pyi +23 -0
  87. package/elizaos/types/model.py +3 -0
  88. package/elizaos/types/runtime.py +1 -1
  89. package/package.json +3 -2
  90. package/tests/test_action_parameters.py +2 -3
  91. package/tests/test_advanced_memory_behavior.py +0 -2
  92. package/tests/test_advanced_memory_flag.py +0 -2
  93. package/tests/test_advanced_planning_behavior.py +11 -5
  94. package/tests/test_autonomy.py +11 -1
  95. package/tests/test_runtime.py +8 -17
  96. package/tests/test_schedule_follow_up_action.py +260 -0
  97. package/tests/test_send_message_action_targets.py +114 -0
  98. package/tests/test_settings_crypto.py +0 -2
  99. package/uv.lock +1565 -0
@@ -1,10 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING
3
+ import contextlib
4
+ from typing import TYPE_CHECKING, TypeVar, cast
4
5
 
5
6
  from google.protobuf.json_format import MessageToDict
6
7
 
7
8
  from elizaos.action_docs import get_canonical_action_example_calls
9
+ from elizaos.deterministic import (
10
+ build_conversation_seed,
11
+ build_deterministic_seed,
12
+ deterministic_int,
13
+ )
8
14
  from elizaos.generated.spec_helpers import require_provider_spec
9
15
  from elizaos.types import Provider, ProviderResult
10
16
  from elizaos.types.components import ActionExample
@@ -23,10 +29,6 @@ if TYPE_CHECKING:
23
29
  _spec = require_provider_spec("ACTIONS")
24
30
 
25
31
 
26
- def format_action_names(actions: list[Action]) -> str:
27
- return ", ".join(action.name for action in actions)
28
-
29
-
30
32
  def _format_parameter_type(schema: ActionParameterSchema) -> str:
31
33
  if schema.type == "number" and (schema.minimum is not None or schema.maximum is not None):
32
34
  min_val = schema.minimum if schema.minimum is not None else "∞"
@@ -35,9 +37,10 @@ def _format_parameter_type(schema: ActionParameterSchema) -> str:
35
37
  return schema.type
36
38
 
37
39
 
38
- def _get_param_schema(param: ActionParameter) -> object:
40
+ def _get_param_schema(param: ActionParameter) -> ActionParameterSchema | None:
39
41
  """Get schema from ActionParameter, handling both Pydantic and protobuf variants."""
40
- return getattr(param, "schema_def", None) or getattr(param, "schema", None)
42
+ schema = getattr(param, "schema_def", None) or getattr(param, "schema", None)
43
+ return cast("ActionParameterSchema | None", schema)
41
44
 
42
45
 
43
46
  def _format_action_parameters(parameters: list[ActionParameter]) -> str:
@@ -64,9 +67,23 @@ def _format_action_parameters(parameters: list[ActionParameter]) -> str:
64
67
  return "\n".join(lines)
65
68
 
66
69
 
67
- def format_actions(actions: list[Action]) -> str:
70
+ T = TypeVar("T")
71
+
72
+
73
+ def _deterministic_shuffle(items: list[T], seed: str, surface: str = "shuffle") -> list[T]:
74
+ shuffled = list(items)
75
+ for i in range(len(shuffled) - 1, 0, -1):
76
+ j = deterministic_int(seed, f"{surface}:{i}", i + 1)
77
+ shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
78
+ return shuffled
79
+
80
+
81
+ def format_actions(actions: list[Action], seed: str | None = None) -> str:
82
+ deterministic_seed = seed or build_deterministic_seed(
83
+ ["actions-format", ",".join(action.name for action in actions)]
84
+ )
68
85
  lines: list[str] = []
69
- for action in actions:
86
+ for action in _deterministic_shuffle(actions, deterministic_seed, "actions"):
70
87
  line = f"- **{action.name}**: {action.description or 'No description'}"
71
88
  if action.parameters:
72
89
  params_text = _format_action_parameters(action.parameters)
@@ -83,7 +100,24 @@ def _replace_name_placeholders(text: str) -> str:
83
100
  return text
84
101
 
85
102
 
86
- def format_action_examples(actions: list[Action], max_examples: int = 10) -> str:
103
+ def _replace_name_placeholders_seeded(text: str, seed: str, example_index: int) -> str:
104
+ names = ["Alex", "Jordan", "Sam", "Taylor", "Riley"]
105
+ output = text
106
+ for placeholder_index in range(1, 6):
107
+ name_index = deterministic_int(
108
+ seed,
109
+ f"example:{example_index}:name:{placeholder_index}",
110
+ len(names),
111
+ )
112
+ output = output.replace(f"{{{{name{placeholder_index}}}}}", names[name_index])
113
+ return output
114
+
115
+
116
+ def format_action_examples(
117
+ actions: list[Action],
118
+ max_examples: int = 10,
119
+ seed: str | None = None,
120
+ ) -> str:
87
121
  """
88
122
  Format a deterministic subset of action examples for prompt context.
89
123
 
@@ -92,27 +126,60 @@ def format_action_examples(actions: list[Action], max_examples: int = 10) -> str
92
126
  if max_examples <= 0:
93
127
  return ""
94
128
 
95
- examples: list[list[ActionExample]] = []
96
- for action in sorted(actions, key=lambda a: a.name):
97
- if not action.examples:
98
- continue
99
- for ex in action.examples:
100
- if isinstance(ex, list) and ex:
101
- examples.append(ex)
102
- if len(examples) >= max_examples:
103
- break
104
- if len(examples) >= max_examples:
105
- break
106
-
107
- if not examples:
129
+ actions_with_examples = [
130
+ action
131
+ for action in actions
132
+ if action.examples and isinstance(action.examples, list) and len(action.examples) > 0
133
+ ]
134
+ if not actions_with_examples:
108
135
  return ""
109
136
 
137
+ examples_copy: list[list[list[ActionExample]]] = [
138
+ [example for example in (action.examples or []) if isinstance(example, list) and example]
139
+ for action in actions_with_examples
140
+ ]
141
+ available_action_indices = [
142
+ idx for idx, action_examples in enumerate(examples_copy) if action_examples
143
+ ]
144
+
145
+ selection_seed = seed or build_deterministic_seed(
146
+ [
147
+ "action-examples",
148
+ ",".join(action.name for action in actions_with_examples),
149
+ max_examples,
150
+ ]
151
+ )
152
+
153
+ selected_examples: list[list[ActionExample]] = []
154
+ iteration = 0
155
+ while len(selected_examples) < max_examples and available_action_indices:
156
+ random_index = deterministic_int(
157
+ selection_seed,
158
+ f"action-index:{iteration}",
159
+ len(available_action_indices),
160
+ )
161
+ action_index = available_action_indices[random_index]
162
+ action_examples = examples_copy[action_index]
163
+
164
+ example_index = deterministic_int(
165
+ selection_seed,
166
+ f"example-index:{iteration}",
167
+ len(action_examples),
168
+ )
169
+ selected_examples.append(action_examples.pop(example_index))
170
+ iteration += 1
171
+
172
+ if not action_examples:
173
+ available_action_indices.pop(random_index)
174
+
110
175
  blocks: list[str] = []
111
- for ex in examples:
176
+ for example_index, ex in enumerate(selected_examples):
112
177
  lines: list[str] = []
113
178
  for msg in ex:
114
179
  msg_text = msg.content.text if msg.content and msg.content.text else ""
115
- lines.append(f"{msg.name}: {_replace_name_placeholders(msg_text)}")
180
+ lines.append(
181
+ f"{msg.name}: {_replace_name_placeholders_seeded(msg_text, selection_seed, example_index)}"
182
+ )
116
183
  blocks.append("\n".join(lines))
117
184
 
118
185
  return "\n\n".join(blocks)
@@ -182,6 +249,17 @@ def format_action_call_examples(actions: list[Action], max_examples: int = 5) ->
182
249
  return "\n\n".join(blocks)
183
250
 
184
251
 
252
+ def format_action_names(actions: list[Action], seed: str | None = None) -> str:
253
+ if not actions:
254
+ return ""
255
+
256
+ deterministic_seed = seed or build_deterministic_seed(
257
+ ["action-names", ",".join(action.name for action in actions)]
258
+ )
259
+ shuffled = _deterministic_shuffle(actions, deterministic_seed, "actions")
260
+ return ", ".join(action.name for action in shuffled)
261
+
262
+
185
263
  async def get_actions(
186
264
  runtime: IAgentRuntime,
187
265
  message: Memory,
@@ -193,16 +271,27 @@ async def get_actions(
193
271
  # Support both validate and validate_fn for backwards compatibility
194
272
  validate_fn = getattr(action, "validate", None) or getattr(action, "validate_fn", None)
195
273
  if validate_fn:
196
- is_valid = await validate_fn(runtime, message, state)
274
+ try:
275
+ is_valid = await validate_fn(runtime, message, state)
276
+ except Exception:
277
+ if hasattr(runtime, "logger"):
278
+ with contextlib.suppress(Exception):
279
+ runtime.logger.warning(
280
+ f"Action validation failed for {action.name}; excluding from prompt"
281
+ )
282
+ is_valid = False
197
283
  if is_valid:
198
284
  validated_actions.append(action)
199
285
  else:
200
286
  # If no validation function, include the action
201
287
  validated_actions.append(action)
202
288
 
203
- action_names = format_action_names(validated_actions)
204
- actions_text = format_actions(validated_actions)
205
- examples_text = format_action_examples(validated_actions, max_examples=10)
289
+ action_seed = build_conversation_seed(runtime, message, state, "provider:actions")
290
+ action_names = format_action_names(validated_actions, seed=f"{action_seed}:names")
291
+ actions_text = format_actions(validated_actions, seed=f"{action_seed}:descriptions")
292
+ examples_text = format_action_examples(
293
+ validated_actions, max_examples=10, seed=f"{action_seed}:examples"
294
+ )
206
295
  call_examples_text = format_action_call_examples(validated_actions, max_examples=5)
207
296
 
208
297
  text_parts: list[str] = [f"Possible response actions: {action_names}"]
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Iterable
3
4
  from typing import TYPE_CHECKING
4
5
 
5
6
  from elizaos.generated.spec_helpers import require_provider_spec
@@ -27,6 +28,19 @@ def _resolve_name_list(items: list[str], name: str) -> list[str]:
27
28
  return [_resolve_name(s, name) for s in items]
28
29
 
29
30
 
31
+ def _coerce_text_list(value: object) -> list[str]:
32
+ """Normalize protobuf repeated fields and plain Python values to list[str]."""
33
+ if value is None:
34
+ return []
35
+ if isinstance(value, str):
36
+ return [value]
37
+ if isinstance(value, (list, tuple)):
38
+ return [str(item) for item in value]
39
+ if isinstance(value, Iterable):
40
+ return [str(item) for item in value]
41
+ return [str(value)]
42
+
43
+
30
44
  async def get_character_context(
31
45
  runtime: IAgentRuntime,
32
46
  message: Memory,
@@ -47,11 +61,7 @@ async def get_character_context(
47
61
  sections.append(f"\n## Bio\n{bio_text}")
48
62
 
49
63
  if character.adjectives:
50
- adjectives = (
51
- character.adjectives
52
- if isinstance(character.adjectives, list)
53
- else [character.adjectives]
54
- )
64
+ adjectives = _coerce_text_list(character.adjectives)
55
65
  resolved_adjectives = _resolve_name_list(adjectives, agent_name)
56
66
  sections.append(f"\n## Personality Traits\n{', '.join(resolved_adjectives)}")
57
67
 
@@ -65,34 +75,22 @@ async def get_character_context(
65
75
  sections.append(f"\n## Background\n{lore_text}")
66
76
 
67
77
  if character.topics:
68
- topics = character.topics if isinstance(character.topics, list) else [character.topics]
78
+ topics = _coerce_text_list(character.topics)
69
79
  resolved_topics = _resolve_name_list(topics, agent_name)
70
80
  sections.append(f"\n## Knowledge Areas\n{', '.join(resolved_topics)}")
71
81
 
72
82
  if character.style:
73
83
  style_sections: list[str] = []
74
84
  if character.style.all:
75
- all_style = (
76
- character.style.all
77
- if isinstance(character.style.all, list)
78
- else [character.style.all]
79
- )
85
+ all_style = _coerce_text_list(character.style.all)
80
86
  resolved_all = _resolve_name_list(all_style, agent_name)
81
87
  style_sections.append(f"General: {', '.join(resolved_all)}")
82
88
  if character.style.chat:
83
- chat_style = (
84
- character.style.chat
85
- if isinstance(character.style.chat, list)
86
- else [character.style.chat]
87
- )
89
+ chat_style = _coerce_text_list(character.style.chat)
88
90
  resolved_chat = _resolve_name_list(chat_style, agent_name)
89
91
  style_sections.append(f"Chat: {', '.join(resolved_chat)}")
90
92
  if character.style.post:
91
- post_style = (
92
- character.style.post
93
- if isinstance(character.style.post, list)
94
- else [character.style.post]
95
- )
93
+ post_style = _coerce_text_list(character.style.post)
96
94
  resolved_post = _resolve_name_list(post_style, agent_name)
97
95
  style_sections.append(f"Posts: {', '.join(resolved_post)}")
98
96
  if style_sections:
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import UTC, datetime
4
3
  from typing import TYPE_CHECKING
5
4
 
5
+ from elizaos.deterministic import get_prompt_reference_datetime
6
6
  from elizaos.generated.spec_helpers import require_provider_spec
7
7
  from elizaos.types import Provider, ProviderResult
8
8
 
@@ -18,9 +18,12 @@ async def get_current_time_context(
18
18
  message: Memory,
19
19
  state: State | None = None,
20
20
  ) -> ProviderResult:
21
- _ = runtime, message, state
22
-
23
- now = datetime.now(UTC)
21
+ now = get_prompt_reference_datetime(
22
+ runtime,
23
+ message,
24
+ state,
25
+ "provider:current_time",
26
+ )
24
27
 
25
28
  iso_timestamp = now.isoformat()
26
29
  human_readable = now.strftime("%A, %B %d, %Y at %H:%M:%S UTC")
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from datetime import UTC, datetime
4
3
  from typing import TYPE_CHECKING
5
4
 
5
+ from elizaos.deterministic import get_prompt_reference_datetime
6
6
  from elizaos.generated.spec_helpers import require_provider_spec
7
7
  from elizaos.types import Provider, ProviderResult
8
8
 
@@ -18,9 +18,12 @@ async def get_time_context(
18
18
  message: Memory,
19
19
  state: State | None = None,
20
20
  ) -> ProviderResult:
21
- _ = runtime, message, state
22
-
23
- now = datetime.now(UTC)
21
+ now = get_prompt_reference_datetime(
22
+ runtime,
23
+ message,
24
+ state,
25
+ "provider:time",
26
+ )
24
27
  iso_string = now.isoformat()
25
28
  timestamp_ms = int(now.timestamp() * 1000)
26
29
  human_readable = now.strftime("%A, %B %d, %Y at %H:%M:%S UTC")
@@ -1,7 +1,12 @@
1
1
  """elizaOS Bootstrap Plugin - Python implementation."""
2
2
 
3
- from .plugin import bootstrap_plugin, create_bootstrap_plugin
4
- from .types import CapabilityConfig
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .plugin import bootstrap_plugin, create_bootstrap_plugin
9
+ from .types import CapabilityConfig
5
10
 
6
11
  __version__ = "2.0.0-alpha.0"
7
12
  __all__ = [
@@ -10,3 +15,17 @@ __all__ = [
10
15
  "CapabilityConfig",
11
16
  "__version__",
12
17
  ]
18
+
19
+
20
+ def __getattr__(name: str) -> object:
21
+ if name in {"bootstrap_plugin", "create_bootstrap_plugin"}:
22
+ from .plugin import bootstrap_plugin, create_bootstrap_plugin
23
+
24
+ if name == "bootstrap_plugin":
25
+ return bootstrap_plugin
26
+ return create_bootstrap_plugin
27
+ if name == "CapabilityConfig":
28
+ from .types import CapabilityConfig
29
+
30
+ return CapabilityConfig
31
+ raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
@@ -3,8 +3,10 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass, field
4
4
  from datetime import datetime
5
5
  from typing import TYPE_CHECKING
6
+ from uuid import UUID as StdUUID
6
7
 
7
8
  from elizaos.bootstrap.utils.xml import parse_key_value_xml
9
+ from elizaos.deterministic import get_prompt_reference_datetime
8
10
  from elizaos.generated.spec_helpers import require_action_spec
9
11
  from elizaos.prompts import SCHEDULE_FOLLOW_UP_TEMPLATE
10
12
  from elizaos.types import (
@@ -48,6 +50,25 @@ def _convert_spec_examples() -> list[list[ActionExample]]:
48
50
  return []
49
51
 
50
52
 
53
+ def _normalize_priority(raw_priority: str) -> str:
54
+ normalized = raw_priority.strip().lower()
55
+ if normalized in {"high", "medium", "low"}:
56
+ return normalized
57
+ return "medium"
58
+
59
+
60
+ def _coerce_uuid(value: object | None) -> StdUUID | None:
61
+ if value is None:
62
+ return None
63
+ text = str(value).strip()
64
+ if not text:
65
+ return None
66
+ try:
67
+ return StdUUID(text)
68
+ except ValueError:
69
+ return None
70
+
71
+
51
72
  @dataclass
52
73
  class ScheduleFollowUpAction:
53
74
  name: str = _spec["name"]
@@ -93,7 +114,12 @@ class ScheduleFollowUpAction:
93
114
  )
94
115
 
95
116
  state = await runtime.compose_state(message, ["RECENT_MESSAGES", "ENTITIES"])
96
- state.values["currentDateTime"] = datetime.utcnow().isoformat()
117
+ state.values["currentDateTime"] = get_prompt_reference_datetime(
118
+ runtime,
119
+ message,
120
+ state,
121
+ "action:schedule_follow_up",
122
+ ).isoformat()
97
123
 
98
124
  prompt = runtime.compose_prompt_from_state(
99
125
  state=state,
@@ -114,14 +140,46 @@ class ScheduleFollowUpAction:
114
140
  contact_name = str(parsed.get("contactName", ""))
115
141
  scheduled_at_str = str(parsed.get("scheduledAt", ""))
116
142
  reason = str(parsed.get("reason", "Follow-up"))
117
- priority = str(parsed.get("priority", "medium"))
143
+ priority = _normalize_priority(str(parsed.get("priority", "medium")))
118
144
  follow_up_message = str(parsed.get("message", ""))
145
+ parsed_entity_id = _coerce_uuid(parsed.get("entityId"))
146
+ message_entity_id = _coerce_uuid(message.entity_id)
147
+
148
+ try:
149
+ scheduled_at = datetime.fromisoformat(scheduled_at_str.replace("Z", "+00:00"))
150
+ except ValueError:
151
+ return ActionResult(
152
+ text="Could not parse the follow-up date/time",
153
+ success=False,
154
+ values={"error": True},
155
+ data={"error": "Invalid follow-up datetime"},
156
+ )
157
+
158
+ entity_id_uuid = parsed_entity_id or message_entity_id
159
+ if entity_id_uuid is None and contact_name:
160
+ contacts = await rolodex_service.search_contacts(search_term=contact_name)
161
+ if contacts:
162
+ entity_id_uuid = contacts[0].entity_id
119
163
 
120
- scheduled_at = datetime.fromisoformat(scheduled_at_str.replace("Z", "+00:00"))
164
+ if entity_id_uuid is None:
165
+ return ActionResult(
166
+ text=f"Could not determine which contact to schedule for ({contact_name}).",
167
+ success=False,
168
+ values={"error": True},
169
+ data={"error": "Missing contact entity id"},
170
+ )
171
+
172
+ contact = await rolodex_service.get_contact(entity_id_uuid)
173
+ if contact is None:
174
+ return ActionResult(
175
+ text=f"Contact '{contact_name}' was not found in the rolodex.",
176
+ success=False,
177
+ values={"error": True},
178
+ data={"error": "Contact not found"},
179
+ )
121
180
 
122
- entity_id = message.entity_id
123
181
  await follow_up_service.schedule_follow_up(
124
- entity_id=entity_id,
182
+ entity_id=entity_id_uuid,
125
183
  scheduled_at=scheduled_at,
126
184
  reason=reason,
127
185
  priority=priority,
@@ -137,11 +195,11 @@ class ScheduleFollowUpAction:
137
195
  text=response_text,
138
196
  success=True,
139
197
  values={
140
- "contactId": str(entity_id),
198
+ "contactId": str(entity_id_uuid),
141
199
  "scheduledAt": scheduled_at.isoformat(),
142
200
  },
143
201
  data={
144
- "contactId": str(entity_id),
202
+ "contactId": str(entity_id_uuid),
145
203
  "contactName": contact_name,
146
204
  "scheduledAt": scheduled_at.isoformat(),
147
205
  "reason": reason,