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.
- planar/.__init__.py.un~ +0 -0
- planar/._version.py.un~ +0 -0
- planar/.app.py.un~ +0 -0
- planar/.cli.py.un~ +0 -0
- planar/.config.py.un~ +0 -0
- planar/.context.py.un~ +0 -0
- planar/.db.py.un~ +0 -0
- planar/.di.py.un~ +0 -0
- planar/.engine.py.un~ +0 -0
- planar/.files.py.un~ +0 -0
- planar/.log_context.py.un~ +0 -0
- planar/.log_metadata.py.un~ +0 -0
- planar/.logging.py.un~ +0 -0
- planar/.object_registry.py.un~ +0 -0
- planar/.otel.py.un~ +0 -0
- planar/.server.py.un~ +0 -0
- planar/.session.py.un~ +0 -0
- planar/.sqlalchemy.py.un~ +0 -0
- planar/.task_local.py.un~ +0 -0
- planar/.test_app.py.un~ +0 -0
- planar/.test_config.py.un~ +0 -0
- planar/.test_object_config.py.un~ +0 -0
- planar/.test_sqlalchemy.py.un~ +0 -0
- planar/.test_utils.py.un~ +0 -0
- planar/.util.py.un~ +0 -0
- planar/.utils.py.un~ +0 -0
- planar/__init__.py +26 -0
- planar/_version.py +1 -0
- planar/ai/.__init__.py.un~ +0 -0
- planar/ai/._models.py.un~ +0 -0
- planar/ai/.agent.py.un~ +0 -0
- planar/ai/.agent_utils.py.un~ +0 -0
- planar/ai/.events.py.un~ +0 -0
- planar/ai/.files.py.un~ +0 -0
- planar/ai/.models.py.un~ +0 -0
- planar/ai/.providers.py.un~ +0 -0
- planar/ai/.pydantic_ai.py.un~ +0 -0
- planar/ai/.pydantic_ai_agent.py.un~ +0 -0
- planar/ai/.pydantic_ai_provider.py.un~ +0 -0
- planar/ai/.step.py.un~ +0 -0
- planar/ai/.test_agent.py.un~ +0 -0
- planar/ai/.test_agent_serialization.py.un~ +0 -0
- planar/ai/.test_providers.py.un~ +0 -0
- planar/ai/.utils.py.un~ +0 -0
- planar/ai/__init__.py +15 -0
- planar/ai/agent.py +457 -0
- planar/ai/agent_utils.py +205 -0
- planar/ai/models.py +140 -0
- planar/ai/providers.py +1088 -0
- planar/ai/test_agent.py +1298 -0
- planar/ai/test_agent_serialization.py +229 -0
- planar/ai/test_providers.py +463 -0
- planar/ai/utils.py +102 -0
- planar/app.py +494 -0
- planar/cli.py +282 -0
- planar/config.py +544 -0
- planar/db/.db.py.un~ +0 -0
- planar/db/__init__.py +17 -0
- planar/db/alembic/env.py +136 -0
- planar/db/alembic/script.py.mako +28 -0
- planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
- planar/db/alembic.ini +128 -0
- planar/db/db.py +318 -0
- planar/files/.config.py.un~ +0 -0
- planar/files/.local.py.un~ +0 -0
- planar/files/.local_filesystem.py.un~ +0 -0
- planar/files/.model.py.un~ +0 -0
- planar/files/.models.py.un~ +0 -0
- planar/files/.s3.py.un~ +0 -0
- planar/files/.storage.py.un~ +0 -0
- planar/files/.test_files.py.un~ +0 -0
- planar/files/__init__.py +2 -0
- planar/files/models.py +162 -0
- planar/files/storage/.__init__.py.un~ +0 -0
- planar/files/storage/.base.py.un~ +0 -0
- planar/files/storage/.config.py.un~ +0 -0
- planar/files/storage/.context.py.un~ +0 -0
- planar/files/storage/.local_directory.py.un~ +0 -0
- planar/files/storage/.test_local_directory.py.un~ +0 -0
- planar/files/storage/.test_s3.py.un~ +0 -0
- planar/files/storage/base.py +61 -0
- planar/files/storage/config.py +44 -0
- planar/files/storage/context.py +15 -0
- planar/files/storage/local_directory.py +188 -0
- planar/files/storage/s3.py +220 -0
- planar/files/storage/test_local_directory.py +162 -0
- planar/files/storage/test_s3.py +299 -0
- planar/files/test_files.py +283 -0
- planar/human/.human.py.un~ +0 -0
- planar/human/.test_human.py.un~ +0 -0
- planar/human/__init__.py +2 -0
- planar/human/human.py +458 -0
- planar/human/models.py +80 -0
- planar/human/test_human.py +385 -0
- planar/logging/.__init__.py.un~ +0 -0
- planar/logging/.attributes.py.un~ +0 -0
- planar/logging/.formatter.py.un~ +0 -0
- planar/logging/.logger.py.un~ +0 -0
- planar/logging/.otel.py.un~ +0 -0
- planar/logging/.tracer.py.un~ +0 -0
- planar/logging/__init__.py +10 -0
- planar/logging/attributes.py +54 -0
- planar/logging/context.py +14 -0
- planar/logging/formatter.py +113 -0
- planar/logging/logger.py +114 -0
- planar/logging/otel.py +51 -0
- planar/modeling/.mixin.py.un~ +0 -0
- planar/modeling/.storage.py.un~ +0 -0
- planar/modeling/__init__.py +0 -0
- planar/modeling/field_helpers.py +59 -0
- planar/modeling/json_schema_generator.py +94 -0
- planar/modeling/mixins/__init__.py +10 -0
- planar/modeling/mixins/auditable.py +52 -0
- planar/modeling/mixins/test_auditable.py +97 -0
- planar/modeling/mixins/test_timestamp.py +134 -0
- planar/modeling/mixins/test_uuid_primary_key.py +52 -0
- planar/modeling/mixins/timestamp.py +53 -0
- planar/modeling/mixins/uuid_primary_key.py +19 -0
- planar/modeling/orm/.planar_base_model.py.un~ +0 -0
- planar/modeling/orm/__init__.py +18 -0
- planar/modeling/orm/planar_base_entity.py +29 -0
- planar/modeling/orm/query_filter_builder.py +122 -0
- planar/modeling/orm/reexports.py +15 -0
- planar/object_config/.object_config.py.un~ +0 -0
- planar/object_config/__init__.py +11 -0
- planar/object_config/models.py +114 -0
- planar/object_config/object_config.py +378 -0
- planar/object_registry.py +100 -0
- planar/registry_items.py +65 -0
- planar/routers/.__init__.py.un~ +0 -0
- planar/routers/.agents_router.py.un~ +0 -0
- planar/routers/.crud.py.un~ +0 -0
- planar/routers/.decision.py.un~ +0 -0
- planar/routers/.event.py.un~ +0 -0
- planar/routers/.file_attachment.py.un~ +0 -0
- planar/routers/.files.py.un~ +0 -0
- planar/routers/.files_router.py.un~ +0 -0
- planar/routers/.human.py.un~ +0 -0
- planar/routers/.info.py.un~ +0 -0
- planar/routers/.models.py.un~ +0 -0
- planar/routers/.object_config_router.py.un~ +0 -0
- planar/routers/.rule.py.un~ +0 -0
- planar/routers/.test_object_config_router.py.un~ +0 -0
- planar/routers/.test_workflow_router.py.un~ +0 -0
- planar/routers/.workflow.py.un~ +0 -0
- planar/routers/__init__.py +13 -0
- planar/routers/agents_router.py +197 -0
- planar/routers/entity_router.py +143 -0
- planar/routers/event.py +91 -0
- planar/routers/files.py +142 -0
- planar/routers/human.py +151 -0
- planar/routers/info.py +131 -0
- planar/routers/models.py +170 -0
- planar/routers/object_config_router.py +133 -0
- planar/routers/rule.py +108 -0
- planar/routers/test_agents_router.py +174 -0
- planar/routers/test_object_config_router.py +367 -0
- planar/routers/test_routes_security.py +169 -0
- planar/routers/test_rule_router.py +470 -0
- planar/routers/test_workflow_router.py +274 -0
- planar/routers/workflow.py +468 -0
- planar/rules/.decorator.py.un~ +0 -0
- planar/rules/.runner.py.un~ +0 -0
- planar/rules/.test_rules.py.un~ +0 -0
- planar/rules/__init__.py +23 -0
- planar/rules/decorator.py +184 -0
- planar/rules/models.py +355 -0
- planar/rules/rule_configuration.py +191 -0
- planar/rules/runner.py +64 -0
- planar/rules/test_rules.py +750 -0
- planar/scaffold_templates/app/__init__.py.j2 +0 -0
- planar/scaffold_templates/app/db/entities.py.j2 +11 -0
- planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
- planar/scaffold_templates/main.py.j2 +13 -0
- planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
- planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
- planar/scaffold_templates/pyproject.toml.j2 +10 -0
- planar/security/.jwt_middleware.py.un~ +0 -0
- planar/security/auth_context.py +148 -0
- planar/security/authorization.py +388 -0
- planar/security/default_policies.cedar +77 -0
- planar/security/jwt_middleware.py +116 -0
- planar/security/security_context.py +18 -0
- planar/security/tests/test_authorization_context.py +78 -0
- planar/security/tests/test_cedar_basics.py +41 -0
- planar/security/tests/test_cedar_policies.py +158 -0
- planar/security/tests/test_jwt_principal_context.py +179 -0
- planar/session.py +40 -0
- planar/sse/.constants.py.un~ +0 -0
- planar/sse/.example.html.un~ +0 -0
- planar/sse/.hub.py.un~ +0 -0
- planar/sse/.model.py.un~ +0 -0
- planar/sse/.proxy.py.un~ +0 -0
- planar/sse/constants.py +1 -0
- planar/sse/example.html +126 -0
- planar/sse/hub.py +216 -0
- planar/sse/model.py +8 -0
- planar/sse/proxy.py +257 -0
- planar/task_local.py +37 -0
- planar/test_app.py +51 -0
- planar/test_cli.py +372 -0
- planar/test_config.py +512 -0
- planar/test_object_config.py +527 -0
- planar/test_object_registry.py +14 -0
- planar/test_sqlalchemy.py +158 -0
- planar/test_utils.py +105 -0
- planar/testing/.client.py.un~ +0 -0
- planar/testing/.memory_storage.py.un~ +0 -0
- planar/testing/.planar_test_client.py.un~ +0 -0
- planar/testing/.predictable_tracer.py.un~ +0 -0
- planar/testing/.synchronizable_tracer.py.un~ +0 -0
- planar/testing/.test_memory_storage.py.un~ +0 -0
- planar/testing/.workflow_observer.py.un~ +0 -0
- planar/testing/__init__.py +0 -0
- planar/testing/memory_storage.py +78 -0
- planar/testing/planar_test_client.py +54 -0
- planar/testing/synchronizable_tracer.py +153 -0
- planar/testing/test_memory_storage.py +143 -0
- planar/testing/workflow_observer.py +73 -0
- planar/utils.py +70 -0
- planar/workflows/.__init__.py.un~ +0 -0
- planar/workflows/.builtin_steps.py.un~ +0 -0
- planar/workflows/.concurrency_tracing.py.un~ +0 -0
- planar/workflows/.context.py.un~ +0 -0
- planar/workflows/.contrib.py.un~ +0 -0
- planar/workflows/.decorators.py.un~ +0 -0
- planar/workflows/.durable_test.py.un~ +0 -0
- planar/workflows/.errors.py.un~ +0 -0
- planar/workflows/.events.py.un~ +0 -0
- planar/workflows/.exceptions.py.un~ +0 -0
- planar/workflows/.execution.py.un~ +0 -0
- planar/workflows/.human.py.un~ +0 -0
- planar/workflows/.lock.py.un~ +0 -0
- planar/workflows/.misc.py.un~ +0 -0
- planar/workflows/.model.py.un~ +0 -0
- planar/workflows/.models.py.un~ +0 -0
- planar/workflows/.notifications.py.un~ +0 -0
- planar/workflows/.orchestrator.py.un~ +0 -0
- planar/workflows/.runtime.py.un~ +0 -0
- planar/workflows/.serialization.py.un~ +0 -0
- planar/workflows/.step.py.un~ +0 -0
- planar/workflows/.step_core.py.un~ +0 -0
- planar/workflows/.sub_workflow_runner.py.un~ +0 -0
- planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
- planar/workflows/.test_concurrency.py.un~ +0 -0
- planar/workflows/.test_concurrency_detection.py.un~ +0 -0
- planar/workflows/.test_human.py.un~ +0 -0
- planar/workflows/.test_lock_timeout.py.un~ +0 -0
- planar/workflows/.test_orchestrator.py.un~ +0 -0
- planar/workflows/.test_race_conditions.py.un~ +0 -0
- planar/workflows/.test_serialization.py.un~ +0 -0
- planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
- planar/workflows/.test_workflow.py.un~ +0 -0
- planar/workflows/.tracing.py.un~ +0 -0
- planar/workflows/.types.py.un~ +0 -0
- planar/workflows/.util.py.un~ +0 -0
- planar/workflows/.utils.py.un~ +0 -0
- planar/workflows/.workflow.py.un~ +0 -0
- planar/workflows/.workflow_wrapper.py.un~ +0 -0
- planar/workflows/.wrappers.py.un~ +0 -0
- planar/workflows/__init__.py +42 -0
- planar/workflows/context.py +44 -0
- planar/workflows/contrib.py +190 -0
- planar/workflows/decorators.py +217 -0
- planar/workflows/events.py +185 -0
- planar/workflows/exceptions.py +34 -0
- planar/workflows/execution.py +198 -0
- planar/workflows/lock.py +229 -0
- planar/workflows/misc.py +5 -0
- planar/workflows/models.py +154 -0
- planar/workflows/notifications.py +96 -0
- planar/workflows/orchestrator.py +383 -0
- planar/workflows/query.py +256 -0
- planar/workflows/serialization.py +409 -0
- planar/workflows/step_core.py +373 -0
- planar/workflows/step_metadata.py +357 -0
- planar/workflows/step_testing_utils.py +86 -0
- planar/workflows/sub_workflow_runner.py +191 -0
- planar/workflows/test_concurrency_detection.py +120 -0
- planar/workflows/test_lock_timeout.py +140 -0
- planar/workflows/test_serialization.py +1195 -0
- planar/workflows/test_suspend_deserialization.py +231 -0
- planar/workflows/test_workflow.py +1967 -0
- planar/workflows/tracing.py +106 -0
- planar/workflows/wrappers.py +41 -0
- planar-0.5.0.dist-info/METADATA +285 -0
- planar-0.5.0.dist-info/RECORD +289 -0
- planar-0.5.0.dist-info/WHEEL +4 -0
- 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()
|