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.
Files changed (34) hide show
  1. abstractflow/__init__.py +75 -95
  2. abstractflow/__main__.py +2 -0
  3. abstractflow/adapters/__init__.py +11 -0
  4. abstractflow/adapters/agent_adapter.py +124 -0
  5. abstractflow/adapters/control_adapter.py +615 -0
  6. abstractflow/adapters/effect_adapter.py +645 -0
  7. abstractflow/adapters/event_adapter.py +307 -0
  8. abstractflow/adapters/function_adapter.py +97 -0
  9. abstractflow/adapters/subflow_adapter.py +74 -0
  10. abstractflow/adapters/variable_adapter.py +317 -0
  11. abstractflow/cli.py +2 -0
  12. abstractflow/compiler.py +2027 -0
  13. abstractflow/core/__init__.py +5 -0
  14. abstractflow/core/flow.py +247 -0
  15. abstractflow/py.typed +2 -0
  16. abstractflow/runner.py +348 -0
  17. abstractflow/visual/__init__.py +43 -0
  18. abstractflow/visual/agent_ids.py +29 -0
  19. abstractflow/visual/builtins.py +789 -0
  20. abstractflow/visual/code_executor.py +214 -0
  21. abstractflow/visual/event_ids.py +33 -0
  22. abstractflow/visual/executor.py +2789 -0
  23. abstractflow/visual/interfaces.py +347 -0
  24. abstractflow/visual/models.py +252 -0
  25. abstractflow/visual/session_runner.py +168 -0
  26. abstractflow/visual/workspace_scoped_tools.py +261 -0
  27. abstractflow-0.3.0.dist-info/METADATA +413 -0
  28. abstractflow-0.3.0.dist-info/RECORD +32 -0
  29. {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/licenses/LICENSE +2 -0
  30. abstractflow-0.1.0.dist-info/METADATA +0 -238
  31. abstractflow-0.1.0.dist-info/RECORD +0 -10
  32. {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/WHEEL +0 -0
  33. {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/entry_points.txt +0 -0
  34. {abstractflow-0.1.0.dist-info → abstractflow-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,5 @@
1
+ """AbstractFlow core module."""
2
+
3
+ from .flow import Flow, FlowNode, FlowEdge
4
+
5
+ __all__ = ["Flow", "FlowNode", "FlowEdge"]
@@ -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
@@ -1 +1,3 @@
1
1
  # Marker file for PEP 561 - indicates this package supports type checking
2
+
3
+
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
+