planar 0.5.0__py3-none-any.whl

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 (289) hide show
  1. planar/.__init__.py.un~ +0 -0
  2. planar/._version.py.un~ +0 -0
  3. planar/.app.py.un~ +0 -0
  4. planar/.cli.py.un~ +0 -0
  5. planar/.config.py.un~ +0 -0
  6. planar/.context.py.un~ +0 -0
  7. planar/.db.py.un~ +0 -0
  8. planar/.di.py.un~ +0 -0
  9. planar/.engine.py.un~ +0 -0
  10. planar/.files.py.un~ +0 -0
  11. planar/.log_context.py.un~ +0 -0
  12. planar/.log_metadata.py.un~ +0 -0
  13. planar/.logging.py.un~ +0 -0
  14. planar/.object_registry.py.un~ +0 -0
  15. planar/.otel.py.un~ +0 -0
  16. planar/.server.py.un~ +0 -0
  17. planar/.session.py.un~ +0 -0
  18. planar/.sqlalchemy.py.un~ +0 -0
  19. planar/.task_local.py.un~ +0 -0
  20. planar/.test_app.py.un~ +0 -0
  21. planar/.test_config.py.un~ +0 -0
  22. planar/.test_object_config.py.un~ +0 -0
  23. planar/.test_sqlalchemy.py.un~ +0 -0
  24. planar/.test_utils.py.un~ +0 -0
  25. planar/.util.py.un~ +0 -0
  26. planar/.utils.py.un~ +0 -0
  27. planar/__init__.py +26 -0
  28. planar/_version.py +1 -0
  29. planar/ai/.__init__.py.un~ +0 -0
  30. planar/ai/._models.py.un~ +0 -0
  31. planar/ai/.agent.py.un~ +0 -0
  32. planar/ai/.agent_utils.py.un~ +0 -0
  33. planar/ai/.events.py.un~ +0 -0
  34. planar/ai/.files.py.un~ +0 -0
  35. planar/ai/.models.py.un~ +0 -0
  36. planar/ai/.providers.py.un~ +0 -0
  37. planar/ai/.pydantic_ai.py.un~ +0 -0
  38. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  39. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  40. planar/ai/.step.py.un~ +0 -0
  41. planar/ai/.test_agent.py.un~ +0 -0
  42. planar/ai/.test_agent_serialization.py.un~ +0 -0
  43. planar/ai/.test_providers.py.un~ +0 -0
  44. planar/ai/.utils.py.un~ +0 -0
  45. planar/ai/__init__.py +15 -0
  46. planar/ai/agent.py +457 -0
  47. planar/ai/agent_utils.py +205 -0
  48. planar/ai/models.py +140 -0
  49. planar/ai/providers.py +1088 -0
  50. planar/ai/test_agent.py +1298 -0
  51. planar/ai/test_agent_serialization.py +229 -0
  52. planar/ai/test_providers.py +463 -0
  53. planar/ai/utils.py +102 -0
  54. planar/app.py +494 -0
  55. planar/cli.py +282 -0
  56. planar/config.py +544 -0
  57. planar/db/.db.py.un~ +0 -0
  58. planar/db/__init__.py +17 -0
  59. planar/db/alembic/env.py +136 -0
  60. planar/db/alembic/script.py.mako +28 -0
  61. planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
  62. planar/db/alembic.ini +128 -0
  63. planar/db/db.py +318 -0
  64. planar/files/.config.py.un~ +0 -0
  65. planar/files/.local.py.un~ +0 -0
  66. planar/files/.local_filesystem.py.un~ +0 -0
  67. planar/files/.model.py.un~ +0 -0
  68. planar/files/.models.py.un~ +0 -0
  69. planar/files/.s3.py.un~ +0 -0
  70. planar/files/.storage.py.un~ +0 -0
  71. planar/files/.test_files.py.un~ +0 -0
  72. planar/files/__init__.py +2 -0
  73. planar/files/models.py +162 -0
  74. planar/files/storage/.__init__.py.un~ +0 -0
  75. planar/files/storage/.base.py.un~ +0 -0
  76. planar/files/storage/.config.py.un~ +0 -0
  77. planar/files/storage/.context.py.un~ +0 -0
  78. planar/files/storage/.local_directory.py.un~ +0 -0
  79. planar/files/storage/.test_local_directory.py.un~ +0 -0
  80. planar/files/storage/.test_s3.py.un~ +0 -0
  81. planar/files/storage/base.py +61 -0
  82. planar/files/storage/config.py +44 -0
  83. planar/files/storage/context.py +15 -0
  84. planar/files/storage/local_directory.py +188 -0
  85. planar/files/storage/s3.py +220 -0
  86. planar/files/storage/test_local_directory.py +162 -0
  87. planar/files/storage/test_s3.py +299 -0
  88. planar/files/test_files.py +283 -0
  89. planar/human/.human.py.un~ +0 -0
  90. planar/human/.test_human.py.un~ +0 -0
  91. planar/human/__init__.py +2 -0
  92. planar/human/human.py +458 -0
  93. planar/human/models.py +80 -0
  94. planar/human/test_human.py +385 -0
  95. planar/logging/.__init__.py.un~ +0 -0
  96. planar/logging/.attributes.py.un~ +0 -0
  97. planar/logging/.formatter.py.un~ +0 -0
  98. planar/logging/.logger.py.un~ +0 -0
  99. planar/logging/.otel.py.un~ +0 -0
  100. planar/logging/.tracer.py.un~ +0 -0
  101. planar/logging/__init__.py +10 -0
  102. planar/logging/attributes.py +54 -0
  103. planar/logging/context.py +14 -0
  104. planar/logging/formatter.py +113 -0
  105. planar/logging/logger.py +114 -0
  106. planar/logging/otel.py +51 -0
  107. planar/modeling/.mixin.py.un~ +0 -0
  108. planar/modeling/.storage.py.un~ +0 -0
  109. planar/modeling/__init__.py +0 -0
  110. planar/modeling/field_helpers.py +59 -0
  111. planar/modeling/json_schema_generator.py +94 -0
  112. planar/modeling/mixins/__init__.py +10 -0
  113. planar/modeling/mixins/auditable.py +52 -0
  114. planar/modeling/mixins/test_auditable.py +97 -0
  115. planar/modeling/mixins/test_timestamp.py +134 -0
  116. planar/modeling/mixins/test_uuid_primary_key.py +52 -0
  117. planar/modeling/mixins/timestamp.py +53 -0
  118. planar/modeling/mixins/uuid_primary_key.py +19 -0
  119. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  120. planar/modeling/orm/__init__.py +18 -0
  121. planar/modeling/orm/planar_base_entity.py +29 -0
  122. planar/modeling/orm/query_filter_builder.py +122 -0
  123. planar/modeling/orm/reexports.py +15 -0
  124. planar/object_config/.object_config.py.un~ +0 -0
  125. planar/object_config/__init__.py +11 -0
  126. planar/object_config/models.py +114 -0
  127. planar/object_config/object_config.py +378 -0
  128. planar/object_registry.py +100 -0
  129. planar/registry_items.py +65 -0
  130. planar/routers/.__init__.py.un~ +0 -0
  131. planar/routers/.agents_router.py.un~ +0 -0
  132. planar/routers/.crud.py.un~ +0 -0
  133. planar/routers/.decision.py.un~ +0 -0
  134. planar/routers/.event.py.un~ +0 -0
  135. planar/routers/.file_attachment.py.un~ +0 -0
  136. planar/routers/.files.py.un~ +0 -0
  137. planar/routers/.files_router.py.un~ +0 -0
  138. planar/routers/.human.py.un~ +0 -0
  139. planar/routers/.info.py.un~ +0 -0
  140. planar/routers/.models.py.un~ +0 -0
  141. planar/routers/.object_config_router.py.un~ +0 -0
  142. planar/routers/.rule.py.un~ +0 -0
  143. planar/routers/.test_object_config_router.py.un~ +0 -0
  144. planar/routers/.test_workflow_router.py.un~ +0 -0
  145. planar/routers/.workflow.py.un~ +0 -0
  146. planar/routers/__init__.py +13 -0
  147. planar/routers/agents_router.py +197 -0
  148. planar/routers/entity_router.py +143 -0
  149. planar/routers/event.py +91 -0
  150. planar/routers/files.py +142 -0
  151. planar/routers/human.py +151 -0
  152. planar/routers/info.py +131 -0
  153. planar/routers/models.py +170 -0
  154. planar/routers/object_config_router.py +133 -0
  155. planar/routers/rule.py +108 -0
  156. planar/routers/test_agents_router.py +174 -0
  157. planar/routers/test_object_config_router.py +367 -0
  158. planar/routers/test_routes_security.py +169 -0
  159. planar/routers/test_rule_router.py +470 -0
  160. planar/routers/test_workflow_router.py +274 -0
  161. planar/routers/workflow.py +468 -0
  162. planar/rules/.decorator.py.un~ +0 -0
  163. planar/rules/.runner.py.un~ +0 -0
  164. planar/rules/.test_rules.py.un~ +0 -0
  165. planar/rules/__init__.py +23 -0
  166. planar/rules/decorator.py +184 -0
  167. planar/rules/models.py +355 -0
  168. planar/rules/rule_configuration.py +191 -0
  169. planar/rules/runner.py +64 -0
  170. planar/rules/test_rules.py +750 -0
  171. planar/scaffold_templates/app/__init__.py.j2 +0 -0
  172. planar/scaffold_templates/app/db/entities.py.j2 +11 -0
  173. planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
  174. planar/scaffold_templates/main.py.j2 +13 -0
  175. planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
  176. planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
  177. planar/scaffold_templates/pyproject.toml.j2 +10 -0
  178. planar/security/.jwt_middleware.py.un~ +0 -0
  179. planar/security/auth_context.py +148 -0
  180. planar/security/authorization.py +388 -0
  181. planar/security/default_policies.cedar +77 -0
  182. planar/security/jwt_middleware.py +116 -0
  183. planar/security/security_context.py +18 -0
  184. planar/security/tests/test_authorization_context.py +78 -0
  185. planar/security/tests/test_cedar_basics.py +41 -0
  186. planar/security/tests/test_cedar_policies.py +158 -0
  187. planar/security/tests/test_jwt_principal_context.py +179 -0
  188. planar/session.py +40 -0
  189. planar/sse/.constants.py.un~ +0 -0
  190. planar/sse/.example.html.un~ +0 -0
  191. planar/sse/.hub.py.un~ +0 -0
  192. planar/sse/.model.py.un~ +0 -0
  193. planar/sse/.proxy.py.un~ +0 -0
  194. planar/sse/constants.py +1 -0
  195. planar/sse/example.html +126 -0
  196. planar/sse/hub.py +216 -0
  197. planar/sse/model.py +8 -0
  198. planar/sse/proxy.py +257 -0
  199. planar/task_local.py +37 -0
  200. planar/test_app.py +51 -0
  201. planar/test_cli.py +372 -0
  202. planar/test_config.py +512 -0
  203. planar/test_object_config.py +527 -0
  204. planar/test_object_registry.py +14 -0
  205. planar/test_sqlalchemy.py +158 -0
  206. planar/test_utils.py +105 -0
  207. planar/testing/.client.py.un~ +0 -0
  208. planar/testing/.memory_storage.py.un~ +0 -0
  209. planar/testing/.planar_test_client.py.un~ +0 -0
  210. planar/testing/.predictable_tracer.py.un~ +0 -0
  211. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  212. planar/testing/.test_memory_storage.py.un~ +0 -0
  213. planar/testing/.workflow_observer.py.un~ +0 -0
  214. planar/testing/__init__.py +0 -0
  215. planar/testing/memory_storage.py +78 -0
  216. planar/testing/planar_test_client.py +54 -0
  217. planar/testing/synchronizable_tracer.py +153 -0
  218. planar/testing/test_memory_storage.py +143 -0
  219. planar/testing/workflow_observer.py +73 -0
  220. planar/utils.py +70 -0
  221. planar/workflows/.__init__.py.un~ +0 -0
  222. planar/workflows/.builtin_steps.py.un~ +0 -0
  223. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  224. planar/workflows/.context.py.un~ +0 -0
  225. planar/workflows/.contrib.py.un~ +0 -0
  226. planar/workflows/.decorators.py.un~ +0 -0
  227. planar/workflows/.durable_test.py.un~ +0 -0
  228. planar/workflows/.errors.py.un~ +0 -0
  229. planar/workflows/.events.py.un~ +0 -0
  230. planar/workflows/.exceptions.py.un~ +0 -0
  231. planar/workflows/.execution.py.un~ +0 -0
  232. planar/workflows/.human.py.un~ +0 -0
  233. planar/workflows/.lock.py.un~ +0 -0
  234. planar/workflows/.misc.py.un~ +0 -0
  235. planar/workflows/.model.py.un~ +0 -0
  236. planar/workflows/.models.py.un~ +0 -0
  237. planar/workflows/.notifications.py.un~ +0 -0
  238. planar/workflows/.orchestrator.py.un~ +0 -0
  239. planar/workflows/.runtime.py.un~ +0 -0
  240. planar/workflows/.serialization.py.un~ +0 -0
  241. planar/workflows/.step.py.un~ +0 -0
  242. planar/workflows/.step_core.py.un~ +0 -0
  243. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  244. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  245. planar/workflows/.test_concurrency.py.un~ +0 -0
  246. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  247. planar/workflows/.test_human.py.un~ +0 -0
  248. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  249. planar/workflows/.test_orchestrator.py.un~ +0 -0
  250. planar/workflows/.test_race_conditions.py.un~ +0 -0
  251. planar/workflows/.test_serialization.py.un~ +0 -0
  252. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  253. planar/workflows/.test_workflow.py.un~ +0 -0
  254. planar/workflows/.tracing.py.un~ +0 -0
  255. planar/workflows/.types.py.un~ +0 -0
  256. planar/workflows/.util.py.un~ +0 -0
  257. planar/workflows/.utils.py.un~ +0 -0
  258. planar/workflows/.workflow.py.un~ +0 -0
  259. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  260. planar/workflows/.wrappers.py.un~ +0 -0
  261. planar/workflows/__init__.py +42 -0
  262. planar/workflows/context.py +44 -0
  263. planar/workflows/contrib.py +190 -0
  264. planar/workflows/decorators.py +217 -0
  265. planar/workflows/events.py +185 -0
  266. planar/workflows/exceptions.py +34 -0
  267. planar/workflows/execution.py +198 -0
  268. planar/workflows/lock.py +229 -0
  269. planar/workflows/misc.py +5 -0
  270. planar/workflows/models.py +154 -0
  271. planar/workflows/notifications.py +96 -0
  272. planar/workflows/orchestrator.py +383 -0
  273. planar/workflows/query.py +256 -0
  274. planar/workflows/serialization.py +409 -0
  275. planar/workflows/step_core.py +373 -0
  276. planar/workflows/step_metadata.py +357 -0
  277. planar/workflows/step_testing_utils.py +86 -0
  278. planar/workflows/sub_workflow_runner.py +191 -0
  279. planar/workflows/test_concurrency_detection.py +120 -0
  280. planar/workflows/test_lock_timeout.py +140 -0
  281. planar/workflows/test_serialization.py +1195 -0
  282. planar/workflows/test_suspend_deserialization.py +231 -0
  283. planar/workflows/test_workflow.py +1967 -0
  284. planar/workflows/tracing.py +106 -0
  285. planar/workflows/wrappers.py +41 -0
  286. planar-0.5.0.dist-info/METADATA +285 -0
  287. planar-0.5.0.dist-info/RECORD +289 -0
  288. planar-0.5.0.dist-info/WHEEL +4 -0
  289. planar-0.5.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,357 @@
