abstractflow 0.1.0__py3-none-any.whl → 0.3.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.
- abstractflow/__init__.py +75 -95
- abstractflow/__main__.py +2 -0
- abstractflow/adapters/__init__.py +11 -0
- abstractflow/adapters/agent_adapter.py +124 -0
- abstractflow/adapters/control_adapter.py +615 -0
- abstractflow/adapters/effect_adapter.py +645 -0
- abstractflow/adapters/event_adapter.py +307 -0
- abstractflow/adapters/function_adapter.py +97 -0
- abstractflow/adapters/subflow_adapter.py +74 -0
- abstractflow/adapters/variable_adapter.py +317 -0
- abstractflow/cli.py +2 -0
- abstractflow/compiler.py +2027 -0
- abstractflow/core/__init__.py +5 -0
- abstractflow/core/flow.py +247 -0
- abstractflow/py.typed +2 -0
- abstractflow/runner.py +348 -0
- abstractflow/visual/__init__.py +43 -0
- abstractflow/visual/agent_ids.py +29 -0
- abstractflow/visual/builtins.py +789 -0
- abstractflow/visual/code_executor.py +214 -0
- abstractflow/visual/event_ids.py +33 -0
- abstractflow/visual/executor.py +2789 -0
- abstractflow/visual/interfaces.py +347 -0
- abstractflow/visual/models.py +252 -0
- abstractflow/visual/session_runner.py +168 -0
- abstractflow/visual/workspace_scoped_tools.py +261 -0
- abstractflow-0.3.0.dist-info/METADATA +413 -0
- abstractflow-0.3.0.dist-info/RECORD +32 -0
- {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/licenses/LICENSE +2 -0
- abstractflow-0.1.0.dist-info/METADATA +0 -238
- abstractflow-0.1.0.dist-info/RECORD +0 -10
- {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/WHEEL +0 -0
- {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/entry_points.txt +0 -0
- {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Flow definition classes for AbstractFlow.
|
|
2
|
+
|
|
3
|
+
This module provides the core data structures for defining flows:
|
|
4
|
+
- Flow: A directed graph of nodes connected by edges
|
|
5
|
+
- FlowNode: A node in the flow (agent, function, or nested flow)
|
|
6
|
+
- FlowEdge: An edge connecting two nodes
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional, Union, TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
# Avoid circular import - only used for type hints
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class FlowNode:
|
|
21
|
+
"""A node in a flow graph.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
id: Unique identifier for the node within the flow
|
|
25
|
+
handler: The handler for this node - can be an agent, function, or nested flow
|
|
26
|
+
input_key: Key in run.vars to read input from (optional)
|
|
27
|
+
output_key: Key in run.vars to write output to (optional)
|
|
28
|
+
effect_type: Effect type for effect nodes (ask_user, wait_until, etc.)
|
|
29
|
+
effect_config: Additional configuration for effect nodes
|
|
30
|
+
"""
|
|
31
|
+
id: str
|
|
32
|
+
handler: Any # Union[BaseAgent, Callable, Flow] - Any to avoid circular imports
|
|
33
|
+
input_key: Optional[str] = None
|
|
34
|
+
output_key: Optional[str] = None
|
|
35
|
+
effect_type: Optional[str] = None # e.g., "ask_user", "wait_until", etc.
|
|
36
|
+
effect_config: Optional[Dict[str, Any]] = None # Effect-specific configuration
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class FlowEdge:
|
|
41
|
+
"""An edge connecting two nodes in a flow.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
source: ID of the source node
|
|
45
|
+
target: ID of the target node
|
|
46
|
+
condition: Optional condition function for conditional routing (future)
|
|
47
|
+
source_handle: Optional execution output handle id (visual flows).
|
|
48
|
+
"""
|
|
49
|
+
source: str
|
|
50
|
+
target: str
|
|
51
|
+
condition: Optional[Callable[[Dict[str, Any]], bool]] = None
|
|
52
|
+
source_handle: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Flow:
|
|
56
|
+
"""Declarative flow definition that compiles to WorkflowSpec.
|
|
57
|
+
|
|
58
|
+
A Flow represents a directed graph of nodes (agents, functions, or nested flows)
|
|
59
|
+
connected by edges. Flows can be compiled to AbstractRuntime WorkflowSpec for
|
|
60
|
+
durable execution.
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
>>> flow = Flow("my_flow")
|
|
64
|
+
>>> flow.add_node("start", my_function, output_key="data")
|
|
65
|
+
>>> flow.add_node("process", process_function, input_key="data")
|
|
66
|
+
>>> flow.add_edge("start", "process")
|
|
67
|
+
>>> flow.set_entry("start")
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, flow_id: str):
|
|
71
|
+
"""Initialize a new flow.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
flow_id: Unique identifier for this flow
|
|
75
|
+
"""
|
|
76
|
+
self.flow_id = flow_id
|
|
77
|
+
self.nodes: Dict[str, FlowNode] = {}
|
|
78
|
+
self.edges: List[FlowEdge] = []
|
|
79
|
+
self.entry_node: Optional[str] = None
|
|
80
|
+
self.exit_node: Optional[str] = None
|
|
81
|
+
|
|
82
|
+
def add_node(
|
|
83
|
+
self,
|
|
84
|
+
node_id: str,
|
|
85
|
+
handler: Any,
|
|
86
|
+
*,
|
|
87
|
+
input_key: Optional[str] = None,
|
|
88
|
+
output_key: Optional[str] = None,
|
|
89
|
+
effect_type: Optional[str] = None,
|
|
90
|
+
effect_config: Optional[Dict[str, Any]] = None,
|
|
91
|
+
) -> "Flow":
|
|
92
|
+
"""Add a node to the flow.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
node_id: Unique identifier for this node
|
|
96
|
+
handler: The handler - an agent, callable function, or nested Flow
|
|
97
|
+
input_key: Key in run.vars to read input from
|
|
98
|
+
output_key: Key in run.vars to write output to
|
|
99
|
+
effect_type: Effect type for effect nodes (ask_user, wait_until, etc.)
|
|
100
|
+
effect_config: Additional configuration for effect nodes
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Self for method chaining
|
|
104
|
+
"""
|
|
105
|
+
if node_id in self.nodes:
|
|
106
|
+
raise ValueError(f"Node '{node_id}' already exists in flow '{self.flow_id}'")
|
|
107
|
+
|
|
108
|
+
self.nodes[node_id] = FlowNode(
|
|
109
|
+
id=node_id,
|
|
110
|
+
handler=handler,
|
|
111
|
+
input_key=input_key,
|
|
112
|
+
output_key=output_key,
|
|
113
|
+
effect_type=effect_type,
|
|
114
|
+
effect_config=effect_config,
|
|
115
|
+
)
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def add_edge(
|
|
119
|
+
self,
|
|
120
|
+
source: str,
|
|
121
|
+
target: str,
|
|
122
|
+
*,
|
|
123
|
+
condition: Optional[Callable[[Dict[str, Any]], bool]] = None,
|
|
124
|
+
source_handle: Optional[str] = None,
|
|
125
|
+
) -> "Flow":
|
|
126
|
+
"""Add an edge between nodes.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
source: ID of the source node
|
|
130
|
+
target: ID of the target node
|
|
131
|
+
condition: Optional condition function for conditional routing
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Self for method chaining
|
|
135
|
+
"""
|
|
136
|
+
self.edges.append(
|
|
137
|
+
FlowEdge(
|
|
138
|
+
source=source,
|
|
139
|
+
target=target,
|
|
140
|
+
condition=condition,
|
|
141
|
+
source_handle=source_handle,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
def set_entry(self, node_id: str) -> "Flow":
|
|
147
|
+
"""Set the entry node for the flow.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
node_id: ID of the entry node
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Self for method chaining
|
|
154
|
+
"""
|
|
155
|
+
if node_id not in self.nodes:
|
|
156
|
+
raise ValueError(f"Entry node '{node_id}' not found in flow '{self.flow_id}'")
|
|
157
|
+
self.entry_node = node_id
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def set_exit(self, node_id: str) -> "Flow":
|
|
161
|
+
"""Set the exit node for the flow (optional, can be inferred).
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
node_id: ID of the exit node
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Self for method chaining
|
|
168
|
+
"""
|
|
169
|
+
if node_id not in self.nodes:
|
|
170
|
+
raise ValueError(f"Exit node '{node_id}' not found in flow '{self.flow_id}'")
|
|
171
|
+
self.exit_node = node_id
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
def validate(self) -> List[str]:
|
|
175
|
+
"""Validate the flow definition.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
List of validation error messages (empty if valid)
|
|
179
|
+
"""
|
|
180
|
+
errors = []
|
|
181
|
+
|
|
182
|
+
if not self.entry_node:
|
|
183
|
+
errors.append("Flow must have an entry node")
|
|
184
|
+
elif self.entry_node not in self.nodes:
|
|
185
|
+
errors.append(f"Entry node '{self.entry_node}' not found")
|
|
186
|
+
|
|
187
|
+
# Check that all edge endpoints exist
|
|
188
|
+
for edge in self.edges:
|
|
189
|
+
if edge.source not in self.nodes:
|
|
190
|
+
errors.append(f"Edge source '{edge.source}' not found")
|
|
191
|
+
if edge.target not in self.nodes:
|
|
192
|
+
errors.append(f"Edge target '{edge.target}' not found")
|
|
193
|
+
|
|
194
|
+
# Check for unreachable nodes
|
|
195
|
+
if self.entry_node:
|
|
196
|
+
reachable = self._find_reachable_nodes(self.entry_node)
|
|
197
|
+
for node_id in self.nodes:
|
|
198
|
+
if node_id not in reachable:
|
|
199
|
+
errors.append(f"Node '{node_id}' is unreachable from entry")
|
|
200
|
+
|
|
201
|
+
return errors
|
|
202
|
+
|
|
203
|
+
def _find_reachable_nodes(self, start: str) -> set:
|
|
204
|
+
"""Find all nodes reachable from a starting node."""
|
|
205
|
+
reachable = set()
|
|
206
|
+
to_visit = [start]
|
|
207
|
+
|
|
208
|
+
while to_visit:
|
|
209
|
+
current = to_visit.pop()
|
|
210
|
+
if current in reachable:
|
|
211
|
+
continue
|
|
212
|
+
reachable.add(current)
|
|
213
|
+
|
|
214
|
+
# Find outgoing edges
|
|
215
|
+
for edge in self.edges:
|
|
216
|
+
if edge.source == current and edge.target not in reachable:
|
|
217
|
+
to_visit.append(edge.target)
|
|
218
|
+
|
|
219
|
+
return reachable
|
|
220
|
+
|
|
221
|
+
def get_next_nodes(self, node_id: str) -> List[str]:
|
|
222
|
+
"""Get the next nodes from a given node.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
node_id: ID of the current node
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
List of target node IDs
|
|
229
|
+
"""
|
|
230
|
+
return [edge.target for edge in self.edges if edge.source == node_id]
|
|
231
|
+
|
|
232
|
+
def get_terminal_nodes(self) -> List[str]:
|
|
233
|
+
"""Get nodes with no outgoing edges (terminal nodes).
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
List of terminal node IDs
|
|
237
|
+
"""
|
|
238
|
+
sources = {edge.source for edge in self.edges}
|
|
239
|
+
return [node_id for node_id in self.nodes if node_id not in sources]
|
|
240
|
+
|
|
241
|
+
def __repr__(self) -> str:
|
|
242
|
+
return (
|
|
243
|
+
f"Flow(id={self.flow_id!r}, "
|
|
244
|
+
f"nodes={len(self.nodes)}, "
|
|
245
|
+
f"edges={len(self.edges)}, "
|
|
246
|
+
f"entry={self.entry_node!r})"
|
|
247
|
+
)
|
abstractflow/py.typed
CHANGED
abstractflow/runner.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""FlowRunner - executes flows using AbstractRuntime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, Optional, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .core.flow import Flow
|
|
8
|
+
from .compiler import compile_flow
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from abstractruntime.core.models import RunState
|
|
12
|
+
from abstractruntime.core.runtime import Runtime
|
|
13
|
+
from abstractruntime.core.spec import WorkflowSpec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FlowRunner:
|
|
17
|
+
"""Executes flows using AbstractRuntime.
|
|
18
|
+
|
|
19
|
+
FlowRunner provides a high-level interface for running flows. It handles:
|
|
20
|
+
- Compiling the flow to a WorkflowSpec
|
|
21
|
+
- Creating a default runtime if not provided
|
|
22
|
+
- Managing run lifecycle (start, step, run, resume)
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
>>> flow = Flow("my_flow")
|
|
26
|
+
>>> flow.add_node("start", lambda x: x * 2, input_key="value")
|
|
27
|
+
>>> flow.set_entry("start")
|
|
28
|
+
>>>
|
|
29
|
+
>>> runner = FlowRunner(flow)
|
|
30
|
+
>>> result = runner.run({"value": 21})
|
|
31
|
+
>>> print(result) # {'result': 42, 'success': True}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
flow: Flow,
|
|
37
|
+
runtime: Optional["Runtime"] = None,
|
|
38
|
+
):
|
|
39
|
+
"""Initialize a FlowRunner.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
flow: The Flow definition to run
|
|
43
|
+
runtime: Optional AbstractRuntime instance. If not provided,
|
|
44
|
+
a default in-memory runtime will be created.
|
|
45
|
+
"""
|
|
46
|
+
self.flow = flow
|
|
47
|
+
self.workflow: "WorkflowSpec" = compile_flow(flow)
|
|
48
|
+
self.runtime = runtime or self._create_default_runtime()
|
|
49
|
+
self._current_run_id: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
def _create_default_runtime(self) -> "Runtime":
|
|
52
|
+
"""Create a default in-memory runtime."""
|
|
53
|
+
try:
|
|
54
|
+
from abstractruntime import Runtime, InMemoryRunStore, InMemoryLedgerStore # type: ignore
|
|
55
|
+
except Exception: # pragma: no cover
|
|
56
|
+
from abstractruntime.core.runtime import Runtime # type: ignore
|
|
57
|
+
from abstractruntime.storage.in_memory import InMemoryLedgerStore, InMemoryRunStore # type: ignore
|
|
58
|
+
|
|
59
|
+
return Runtime(
|
|
60
|
+
run_store=InMemoryRunStore(),
|
|
61
|
+
ledger_store=InMemoryLedgerStore(),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def run_id(self) -> Optional[str]:
|
|
66
|
+
"""Get the current run ID."""
|
|
67
|
+
return self._current_run_id
|
|
68
|
+
|
|
69
|
+
def start(self, input_data: Optional[Dict[str, Any]] = None) -> str:
|
|
70
|
+
"""Start flow execution.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
input_data: Initial variables for the flow
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
The run ID for this execution
|
|
77
|
+
"""
|
|
78
|
+
vars_dict = input_data or {}
|
|
79
|
+
self._current_run_id = self.runtime.start(
|
|
80
|
+
workflow=self.workflow,
|
|
81
|
+
vars=vars_dict,
|
|
82
|
+
)
|
|
83
|
+
return self._current_run_id
|
|
84
|
+
|
|
85
|
+
def step(self, max_steps: int = 1) -> "RunState":
|
|
86
|
+
"""Execute one or more steps.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
max_steps: Maximum number of steps to execute
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The current RunState after stepping
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: If no run has been started
|
|
96
|
+
"""
|
|
97
|
+
if not self._current_run_id:
|
|
98
|
+
raise ValueError("No active run. Call start() first.")
|
|
99
|
+
|
|
100
|
+
return self.runtime.tick(
|
|
101
|
+
workflow=self.workflow,
|
|
102
|
+
run_id=self._current_run_id,
|
|
103
|
+
max_steps=max_steps,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def run(self, input_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
107
|
+
"""Execute flow to completion.
|
|
108
|
+
|
|
109
|
+
This method starts the flow and runs until it completes, fails,
|
|
110
|
+
or enters a waiting state.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
input_data: Initial variables for the flow
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The flow's output dictionary. If the flow is waiting,
|
|
117
|
+
returns {"waiting": True, "state": <RunState>}.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
RuntimeError: If the flow fails
|
|
121
|
+
"""
|
|
122
|
+
from abstractruntime.core.models import RunStatus, WaitReason
|
|
123
|
+
|
|
124
|
+
self.start(input_data)
|
|
125
|
+
|
|
126
|
+
while True:
|
|
127
|
+
state = self.runtime.tick(
|
|
128
|
+
workflow=self.workflow,
|
|
129
|
+
run_id=self._current_run_id,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if state.status == RunStatus.COMPLETED:
|
|
133
|
+
return state.output or {}
|
|
134
|
+
|
|
135
|
+
if state.status == RunStatus.FAILED:
|
|
136
|
+
raise RuntimeError(f"Flow failed: {state.error}")
|
|
137
|
+
|
|
138
|
+
if state.status == RunStatus.WAITING:
|
|
139
|
+
# Convenience: when waiting on a SUBWORKFLOW, FlowRunner.run() can
|
|
140
|
+
# auto-drive the child to completion and resume the parent.
|
|
141
|
+
#
|
|
142
|
+
# Visual Agent nodes use async+wait START_SUBWORKFLOW so web hosts
|
|
143
|
+
# can stream traces. In non-interactive contexts (unit tests, CLI),
|
|
144
|
+
# we still want a synchronous `run()` to complete when possible.
|
|
145
|
+
wait = getattr(state, "waiting", None)
|
|
146
|
+
if (
|
|
147
|
+
wait is not None
|
|
148
|
+
and getattr(wait, "reason", None) == WaitReason.SUBWORKFLOW
|
|
149
|
+
and getattr(self.runtime, "workflow_registry", None) is not None
|
|
150
|
+
):
|
|
151
|
+
registry = getattr(self.runtime, "workflow_registry", None)
|
|
152
|
+
|
|
153
|
+
def _extract_sub_run_id(wait_state: Any) -> Optional[str]:
|
|
154
|
+
details2 = getattr(wait_state, "details", None)
|
|
155
|
+
if isinstance(details2, dict):
|
|
156
|
+
rid2 = details2.get("sub_run_id")
|
|
157
|
+
if isinstance(rid2, str) and rid2:
|
|
158
|
+
return rid2
|
|
159
|
+
wk = getattr(wait_state, "wait_key", None)
|
|
160
|
+
if isinstance(wk, str) and wk.startswith("subworkflow:"):
|
|
161
|
+
return wk.split("subworkflow:", 1)[1] or None
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def _spec_for(run_state: Any):
|
|
165
|
+
wf_id = getattr(run_state, "workflow_id", None)
|
|
166
|
+
# FlowRunner always has the root workflow spec (self.workflow).
|
|
167
|
+
# The runtime registry is required only for *child* workflows.
|
|
168
|
+
#
|
|
169
|
+
# Without this fallback, synchronous `FlowRunner.run()` can hang on
|
|
170
|
+
# SUBWORKFLOW waits if callers register only subworkflows (common in
|
|
171
|
+
# unit tests where the parent spec is not registered).
|
|
172
|
+
if wf_id == getattr(self.workflow, "workflow_id", None):
|
|
173
|
+
return self.workflow
|
|
174
|
+
return registry.get(wf_id) if registry is not None else None
|
|
175
|
+
|
|
176
|
+
top_run_id = self._current_run_id # type: ignore[assignment]
|
|
177
|
+
if isinstance(top_run_id, str) and top_run_id:
|
|
178
|
+
# Find the deepest run in a SUBWORKFLOW wait chain.
|
|
179
|
+
target_run_id = top_run_id
|
|
180
|
+
for _ in range(50):
|
|
181
|
+
cur_state = self.runtime.get_state(target_run_id)
|
|
182
|
+
if cur_state.status != RunStatus.WAITING or cur_state.waiting is None:
|
|
183
|
+
break
|
|
184
|
+
if cur_state.waiting.reason != WaitReason.SUBWORKFLOW:
|
|
185
|
+
break
|
|
186
|
+
next_id = _extract_sub_run_id(cur_state.waiting)
|
|
187
|
+
if not next_id:
|
|
188
|
+
break
|
|
189
|
+
target_run_id = next_id
|
|
190
|
+
|
|
191
|
+
# Drive runs bottom-up: tick the deepest runnable run, then bubble completion
|
|
192
|
+
# payloads to waiting parents until we either block on external input or
|
|
193
|
+
# the chain unwinds.
|
|
194
|
+
current_run_id = target_run_id
|
|
195
|
+
for _ in range(10_000):
|
|
196
|
+
cur_state = self.runtime.get_state(current_run_id)
|
|
197
|
+
if cur_state.status == RunStatus.RUNNING:
|
|
198
|
+
wf = _spec_for(cur_state)
|
|
199
|
+
if wf is None:
|
|
200
|
+
break
|
|
201
|
+
cur_state = self.runtime.tick(workflow=wf, run_id=current_run_id)
|
|
202
|
+
|
|
203
|
+
if cur_state.status == RunStatus.WAITING:
|
|
204
|
+
# If this is a subworkflow wait, descend further.
|
|
205
|
+
if cur_state.waiting is not None and cur_state.waiting.reason == WaitReason.SUBWORKFLOW:
|
|
206
|
+
next_id = _extract_sub_run_id(cur_state.waiting)
|
|
207
|
+
if next_id:
|
|
208
|
+
current_run_id = next_id
|
|
209
|
+
continue
|
|
210
|
+
# Blocked on non-subworkflow input (ASK_USER / EVENT / UNTIL).
|
|
211
|
+
break
|
|
212
|
+
|
|
213
|
+
if cur_state.status == RunStatus.FAILED:
|
|
214
|
+
raise RuntimeError(f"Subworkflow failed: {cur_state.error}")
|
|
215
|
+
if cur_state.status == RunStatus.CANCELLED:
|
|
216
|
+
raise RuntimeError("Subworkflow cancelled")
|
|
217
|
+
if cur_state.status != RunStatus.COMPLETED:
|
|
218
|
+
break
|
|
219
|
+
|
|
220
|
+
parent_id = getattr(cur_state, "parent_run_id", None)
|
|
221
|
+
if not isinstance(parent_id, str) or not parent_id:
|
|
222
|
+
break
|
|
223
|
+
|
|
224
|
+
parent_state = self.runtime.get_state(parent_id)
|
|
225
|
+
if (
|
|
226
|
+
parent_state.status == RunStatus.WAITING
|
|
227
|
+
and parent_state.waiting is not None
|
|
228
|
+
and parent_state.waiting.reason == WaitReason.SUBWORKFLOW
|
|
229
|
+
):
|
|
230
|
+
parent_wf = _spec_for(parent_state)
|
|
231
|
+
if parent_wf is None:
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
node_traces = None
|
|
235
|
+
try:
|
|
236
|
+
node_traces = self.runtime.get_node_traces(cur_state.run_id)
|
|
237
|
+
except Exception:
|
|
238
|
+
node_traces = None
|
|
239
|
+
|
|
240
|
+
self.runtime.resume(
|
|
241
|
+
workflow=parent_wf,
|
|
242
|
+
run_id=parent_id,
|
|
243
|
+
wait_key=None,
|
|
244
|
+
payload={
|
|
245
|
+
"sub_run_id": cur_state.run_id,
|
|
246
|
+
"output": cur_state.output,
|
|
247
|
+
"node_traces": node_traces,
|
|
248
|
+
},
|
|
249
|
+
max_steps=0,
|
|
250
|
+
)
|
|
251
|
+
current_run_id = parent_id
|
|
252
|
+
# Continue bubbling (and ticking resumed parents) until we unwind.
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
# After driving/bubbling, re-enter the main loop and tick the top run again.
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# Flow is waiting for external input
|
|
261
|
+
return {
|
|
262
|
+
"waiting": True,
|
|
263
|
+
"state": state,
|
|
264
|
+
"wait_key": state.waiting.wait_key if state.waiting else None,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
def resume(
|
|
268
|
+
self,
|
|
269
|
+
wait_key: Optional[str] = None,
|
|
270
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
271
|
+
*,
|
|
272
|
+
max_steps: int = 100,
|
|
273
|
+
) -> "RunState":
|
|
274
|
+
"""Resume a waiting flow.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
wait_key: The wait key to resume (optional, uses current if not specified)
|
|
278
|
+
payload: Data to provide to the waiting node
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
The RunState after resuming
|
|
282
|
+
"""
|
|
283
|
+
if not self._current_run_id:
|
|
284
|
+
raise ValueError("No active run to resume.")
|
|
285
|
+
|
|
286
|
+
return self.runtime.resume(
|
|
287
|
+
workflow=self.workflow,
|
|
288
|
+
run_id=self._current_run_id,
|
|
289
|
+
wait_key=wait_key,
|
|
290
|
+
payload=payload or {},
|
|
291
|
+
max_steps=max_steps,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def get_state(self) -> Optional["RunState"]:
|
|
295
|
+
"""Get the current run state.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
The current RunState, or None if no run is active
|
|
299
|
+
"""
|
|
300
|
+
if not self._current_run_id:
|
|
301
|
+
return None
|
|
302
|
+
return self.runtime.get_state(self._current_run_id)
|
|
303
|
+
|
|
304
|
+
def get_ledger(self) -> list:
|
|
305
|
+
"""Get the execution ledger for the current run.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
List of step records, or empty list if no run
|
|
309
|
+
"""
|
|
310
|
+
if not self._current_run_id:
|
|
311
|
+
return []
|
|
312
|
+
return self.runtime.get_ledger(self._current_run_id)
|
|
313
|
+
|
|
314
|
+
def is_running(self) -> bool:
|
|
315
|
+
"""Check if the flow is currently running."""
|
|
316
|
+
from abstractruntime.core.models import RunStatus
|
|
317
|
+
|
|
318
|
+
state = self.get_state()
|
|
319
|
+
return state is not None and state.status == RunStatus.RUNNING
|
|
320
|
+
|
|
321
|
+
def is_waiting(self) -> bool:
|
|
322
|
+
"""Check if the flow is waiting for input."""
|
|
323
|
+
from abstractruntime.core.models import RunStatus
|
|
324
|
+
|
|
325
|
+
state = self.get_state()
|
|
326
|
+
return state is not None and state.status == RunStatus.WAITING
|
|
327
|
+
|
|
328
|
+
def is_complete(self) -> bool:
|
|
329
|
+
"""Check if the flow has completed."""
|
|
330
|
+
from abstractruntime.core.models import RunStatus
|
|
331
|
+
|
|
332
|
+
state = self.get_state()
|
|
333
|
+
return state is not None and state.status == RunStatus.COMPLETED
|
|
334
|
+
|
|
335
|
+
def is_failed(self) -> bool:
|
|
336
|
+
"""Check if the flow has failed."""
|
|
337
|
+
from abstractruntime.core.models import RunStatus
|
|
338
|
+
|
|
339
|
+
state = self.get_state()
|
|
340
|
+
return state is not None and state.status == RunStatus.FAILED
|
|
341
|
+
|
|
342
|
+
def __repr__(self) -> str:
|
|
343
|
+
status = "not started"
|
|
344
|
+
if self._current_run_id:
|
|
345
|
+
state = self.get_state()
|
|
346
|
+
if state:
|
|
347
|
+
status = state.status.value
|
|
348
|
+
return f"FlowRunner(flow={self.flow.flow_id!r}, status={status!r})"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Portable utilities for AbstractFlow visual workflows.
|
|
2
|
+
|
|
3
|
+
The visual editor saves flows as JSON (nodes/edges). These helpers compile that
|
|
4
|
+
representation into an `abstractflow.Flow` / `abstractruntime.WorkflowSpec` so
|
|
5
|
+
the same workflow can be executed from other hosts (e.g. AbstractCode, CLI),
|
|
6
|
+
not only the web backend.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .executor import create_visual_runner, execute_visual_flow, visual_to_flow
|
|
10
|
+
from .models import (
|
|
11
|
+
ExecutionEvent,
|
|
12
|
+
FlowCreateRequest,
|
|
13
|
+
FlowRunRequest,
|
|
14
|
+
FlowRunResult,
|
|
15
|
+
FlowUpdateRequest,
|
|
16
|
+
NodeType,
|
|
17
|
+
Pin,
|
|
18
|
+
PinType,
|
|
19
|
+
Position,
|
|
20
|
+
VisualEdge,
|
|
21
|
+
VisualFlow,
|
|
22
|
+
VisualNode,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"create_visual_runner",
|
|
27
|
+
"execute_visual_flow",
|
|
28
|
+
"visual_to_flow",
|
|
29
|
+
# Models
|
|
30
|
+
"ExecutionEvent",
|
|
31
|
+
"FlowCreateRequest",
|
|
32
|
+
"FlowRunRequest",
|
|
33
|
+
"FlowRunResult",
|
|
34
|
+
"FlowUpdateRequest",
|
|
35
|
+
"NodeType",
|
|
36
|
+
"Pin",
|
|
37
|
+
"PinType",
|
|
38
|
+
"Position",
|
|
39
|
+
"VisualEdge",
|
|
40
|
+
"VisualFlow",
|
|
41
|
+
"VisualNode",
|
|
42
|
+
]
|
|
43
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Deterministic workflow IDs for VisualFlow Agent nodes.
|
|
2
|
+
|
|
3
|
+
Visual Agent nodes are compiled into `START_SUBWORKFLOW` effects that reference
|
|
4
|
+
an AbstractAgent ReAct workflow registered in the runtime's WorkflowRegistry.
|
|
5
|
+
|
|
6
|
+
IDs must be stable across hosts so a VisualFlow JSON document can be executed
|
|
7
|
+
outside the web editor (CLI, AbstractCode, third-party apps).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_SAFE_ID_RE = re.compile(r"[^a-zA-Z0-9_-]+")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _sanitize(value: str) -> str:
|
|
19
|
+
value = str(value or "").strip()
|
|
20
|
+
if not value:
|
|
21
|
+
return "unknown"
|
|
22
|
+
value = _SAFE_ID_RE.sub("_", value)
|
|
23
|
+
return value or "unknown"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def visual_react_workflow_id(*, flow_id: str, node_id: str) -> str:
|
|
27
|
+
"""Return the workflow_id used for a VisualFlow Agent node's ReAct subworkflow."""
|
|
28
|
+
return f"visual_react_agent_{_sanitize(flow_id)}_{_sanitize(node_id)}"
|
|
29
|
+
|