1
+ """
2
+ Utility functions for gathering rich metadata for workflow steps.
3
+
4
+ This module provides helper functions for getting step-specific
5
+ metadata for the various step types in Planar workflows.
6
+ """
7
+
8
+ from typing import Annotated, Dict, List, Literal, Optional, Union
9
+ from uuid import UUID
10
+
11
+ from pydantic import BaseModel, Field
12
+ from sqlmodel import select
13
+
14
+ from planar.ai.models import ToolCall
15
+ from planar.ai.utils import AgentSerializeable, get_agent_serializable
16
+ from planar.human import HumanTask, get_human_tasks
17
+ from planar.logging import get_logger
18
+ from planar.object_registry import ObjectRegistry
19
+ from planar.rules.models import (
20
+ RuleSerializeable,
21
+ )
22
+ from planar.rules.rule_configuration import rule_configuration
23
+ from planar.session import get_session
24
+ from planar.workflows.models import StepType, WorkflowStep
25
+
26
+ logger = get_logger(__name__)
27
+
28
+
29
+ class HumanTaskMetadata(BaseModel):
30
+ """Metadata wrapper for human task steps."""
31
+
32
+ step_type: Literal[StepType.HUMAN_IN_THE_LOOP] = StepType.HUMAN_IN_THE_LOOP
33
+ human_task: HumanTask
34
+
35
+
36
+ class AgentMetadata(BaseModel):
37
+ """Metadata wrapper for agent steps."""
38
+
39
+ step_type: Literal[StepType.AGENT] = StepType.AGENT
40
+ agent: AgentSerializeable
41
+
42
+
43
+ class RuleMetadata(BaseModel):
44
+ """Metadata wrapper for rule steps."""
45
+
46
+ step_type: Literal[StepType.RULE] = StepType.RULE
47
+ rule: RuleSerializeable
48
+
49
+
50
+ class ToolCallMetadata(BaseModel):
51
+ """Metadata wrapper for tool call steps."""
52
+
53
+ step_type: Literal[StepType.TOOL_CALL] = StepType.TOOL_CALL
54
+ tool_call: ToolCall
55
+
56
+
57
+ StepMetadata = Annotated[
58
+ Union[
59
+ HumanTaskMetadata,
60
+ AgentMetadata,
61
+ RuleMetadata,
62
+ ToolCallMetadata,
63
+ ],
64
+ Field(discriminator="step_type"),
65
+ ]
66
+
67
+
68
+ def extract_simple_name(fully_qualified_name: str) -> str:
69
+ """
70
+ Extract the last part of a fully qualified name.
71
+
72
+ For example: 'module.directory.fn_name' becomes 'fn_name'.
73
+ If there are no periods in the string, returns the original string.
74
+
75
+ Args:
76
+ fully_qualified_name: A possibly fully qualified name with dot separators
77
+
78
+ Returns:
79
+ The last part of the name
80
+ """
81
+ return (
82
+ fully_qualified_name.split(".")[-1]
83
+ if "." in fully_qualified_name
84
+ else fully_qualified_name
85
+ )
86
+
87
+
88
+ async def get_human_step_metadata(
89
+ workflow_id: UUID, step_id: int
90
+ ) -> Optional[HumanTaskMetadata]:
91
+ """
92
+ Get metadata for a human-in-the-loop step.
93
+
94
+ Args:
95
+ workflow_id: The ID of the workflow
96
+ step_id: The ID of the step
97
+
98
+ Returns:
99
+ A HumanTaskMetadata object, or None if no task is found
100
+ """
101
+ # Get human tasks for this workflow
102
+ tasks = await get_human_tasks(workflow_id=workflow_id)
103
+
104
+ # Tasks are ordered by creation time, newest first
105
+ # For now, we'll assume the most recent task is associated with this step
106
+ # In the future, we may want to add a step_id field to HumanTask for direct correlation
107
+ if not tasks:
108
+ logger.debug("no human tasks found", workflow_id=workflow_id, step_id=step_id)
109
+ return None
110
+
111
+ task = tasks[0]
112
+ logger.debug(
113
+ "found human task",
114
+ task_id=task.id,
115
+ workflow_id=workflow_id,
116
+ step_id=step_id,
117
+ )
118
+ return HumanTaskMetadata(human_task=task)
119
+
120
+
121
+ async def get_agent_step_metadata(
122
+ workflow_id: UUID, step_id: int, registry: ObjectRegistry
123
+ ) -> Optional[AgentMetadata]:
124
+ """
125
+ Get metadata for an agent step.
126
+
127
+ Args:
128
+ workflow_id: The ID of the workflow
129
+ step_id: The ID of the step
130
+ registry: ObjectRegistry instance for looking up agents
131
+
132
+ Returns:
133
+ An AgentMetadata object, or None if no metadata is found
134
+ """
135
+ session = get_session()
136
+
137
+ logger.debug(
138
+ "getting agent step metadata", workflow_id=workflow_id, step_id=step_id
139
+ )
140
+ async with session.begin_read():
141
+ step = (
142
+ await session.exec(
143
+ select(WorkflowStep).where(
144
+ (WorkflowStep.workflow_id == workflow_id)
145
+ & (WorkflowStep.step_id == step_id)
146
+ )
147
+ )
148
+ ).first()
149
+
150
+ if not step or not step.display_name:
151
+ logger.debug(
152
+ "agent step or display_name not found",
153
+ workflow_id=workflow_id,
154
+ step_id=step_id,
155
+ )
156
+ return None
157
+
158
+ agent_name = step.display_name
159
+ logger.debug("agent name from step display_name", agent_name=agent_name)
160
+
161
+ agent_serializable = await get_agent_serializable(
162
+ agent_name=agent_name, registry=registry
163
+ )
164
+
165
+ if not agent_serializable:
166
+ logger.debug("agent serializable not found", agent_name=agent_name)
167
+ return None
168
+
169
+ logger.info("agent metadata retrieved", agent_name=agent_name)
170
+ return AgentMetadata(agent=agent_serializable)
171
+
172
+
173
+ async def get_rule_step_metadata(
174
+ workflow_id: UUID, step_id: int, registry: ObjectRegistry
175
+ ) -> Optional[RuleMetadata]:
176
+ """
177
+ Get metadata for a rule step.
178
+
179
+ Args:
180
+ workflow_id: The ID of the workflow
181
+ step_id: The ID of the step
182
+
183
+ Returns:
184
+ A RuleMetadata object, or None if no metadata is found
185
+ """
186
+ session = get_session()
187
+
188
+ logger.debug("getting rule step metadata", workflow_id=workflow_id, step_id=step_id)
189
+ async with session.begin_read():
190
+ step = (
191
+ await session.exec(
192
+ select(WorkflowStep).where(
193
+ (WorkflowStep.workflow_id == workflow_id)
194
+ & (WorkflowStep.step_id == step_id)
195
+ )
196
+ )
197
+ ).first()
198
+
199
+ if not step:
200
+ logger.debug("rule step not found", workflow_id=workflow_id, step_id=step_id)
201
+ return None
202
+
203
+ rule_name = extract_simple_name(step.function_name)
204
+ logger.debug("rule name extracted", rule_name=rule_name)
205
+
206
+ rule = next((r for r in registry.get_rules() if r.name == rule_name), None)
207
+
208
+ if not rule:
209
+ logger.debug("rule not found in registry", rule_name=rule_name)
210
+ return None
211
+
212
+ configs = await rule_configuration.read_configs_with_default(
213
+ rule_name, rule.to_config()
214
+ )
215
+ logger.debug(
216
+ "retrieved configs for rule",
217
+ count=len(configs),
218
+ rule_name=rule_name,
219
+ )
220
+
221
+ rule_serializable = RuleSerializeable(
222
+ input_schema=rule.input.model_json_schema(),
223
+ output_schema=rule.output.model_json_schema(),
224
+ name=rule_name,
225
+ description=step.display_name or rule_name,
226
+ configs=configs,
227
+ )
228
+ logger.info("rule metadata retrieved", rule_name=rule_name)
229
+ return RuleMetadata(rule=rule_serializable)
230
+
231
+
232
+ async def get_tool_call_step_metadata(
233
+ workflow_id: UUID, step_id: int
234
+ ) -> Optional[ToolCallMetadata]:
235
+ """
236
+ Get metadata for a tool call step.
237
+
238
+ Args:
239
+ workflow_id: The ID of the workflow
240
+ step_id: The ID of the step
241
+
242
+ Returns:
243
+ A ToolCallMetadata object, or None if no metadata is found
244
+ """
245
+ session = get_session()
246
+
247
+ logger.debug(
248
+ "getting tool call step metadata", workflow_id=workflow_id, step_id=step_id
249
+ )
250
+ async with session.begin_read():
251
+ step = (
252
+ await session.exec(
253
+ select(WorkflowStep).where(
254
+ (WorkflowStep.workflow_id == workflow_id)
255
+ & (WorkflowStep.step_id == step_id)
256
+ )
257
+ )
258
+ ).first()
259
+
260
+ if not step:
261
+ logger.debug(
262
+ "tool call step not found", workflow_id=workflow_id, step_id=step_id
263
+ )
264
+ return None
265
+ tool_name = extract_simple_name(step.function_name)
266
+ logger.debug("tool name extracted", tool_name=tool_name)
267
+
268
+ # Get the parent step if available
269
+ parent_step = None
270
+ if step.parent_step_id:
271
+ logger.debug(
272
+ "fetching parent step for tool call step",
273
+ parent_step_id=step.parent_step_id,
274
+ step_id=step_id,
275
+ )
276
+ parent_step = (
277
+ await session.exec(
278
+ select(WorkflowStep).where(
279
+ (WorkflowStep.workflow_id == workflow_id)
280
+ & (WorkflowStep.step_id == step.parent_step_id)
281
+ )
282
+ )
283
+ ).first()
284
+ logger.debug("parent step found", found=parent_step is not None)
285
+
286
+ tool_call_id = None
287
+ if parent_step and parent_step.result and isinstance(parent_step.result, dict):
288
+ if "tool_calls" in parent_step.result:
289
+ for tc in parent_step.result["tool_calls"]:
290
+ if tc.get("name") == tool_name:
291
+ tool_call_id = tc.get("id")
292
+ logger.debug(
293
+ "found tool_call_id for tool in parent step result",
294
+ tool_call_id=tool_call_id,
295
+ tool_name=tool_name,
296
+ )
297
+ break
298
+
299
+ if not tool_call_id:
300
+ logger.debug("could not determine tool_call_id for tool", tool_name=tool_name)
301
+
302
+ tool_call_obj = ToolCall(
303
+ id=tool_call_id, name=tool_name, arguments=step.kwargs or {}
304
+ )
305
+ logger.info("tool call metadata retrieved", tool_name=tool_name)
306
+ return ToolCallMetadata(tool_call=tool_call_obj)
307
+
308
+
309
+ async def get_steps_metadata(
310
+ steps: List[WorkflowStep], registry: ObjectRegistry
311
+ ) -> Dict[int, StepMetadata]:
312
+ """
313
+ Get metadata for multiple steps efficiently.
314
+
315
+ Args:
316
+ steps: A list of workflow steps
317
+ registry: Optional ObjectRegistry instance for looking up agents
318
+
319
+ Returns:
320
+ A dictionary mapping step_id to strongly-typed StepMetadata objects
321
+ """
322
+ result = {}
323
+
324
+ human_steps = [s for s in steps if s.step_type == StepType.HUMAN_IN_THE_LOOP]
325
+ agent_steps = [s for s in steps if s.step_type == StepType.AGENT]
326
+ rule_steps = [s for s in steps if s.step_type == StepType.RULE]
327
+ tool_call_steps = [s for s in steps if s.step_type == StepType.TOOL_CALL]
328
+
329
+ if human_steps:
330
+ for step in human_steps:
331
+ metadata = await get_human_step_metadata(step.workflow_id, step.step_id)
332
+ if metadata:
333
+ result[step.step_id] = metadata
334
+
335
+ if agent_steps:
336
+ for step in agent_steps:
337
+ metadata = await get_agent_step_metadata(
338
+ step.workflow_id, step.step_id, registry
339
+ )
340
+ if metadata:
341
+ result[step.step_id] = metadata
342
+
343
+ if rule_steps:
344
+ for step in rule_steps:
345
+ metadata = await get_rule_step_metadata(
346
+ step.workflow_id, step.step_id, registry
347
+ )
348
+ if metadata:
349
+ result[step.step_id] = metadata
350
+
351
+ if tool_call_steps:
352
+ for step in tool_call_steps:
353
+ metadata = await get_tool_call_step_metadata(step.workflow_id, step.step_id)
354
+ if metadata:
355
+ result[step.step_id] = metadata
356
+
357
+ return result
@@ -0,0 +1,86 @@
1
+ from sqlmodel import col, select
2
+
3
+ from planar.session import get_session
4
+ from planar.workflows.models import WorkflowStep
5
+
6
+
7
+ async def get_step_parent(step: WorkflowStep) -> WorkflowStep | None:
8
+ """Get the parent step of the given step.
9
+
10
+ Args:
11
+ step: The step to get the parent of
12
+
13
+ Returns:
14
+ The parent step, or None if the step has no parent
15
+ """
16
+ if step.parent_step_id is None:
17
+ return None
18
+
19
+ session = get_session()
20
+ return (
21
+ await session.exec(
22
+ select(WorkflowStep)
23
+ .where(col(WorkflowStep.workflow_id) == step.workflow_id)
24
+ .where(col(WorkflowStep.step_id) == step.parent_step_id)
25
+ )
26
+ ).first()
27
+
28
+
29
+ async def get_step_children(step: WorkflowStep) -> list[WorkflowStep]:
30
+ """Get all direct child steps of the given step.
31
+
32
+ Args:
33
+ step: The step to get the children of
34
+
35
+ Returns:
36
+ A list of child steps
37
+ """
38
+ session = get_session()
39
+ result = await session.exec(
40
+ select(WorkflowStep)
41
+ .where(col(WorkflowStep.workflow_id) == step.workflow_id)
42
+ .where(col(WorkflowStep.parent_step_id) == step.step_id)
43
+ )
44
+ return list(result.all())
45
+
46
+
47
+ async def get_step_descendants(step: WorkflowStep) -> list[WorkflowStep]:
48
+ """Get all descendant steps (children, grandchildren, etc.) of the given step.
49
+
50
+ Args:
51
+ step: The step to get the descendants of
52
+
53
+ Returns:
54
+ A list of all descendant steps
55
+ """
56
+ descendants = await get_step_children(step)
57
+ result_descendants = descendants.copy()
58
+
59
+ # For each child, recursively get their descendants
60
+ for child in descendants:
61
+ child_descendants = await get_step_descendants(child)
62
+ result_descendants.extend(child_descendants)
63
+
64
+ return result_descendants
65
+
66
+
67
+ async def get_step_ancestors(step: WorkflowStep) -> list[WorkflowStep]:
68
+ """Get all ancestor steps (parent, grandparent, etc.) of the given step.
69
+
70
+ Args:
71
+ step: The step to get the ancestors of
72
+
73
+ Returns:
74
+ A list of all ancestor steps, ordered from parent to oldest ancestor
75
+ """
76
+ ancestors = []
77
+ current = step
78
+
79
+ while current.parent_step_id is not None:
80
+ parent = await get_step_parent(current)
81
+ if parent is None:
82
+ break
83
+ ancestors.append(parent)
84
+ current = parent
85
+
86
+ return ancestors
@@ -0,0 +1,191 @@
1
+ # This module contains a code for running workflows and steps as background
2
+ # tasks.
3
+ # This functionality is very complex and not well tested, so it is going to be
4
+ # disabled for now. In the future if there's demand for it, we can enable it
5
+ # again.
6
+ # For reference, this is the commit where it was enabled:
7
+ # 8c6b7decbdfc01072b5a0be66597690a487455d5
8
+ from asyncio import Future, create_task
9
+ from dataclasses import dataclass
10
+ from typing import Any, Coroutine, Mapping, Sequence, cast
11
+ from uuid import UUID
12
+
13
+ from planar.session import get_engine, session_context
14
+ from planar.task_local import TaskLocal
15
+ from planar.utils import P, R, T, U
16
+ from planar.workflows.context import (
17
+ ExecutionContext,
18
+ get_context,
19
+ in_context,
20
+ set_context,
21
+ )
22
+ from planar.workflows.wrappers import StepWrapper, WorkflowWrapper
23
+
24
+
25
+ @dataclass(kw_only=True)
26
+ class SubWorkflowCall:
27
+ wrapper: WorkflowWrapper
28
+ args: Sequence[Any]
29
+ kwargs: Mapping[str, Any]
30
+ started: Future[UUID]
31
+
32
+
33
+ # When a workflow is running, it will may call steps and other workflows. This
34
+ # class encapsulates the logic for handling the various ways in which this can
35
+ # happen.
36
+
37
+ # The simplest case is when steps/workflows are called using `await` directly
38
+ # in the current task (when "in_context").
39
+
40
+
41
+ # For workflows this means we'll start the workflow as a step (every workflow has an
42
+ # auto generated "start step"), which makes the operation durable. Additionally
43
+ # we call the "wait_for_completion" helper, which waits for the workflow to
44
+ # complete (up to a timeout) and returns the result.
45
+ #
46
+ # For steps it is even simpler after all, calling steps is the most common thing that
47
+ # can be done in a workflow.
48
+ #
49
+ # Things start to get more complicated when workflows or steps are called using
50
+ # `asyncio.create_task`, which means it will run in a separate task without
51
+ # blocking the current one.
52
+ #
53
+ # A lot of the logic in SubWorkflowRunner is about dealing with multiple
54
+ # background workflows/steps starting at the same time. To make these
55
+ # operations durable, we must do some magic to force the concurrent workflows
56
+ # to start in the order they were called.
57
+ #
58
+ # The initial work is done by the "run" method, which cannot be an async
59
+ # function since it must be able to access the current workflow context. If we
60
+ # used an async function, it would only start executing when the new task
61
+ # started. OTOH we can only know if a step/workflow was called in a separate task
62
+ # in an async function (the "context forwarder"). So this is what we do:
63
+ #
64
+ # - in the "run" method, we collect the all information about the calls in a list,
65
+ # (pending_sub_workflow_calls) withou actually calling anything, and then we call
66
+ # the appropriate context forwarder for steps/worfklows.
67
+ # - in the workflow context forwarder, if we were called in a separate task we
68
+ # invoke the "start_sub_workflow" function, which will search for the current
69
+ # workflow start function in the pending list and invoke the workflow's "waiter",
70
+ # an async function which will wait for a signal that the workflow has started
71
+ # (signal passed via a future).
72
+ # - after the last workflow is discovered, we invoke a "starter" task that
73
+ # will start all workflows in the correct order, signaling futures associated
74
+ # with the workflow
75
+ #
76
+ # The above magic is what ensures that child concurrent workflows will start in a
77
+ # predictable order, and will be durable against restarts.
78
+ #
79
+ # For steps, the only thing that makes sense is to run it in a separate
80
+ # workflow, or else there would be no ordering of completion, and that's why
81
+ # every step has an "auto workflow", thus entering in the same path as
82
+ # concurrent subworkflows described above.
83
+ class SubWorkflowRunner:
84
+ def __init__(self):
85
+ self._pending_sub_workflow_calls: list[SubWorkflowCall] = []
86
+
87
+ def run(
88
+ self,
89
+ wrapper: WorkflowWrapper[P, T, U, R] | StepWrapper[P, T, U, R],
90
+ *args: P.args,
91
+ **kwargs: P.kwargs,
92
+ ) -> Coroutine[T, U, R]:
93
+ async def starter(ctx: ExecutionContext):
94
+ # Main starter task that will call the start steps in the order
95
+ # they were called
96
+ set_context(ctx)
97
+ sub_workflow_calls = self._pending_sub_workflow_calls[:]
98
+ self._pending_sub_workflow_calls.clear()
99
+ async with session_context(get_engine()):
100
+ for sub_workflow_call in sub_workflow_calls:
101
+ args = sub_workflow_call.args
102
+ kwargs = sub_workflow_call.kwargs
103
+ start_step = sub_workflow_call.wrapper.start_step
104
+ workflow_id = await start_step(*args, **kwargs)
105
+ sub_workflow_call.started.set_result(workflow_id)
106
+
107
+ async def waiter(future: Future[UUID], wf_wrapper: WorkflowWrapper[P, T, U, R]):
108
+ # only call wait_for_completion after receiving the workflow_id
109
+ # from the future
110
+ workflow_id = await future
111
+ completion_coro = wf_wrapper.wait_for_completion(workflow_id)
112
+ return await cast(Coroutine[T, U, R], completion_coro)
113
+
114
+ def start_sub_workflow(wf_wrapper: WorkflowWrapper[P, T, U, R]):
115
+ ctx = get_context()
116
+
117
+ # find the matching call instance
118
+ future = None
119
+ index = 0
120
+ for index, sub_workflow_call in enumerate(self._pending_sub_workflow_calls):
121
+ if sub_workflow_call.wrapper == wf_wrapper:
122
+ future = sub_workflow_call.started
123
+ break
124
+
125
+ if index == len(self._pending_sub_workflow_calls) - 1:
126
+ # Last subworkflow. Run the starter task that will start all
127
+ # workflows in the correct order
128
+ create_task(starter(ctx))
129
+
130
+ assert future
131
+ return waiter(future, wf_wrapper)
132
+
133
+ async def workflow_context_forwarder(
134
+ parent_execution_context: ExecutionContext,
135
+ wf_wrapper: WorkflowWrapper[P, T, U, R],
136
+ ):
137
+ if not in_context():
138
+ # invoke "start_sub_workflow" after forwarding the parent context
139
+ set_context(parent_execution_context)
140
+ return await cast(Coroutine[T, U, R], start_sub_workflow(wf_wrapper))
141
+
142
+ # Simple case, no need to start any starter task.
143
+ # Clear pending calls and invoke the start step directly.
144
+ assert len(self._pending_sub_workflow_calls) == 1
145
+ self._pending_sub_workflow_calls.clear()
146
+ workflow_id = await wf_wrapper.start_step(*args, **kwargs)
147
+ # Wait for completion
148
+ completion_coro = wf_wrapper.wait_for_completion(workflow_id)
149
+ return await cast(Coroutine[T, U, R], completion_coro)
150
+
151
+ async def step_context_forwarder(
152
+ parent_execution_context: ExecutionContext,
153
+ step_wrapper: StepWrapper,
154
+ ):
155
+ if not in_context():
156
+ # Invoke the workflow context forwarder, passing in the auto workflow
157
+ return await workflow_context_forwarder(
158
+ parent_execution_context, step_wrapper.auto_workflow
159
+ )
160
+ # Simple case. Clear the pending calls and invoke the step
161
+ # wrapper directly.
162
+ assert len(self._pending_sub_workflow_calls) == 1
163
+ self._pending_sub_workflow_calls.clear()
164
+ return await step_wrapper.wrapper(*args, **kwargs)
165
+
166
+ # add the workflow wrapper, along with args and a future to the pending list
167
+ self._pending_sub_workflow_calls.append(
168
+ SubWorkflowCall(
169
+ wrapper=wrapper.auto_workflow
170
+ if isinstance(wrapper, StepWrapper)
171
+ else wrapper,
172
+ started=Future(),
173
+ args=args,
174
+ kwargs=kwargs,
175
+ )
176
+ )
177
+
178
+ # invoke the correct context forwarder
179
+ if isinstance(wrapper, StepWrapper):
180
+ return step_context_forwarder(get_context(), wrapper)
181
+ else:
182
+ return workflow_context_forwarder(get_context(), wrapper)
183
+
184
+
185
+ data: TaskLocal[SubWorkflowRunner] = TaskLocal()
186
+
187
+
188
+ def get_sub_workflow_runner() -> SubWorkflowRunner:
189
+ if not data.is_set():
190
+ data.set(SubWorkflowRunner())
191
+ return data.get()