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
abstractflow/compiler.py
ADDED
|
@@ -0,0 +1,2027 @@
|
|
|
1
|
+
"""Flow compiler - converts Flow definitions to AbstractRuntime WorkflowSpec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .core.flow import Flow
|
|
8
|
+
from .adapters.function_adapter import create_function_node_handler
|
|
9
|
+
from .adapters.agent_adapter import create_agent_node_handler
|
|
10
|
+
from .adapters.subflow_adapter import create_subflow_node_handler
|
|
11
|
+
from .adapters.effect_adapter import (
|
|
12
|
+
create_ask_user_handler,
|
|
13
|
+
create_answer_user_handler,
|
|
14
|
+
create_wait_until_handler,
|
|
15
|
+
create_wait_event_handler,
|
|
16
|
+
create_memory_note_handler,
|
|
17
|
+
create_memory_query_handler,
|
|
18
|
+
create_memory_rehydrate_handler,
|
|
19
|
+
create_llm_call_handler,
|
|
20
|
+
create_tool_calls_handler,
|
|
21
|
+
create_start_subworkflow_handler,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from abstractruntime.core.models import StepPlan
|
|
26
|
+
from abstractruntime.core.spec import WorkflowSpec
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_agent(obj: Any) -> bool:
|
|
30
|
+
"""Check if object is an agent (has workflow attribute)."""
|
|
31
|
+
return hasattr(obj, "workflow") and hasattr(obj, "start") and hasattr(obj, "step")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_flow(obj: Any) -> bool:
|
|
35
|
+
"""Check if object is a Flow."""
|
|
36
|
+
return isinstance(obj, Flow)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _create_effect_node_handler(
|
|
40
|
+
node_id: str,
|
|
41
|
+
effect_type: str,
|
|
42
|
+
effect_config: Dict[str, Any],
|
|
43
|
+
next_node: Optional[str],
|
|
44
|
+
input_key: Optional[str],
|
|
45
|
+
output_key: Optional[str],
|
|
46
|
+
data_aware_handler: Optional[Callable] = None,
|
|
47
|
+
*,
|
|
48
|
+
flow: Optional[Flow] = None,
|
|
49
|
+
) -> Callable:
|
|
50
|
+
"""Create a node handler for effect nodes.
|
|
51
|
+
|
|
52
|
+
Effect nodes produce AbstractRuntime Effects that can pause execution
|
|
53
|
+
and wait for external input.
|
|
54
|
+
|
|
55
|
+
If data_aware_handler is provided (from visual flow's executor), it will
|
|
56
|
+
be called first to resolve data edge inputs before creating the effect.
|
|
57
|
+
"""
|
|
58
|
+
from abstractruntime.core.models import StepPlan, Effect, EffectType
|
|
59
|
+
|
|
60
|
+
# Build the base effect handler
|
|
61
|
+
if effect_type == "ask_user":
|
|
62
|
+
base_handler = create_ask_user_handler(
|
|
63
|
+
node_id=node_id,
|
|
64
|
+
next_node=next_node,
|
|
65
|
+
input_key=input_key,
|
|
66
|
+
output_key=output_key,
|
|
67
|
+
allow_free_text=effect_config.get("allowFreeText", True),
|
|
68
|
+
)
|
|
69
|
+
elif effect_type == "answer_user":
|
|
70
|
+
base_handler = create_answer_user_handler(
|
|
71
|
+
node_id=node_id,
|
|
72
|
+
next_node=next_node,
|
|
73
|
+
input_key=input_key,
|
|
74
|
+
output_key=output_key,
|
|
75
|
+
)
|
|
76
|
+
elif effect_type == "wait_until":
|
|
77
|
+
base_handler = create_wait_until_handler(
|
|
78
|
+
node_id=node_id,
|
|
79
|
+
next_node=next_node,
|
|
80
|
+
input_key=input_key,
|
|
81
|
+
output_key=output_key,
|
|
82
|
+
duration_type=effect_config.get("durationType", "seconds"),
|
|
83
|
+
)
|
|
84
|
+
elif effect_type == "wait_event":
|
|
85
|
+
base_handler = create_wait_event_handler(
|
|
86
|
+
node_id=node_id,
|
|
87
|
+
next_node=next_node,
|
|
88
|
+
input_key=input_key,
|
|
89
|
+
output_key=output_key,
|
|
90
|
+
)
|
|
91
|
+
elif effect_type == "memory_note":
|
|
92
|
+
base_handler = create_memory_note_handler(
|
|
93
|
+
node_id=node_id,
|
|
94
|
+
next_node=next_node,
|
|
95
|
+
input_key=input_key,
|
|
96
|
+
output_key=output_key,
|
|
97
|
+
)
|
|
98
|
+
elif effect_type == "memory_query":
|
|
99
|
+
base_handler = create_memory_query_handler(
|
|
100
|
+
node_id=node_id,
|
|
101
|
+
next_node=next_node,
|
|
102
|
+
input_key=input_key,
|
|
103
|
+
output_key=output_key,
|
|
104
|
+
)
|
|
105
|
+
elif effect_type == "memory_rehydrate":
|
|
106
|
+
base_handler = create_memory_rehydrate_handler(
|
|
107
|
+
node_id=node_id,
|
|
108
|
+
next_node=next_node,
|
|
109
|
+
input_key=input_key,
|
|
110
|
+
output_key=output_key,
|
|
111
|
+
)
|
|
112
|
+
elif effect_type == "llm_call":
|
|
113
|
+
base_handler = create_llm_call_handler(
|
|
114
|
+
node_id=node_id,
|
|
115
|
+
next_node=next_node,
|
|
116
|
+
input_key=input_key,
|
|
117
|
+
output_key=output_key,
|
|
118
|
+
provider=effect_config.get("provider"),
|
|
119
|
+
model=effect_config.get("model"),
|
|
120
|
+
temperature=effect_config.get("temperature", 0.7),
|
|
121
|
+
)
|
|
122
|
+
elif effect_type == "tool_calls":
|
|
123
|
+
base_handler = create_tool_calls_handler(
|
|
124
|
+
node_id=node_id,
|
|
125
|
+
next_node=next_node,
|
|
126
|
+
input_key=input_key,
|
|
127
|
+
output_key=output_key,
|
|
128
|
+
allowed_tools=effect_config.get("allowed_tools") if isinstance(effect_config, dict) else None,
|
|
129
|
+
)
|
|
130
|
+
elif effect_type == "start_subworkflow":
|
|
131
|
+
base_handler = create_start_subworkflow_handler(
|
|
132
|
+
node_id=node_id,
|
|
133
|
+
next_node=next_node,
|
|
134
|
+
input_key=input_key,
|
|
135
|
+
output_key=output_key,
|
|
136
|
+
workflow_id=effect_config.get("workflow_id"),
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
raise ValueError(f"Unknown effect type: {effect_type}")
|
|
140
|
+
|
|
141
|
+
# If no data-aware handler, just return the base effect handler
|
|
142
|
+
if data_aware_handler is None:
|
|
143
|
+
return base_handler
|
|
144
|
+
|
|
145
|
+
# Wrap to resolve data edges before creating the effect
|
|
146
|
+
def wrapped_effect_handler(run: Any, ctx: Any) -> "StepPlan":
|
|
147
|
+
"""Resolve data edges via executor handler, then create the proper Effect."""
|
|
148
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
149
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
150
|
+
|
|
151
|
+
# Call the data-aware handler to resolve data edge inputs
|
|
152
|
+
# This reads from flow._node_outputs which has literal values
|
|
153
|
+
last_output = run.vars.get("_last_output", {})
|
|
154
|
+
resolved = data_aware_handler(last_output)
|
|
155
|
+
|
|
156
|
+
# Check if this returned a _pending_effect marker (from executor's effect handlers)
|
|
157
|
+
if isinstance(resolved, dict) and "_pending_effect" in resolved:
|
|
158
|
+
pending = resolved["_pending_effect"]
|
|
159
|
+
effect_type_str = pending.get("type", "")
|
|
160
|
+
|
|
161
|
+
# Get the EffectType enum value by name (avoid building dict with all members)
|
|
162
|
+
eff_type = None
|
|
163
|
+
try:
|
|
164
|
+
eff_type = EffectType(effect_type_str)
|
|
165
|
+
except ValueError:
|
|
166
|
+
pass # Unknown effect type
|
|
167
|
+
if eff_type:
|
|
168
|
+
# Visual LLM Call UX: include the run's active context messages when possible.
|
|
169
|
+
#
|
|
170
|
+
# Why here (compiler) and not in AbstractRuntime:
|
|
171
|
+
# - LLM_CALL is a generic runtime effect; not all callers want implicit context.
|
|
172
|
+
# - Visual LLM Call nodes expect "Recall into context" to affect subsequent calls.
|
|
173
|
+
if (
|
|
174
|
+
eff_type == EffectType.LLM_CALL
|
|
175
|
+
and isinstance(pending, dict)
|
|
176
|
+
and "messages" not in pending
|
|
177
|
+
and pending.get("include_context") is True
|
|
178
|
+
):
|
|
179
|
+
try:
|
|
180
|
+
from abstractruntime.memory.active_context import ActiveContextPolicy
|
|
181
|
+
|
|
182
|
+
base = ActiveContextPolicy.select_active_messages_for_llm_from_run(run)
|
|
183
|
+
messages = [dict(m) for m in base if isinstance(m, dict)]
|
|
184
|
+
|
|
185
|
+
sys_raw = pending.get("system_prompt") or pending.get("system")
|
|
186
|
+
sys_text = str(sys_raw).strip() if isinstance(sys_raw, str) else ""
|
|
187
|
+
if sys_text:
|
|
188
|
+
# Insert after existing system messages (system must precede user/assistant).
|
|
189
|
+
insert_at = 0
|
|
190
|
+
while insert_at < len(messages):
|
|
191
|
+
m = messages[insert_at]
|
|
192
|
+
if not isinstance(m, dict) or m.get("role") != "system":
|
|
193
|
+
break
|
|
194
|
+
insert_at += 1
|
|
195
|
+
messages.insert(insert_at, {"role": "system", "content": sys_text})
|
|
196
|
+
|
|
197
|
+
prompt_raw = pending.get("prompt")
|
|
198
|
+
prompt_text = prompt_raw if isinstance(prompt_raw, str) else str(prompt_raw or "")
|
|
199
|
+
messages.append({"role": "user", "content": prompt_text})
|
|
200
|
+
|
|
201
|
+
pending["messages"] = messages
|
|
202
|
+
# Avoid double-including prompt/system_prompt if the LLM client also
|
|
203
|
+
# builds messages from them.
|
|
204
|
+
pending.pop("prompt", None)
|
|
205
|
+
pending.pop("system_prompt", None)
|
|
206
|
+
pending.pop("system", None)
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
# Visual Subflow UX: optionally seed the child run's `context.messages` from the
|
|
211
|
+
# parent run's active context view (so LLM/Agent nodes inside the subflow can
|
|
212
|
+
# "Use context" without extra wiring).
|
|
213
|
+
if (
|
|
214
|
+
eff_type == EffectType.START_SUBWORKFLOW
|
|
215
|
+
and isinstance(pending, dict)
|
|
216
|
+
and pending.get("inherit_context") is True
|
|
217
|
+
):
|
|
218
|
+
try:
|
|
219
|
+
from abstractruntime.memory.active_context import ActiveContextPolicy
|
|
220
|
+
|
|
221
|
+
inherited = ActiveContextPolicy.select_active_messages_for_llm_from_run(run)
|
|
222
|
+
inherited_msgs = [dict(m) for m in inherited if isinstance(m, dict)]
|
|
223
|
+
if inherited_msgs:
|
|
224
|
+
sub_vars = pending.get("vars")
|
|
225
|
+
if not isinstance(sub_vars, dict):
|
|
226
|
+
sub_vars = {}
|
|
227
|
+
sub_ctx = sub_vars.get("context")
|
|
228
|
+
if not isinstance(sub_ctx, dict):
|
|
229
|
+
sub_ctx = {}
|
|
230
|
+
sub_vars["context"] = sub_ctx
|
|
231
|
+
|
|
232
|
+
# Explicit child context wins (do not override).
|
|
233
|
+
existing = sub_ctx.get("messages")
|
|
234
|
+
if not isinstance(existing, list) or not existing:
|
|
235
|
+
sub_ctx["messages"] = inherited_msgs
|
|
236
|
+
|
|
237
|
+
pending["vars"] = sub_vars
|
|
238
|
+
except Exception:
|
|
239
|
+
pass
|
|
240
|
+
# Keep payload clean (runtime ignores it, but it clutters traces).
|
|
241
|
+
pending.pop("inherit_context", None)
|
|
242
|
+
|
|
243
|
+
# Build the Effect with resolved values from data edges
|
|
244
|
+
effect = Effect(
|
|
245
|
+
type=eff_type,
|
|
246
|
+
payload={
|
|
247
|
+
**pending,
|
|
248
|
+
"resume_to_node": next_node,
|
|
249
|
+
},
|
|
250
|
+
# Always store effect outcomes per-node; visual syncing can optionally copy to output_key.
|
|
251
|
+
result_key=f"_temp.effects.{node_id}",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return StepPlan(
|
|
255
|
+
node_id=node_id,
|
|
256
|
+
effect=effect,
|
|
257
|
+
next_node=next_node,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Fallback: run.vars won't have the values, but try anyway
|
|
261
|
+
return base_handler(run, ctx)
|
|
262
|
+
|
|
263
|
+
return wrapped_effect_handler
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _create_visual_agent_effect_handler(
|
|
267
|
+
*,
|
|
268
|
+
node_id: str,
|
|
269
|
+
next_node: Optional[str],
|
|
270
|
+
agent_config: Dict[str, Any],
|
|
271
|
+
data_aware_handler: Optional[Callable[[Any], Any]],
|
|
272
|
+
flow: Flow,
|
|
273
|
+
) -> Callable:
|
|
274
|
+
"""Create a handler for the visual Agent node.
|
|
275
|
+
|
|
276
|
+
Visual Agent nodes delegate to AbstractAgent's canonical ReAct workflow
|
|
277
|
+
via `START_SUBWORKFLOW` (runtime-owned execution and persistence).
|
|
278
|
+
|
|
279
|
+
This handler:
|
|
280
|
+
- resolves `task` / `context` via data edges
|
|
281
|
+
- starts the configured ReAct subworkflow (sync; may wait)
|
|
282
|
+
- exposes the final agent result and trace ("scratchpad") via output pins
|
|
283
|
+
- optionally performs a final structured-output LLM_CALL (format-only pass)
|
|
284
|
+
"""
|
|
285
|
+
import json
|
|
286
|
+
|
|
287
|
+
from abstractruntime.core.models import Effect, EffectType, StepPlan
|
|
288
|
+
|
|
289
|
+
from .visual.agent_ids import visual_react_workflow_id
|
|
290
|
+
|
|
291
|
+
def _ensure_temp_dict(run: Any) -> Dict[str, Any]:
|
|
292
|
+
temp = run.vars.get("_temp")
|
|
293
|
+
if not isinstance(temp, dict):
|
|
294
|
+
temp = {}
|
|
295
|
+
run.vars["_temp"] = temp
|
|
296
|
+
return temp
|
|
297
|
+
|
|
298
|
+
def _get_agent_bucket(run: Any) -> Dict[str, Any]:
|
|
299
|
+
temp = _ensure_temp_dict(run)
|
|
300
|
+
agent = temp.get("agent")
|
|
301
|
+
if not isinstance(agent, dict):
|
|
302
|
+
agent = {}
|
|
303
|
+
temp["agent"] = agent
|
|
304
|
+
bucket = agent.get(node_id)
|
|
305
|
+
if not isinstance(bucket, dict):
|
|
306
|
+
bucket = {}
|
|
307
|
+
agent[node_id] = bucket
|
|
308
|
+
return bucket
|
|
309
|
+
|
|
310
|
+
def _resolve_inputs(run: Any) -> Dict[str, Any]:
|
|
311
|
+
if hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
312
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
313
|
+
|
|
314
|
+
if not callable(data_aware_handler):
|
|
315
|
+
return {}
|
|
316
|
+
last_output = run.vars.get("_last_output", {})
|
|
317
|
+
try:
|
|
318
|
+
resolved = data_aware_handler(last_output)
|
|
319
|
+
except Exception:
|
|
320
|
+
resolved = {}
|
|
321
|
+
return resolved if isinstance(resolved, dict) else {}
|
|
322
|
+
|
|
323
|
+
def _flatten_node_traces(node_traces: Any) -> list[Dict[str, Any]]:
|
|
324
|
+
if not isinstance(node_traces, dict):
|
|
325
|
+
return []
|
|
326
|
+
out: list[Dict[str, Any]] = []
|
|
327
|
+
for trace in node_traces.values():
|
|
328
|
+
if not isinstance(trace, dict):
|
|
329
|
+
continue
|
|
330
|
+
steps = trace.get("steps")
|
|
331
|
+
if not isinstance(steps, list):
|
|
332
|
+
continue
|
|
333
|
+
for s in steps:
|
|
334
|
+
if isinstance(s, dict):
|
|
335
|
+
out.append(dict(s))
|
|
336
|
+
out.sort(key=lambda s: str(s.get("ts") or ""))
|
|
337
|
+
return out
|
|
338
|
+
|
|
339
|
+
def _as_dict_list(value: Any) -> list[Dict[str, Any]]:
|
|
340
|
+
if value is None:
|
|
341
|
+
return []
|
|
342
|
+
if isinstance(value, dict):
|
|
343
|
+
return [dict(value)]
|
|
344
|
+
if isinstance(value, list):
|
|
345
|
+
out: list[Dict[str, Any]] = []
|
|
346
|
+
for x in value:
|
|
347
|
+
if isinstance(x, dict):
|
|
348
|
+
out.append(dict(x))
|
|
349
|
+
return out
|
|
350
|
+
return []
|
|
351
|
+
|
|
352
|
+
def _extract_tool_activity_from_steps(steps: Any) -> tuple[list[Dict[str, Any]], list[Dict[str, Any]]]:
|
|
353
|
+
"""Best-effort tool call/result extraction from flattened scratchpad steps."""
|
|
354
|
+
if not isinstance(steps, list):
|
|
355
|
+
return [], []
|
|
356
|
+
tool_calls: list[Dict[str, Any]] = []
|
|
357
|
+
tool_results: list[Dict[str, Any]] = []
|
|
358
|
+
for entry_any in steps:
|
|
359
|
+
entry = entry_any if isinstance(entry_any, dict) else None
|
|
360
|
+
if entry is None:
|
|
361
|
+
continue
|
|
362
|
+
effect = entry.get("effect")
|
|
363
|
+
if not isinstance(effect, dict) or str(effect.get("type") or "") != "tool_calls":
|
|
364
|
+
continue
|
|
365
|
+
payload = effect.get("payload")
|
|
366
|
+
payload_d = payload if isinstance(payload, dict) else {}
|
|
367
|
+
tool_calls.extend(_as_dict_list(payload_d.get("tool_calls")))
|
|
368
|
+
|
|
369
|
+
result = entry.get("result")
|
|
370
|
+
if not isinstance(result, dict):
|
|
371
|
+
continue
|
|
372
|
+
tool_results.extend(_as_dict_list(result.get("results")))
|
|
373
|
+
return tool_calls, tool_results
|
|
374
|
+
|
|
375
|
+
def _build_sub_vars(
|
|
376
|
+
run: Any,
|
|
377
|
+
*,
|
|
378
|
+
task: str,
|
|
379
|
+
context: Dict[str, Any],
|
|
380
|
+
provider: str,
|
|
381
|
+
model: str,
|
|
382
|
+
system_prompt: str,
|
|
383
|
+
allowed_tools: list[str],
|
|
384
|
+
include_context: bool = False,
|
|
385
|
+
max_iterations: Optional[int] = None,
|
|
386
|
+
) -> Dict[str, Any]:
|
|
387
|
+
parent_limits = run.vars.get("_limits")
|
|
388
|
+
limits = dict(parent_limits) if isinstance(parent_limits, dict) else {}
|
|
389
|
+
limits.setdefault("max_iterations", 25)
|
|
390
|
+
limits.setdefault("current_iteration", 0)
|
|
391
|
+
limits.setdefault("max_tokens", 32768)
|
|
392
|
+
limits.setdefault("max_output_tokens", None)
|
|
393
|
+
limits.setdefault("max_history_messages", -1)
|
|
394
|
+
limits.setdefault("estimated_tokens_used", 0)
|
|
395
|
+
limits.setdefault("warn_iterations_pct", 80)
|
|
396
|
+
limits.setdefault("warn_tokens_pct", 80)
|
|
397
|
+
|
|
398
|
+
if isinstance(max_iterations, int) and max_iterations > 0:
|
|
399
|
+
limits["max_iterations"] = int(max_iterations)
|
|
400
|
+
|
|
401
|
+
ctx_ns: Dict[str, Any] = {"task": str(task or ""), "messages": []}
|
|
402
|
+
|
|
403
|
+
# Optional: inherit the parent's active context as agent history (including Recall into context inserts).
|
|
404
|
+
# This is a visual-editor UX feature; it is disabled by default and can be enabled via
|
|
405
|
+
# agentConfig.include_context or the include_context input pin.
|
|
406
|
+
if bool(include_context):
|
|
407
|
+
try:
|
|
408
|
+
from abstractruntime.memory.active_context import ActiveContextPolicy
|
|
409
|
+
|
|
410
|
+
base = ActiveContextPolicy.select_active_messages_for_llm_from_run(run)
|
|
411
|
+
if isinstance(base, list):
|
|
412
|
+
ctx_ns["messages"] = [dict(m) for m in base if isinstance(m, dict)]
|
|
413
|
+
except Exception:
|
|
414
|
+
pass
|
|
415
|
+
|
|
416
|
+
# Explicit context.messages from a pin overrides the inherited run context.
|
|
417
|
+
raw_msgs = context.get("messages") if isinstance(context, dict) else None
|
|
418
|
+
if isinstance(raw_msgs, list):
|
|
419
|
+
msgs = [dict(m) for m in raw_msgs if isinstance(m, dict)]
|
|
420
|
+
if msgs:
|
|
421
|
+
ctx_ns["messages"] = msgs
|
|
422
|
+
|
|
423
|
+
if isinstance(context, dict) and context:
|
|
424
|
+
for k, v in context.items():
|
|
425
|
+
if k in ("task", "messages"):
|
|
426
|
+
continue
|
|
427
|
+
ctx_ns[str(k)] = v
|
|
428
|
+
|
|
429
|
+
runtime_ns: Dict[str, Any] = {"inbox": [], "provider": provider, "model": model, "allowed_tools": list(allowed_tools)}
|
|
430
|
+
if isinstance(system_prompt, str) and system_prompt.strip():
|
|
431
|
+
runtime_ns["system_prompt"] = system_prompt
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
"context": ctx_ns,
|
|
435
|
+
"scratchpad": {"iteration": 0, "max_iterations": int(limits.get("max_iterations") or 25)},
|
|
436
|
+
# `_runtime` is durable; we store provider/model here so the ReAct subworkflow
|
|
437
|
+
# can inject them into LLM_CALL payloads (and remain resumable).
|
|
438
|
+
"_runtime": runtime_ns,
|
|
439
|
+
"_temp": {},
|
|
440
|
+
"_limits": limits,
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
def _coerce_max_iterations(value: Any) -> Optional[int]:
|
|
444
|
+
try:
|
|
445
|
+
if value is None:
|
|
446
|
+
return None
|
|
447
|
+
if isinstance(value, bool):
|
|
448
|
+
return None
|
|
449
|
+
if isinstance(value, (int, float)):
|
|
450
|
+
iv = int(float(value))
|
|
451
|
+
return iv if iv > 0 else None
|
|
452
|
+
if isinstance(value, str) and value.strip():
|
|
453
|
+
iv = int(float(value.strip()))
|
|
454
|
+
return iv if iv > 0 else None
|
|
455
|
+
except Exception:
|
|
456
|
+
return None
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
def handler(run: Any, ctx: Any) -> "StepPlan":
|
|
460
|
+
del ctx
|
|
461
|
+
|
|
462
|
+
output_schema_cfg = agent_config.get("outputSchema") if isinstance(agent_config.get("outputSchema"), dict) else {}
|
|
463
|
+
schema_enabled = bool(output_schema_cfg.get("enabled"))
|
|
464
|
+
schema = output_schema_cfg.get("jsonSchema") if isinstance(output_schema_cfg.get("jsonSchema"), dict) else None
|
|
465
|
+
|
|
466
|
+
bucket = _get_agent_bucket(run)
|
|
467
|
+
phase = str(bucket.get("phase") or "init")
|
|
468
|
+
|
|
469
|
+
# IMPORTANT: This visual Agent node can be executed multiple times within a single run
|
|
470
|
+
# (e.g. inside a Loop/While/Sequence). The per-node bucket is durable and would otherwise
|
|
471
|
+
# keep `phase="done"` and `resolved_inputs` from the first invocation, causing subsequent
|
|
472
|
+
# invocations to skip work and reuse stale inputs/results.
|
|
473
|
+
#
|
|
474
|
+
# When we re-enter the node after it previously completed, reset the bucket to start a
|
|
475
|
+
# fresh subworkflow invocation with the current upstream inputs.
|
|
476
|
+
if phase == "done":
|
|
477
|
+
try:
|
|
478
|
+
bucket.clear()
|
|
479
|
+
except Exception:
|
|
480
|
+
# Best-effort; if clear fails, overwrite key fields below.
|
|
481
|
+
pass
|
|
482
|
+
phase = "init"
|
|
483
|
+
bucket["phase"] = "init"
|
|
484
|
+
|
|
485
|
+
resolved_inputs = bucket.get("resolved_inputs")
|
|
486
|
+
if not isinstance(resolved_inputs, dict) or phase == "init":
|
|
487
|
+
resolved_inputs = _resolve_inputs(run)
|
|
488
|
+
bucket["resolved_inputs"] = resolved_inputs if isinstance(resolved_inputs, dict) else {}
|
|
489
|
+
|
|
490
|
+
# Pin-driven structured output:
|
|
491
|
+
# If `response_schema` is provided via an input pin (data edge), it overrides the node config
|
|
492
|
+
# and enables the structured-output post-pass (durable LLM_CALL).
|
|
493
|
+
pin_schema = resolved_inputs.get("response_schema") if isinstance(resolved_inputs, dict) else None
|
|
494
|
+
if isinstance(pin_schema, dict) and pin_schema:
|
|
495
|
+
schema = dict(pin_schema)
|
|
496
|
+
schema_enabled = True
|
|
497
|
+
|
|
498
|
+
# Provider/model can come from Agent node config or from data-edge inputs (pins).
|
|
499
|
+
provider_raw = resolved_inputs.get("provider") if isinstance(resolved_inputs, dict) else None
|
|
500
|
+
model_raw = resolved_inputs.get("model") if isinstance(resolved_inputs, dict) else None
|
|
501
|
+
if not isinstance(provider_raw, str) or not provider_raw.strip():
|
|
502
|
+
provider_raw = agent_config.get("provider")
|
|
503
|
+
if not isinstance(model_raw, str) or not model_raw.strip():
|
|
504
|
+
model_raw = agent_config.get("model")
|
|
505
|
+
|
|
506
|
+
provider = str(provider_raw or "").strip().lower() if isinstance(provider_raw, str) else ""
|
|
507
|
+
model = str(model_raw or "").strip() if isinstance(model_raw, str) else ""
|
|
508
|
+
|
|
509
|
+
task = str(resolved_inputs.get("task") or "")
|
|
510
|
+
context_raw = resolved_inputs.get("context")
|
|
511
|
+
context = context_raw if isinstance(context_raw, dict) else {}
|
|
512
|
+
system_raw = resolved_inputs.get("system") if isinstance(resolved_inputs, dict) else None
|
|
513
|
+
system_prompt = system_raw if isinstance(system_raw, str) else str(system_raw or "")
|
|
514
|
+
|
|
515
|
+
# Include parent run context (as agent history):
|
|
516
|
+
# - Pin override wins when connected (resolved_inputs contains include_context)
|
|
517
|
+
# - Otherwise fall back to node config (checkbox)
|
|
518
|
+
# - Default: false
|
|
519
|
+
include_context: bool
|
|
520
|
+
if isinstance(resolved_inputs, dict) and "include_context" in resolved_inputs:
|
|
521
|
+
include_context = bool(resolved_inputs.get("include_context"))
|
|
522
|
+
else:
|
|
523
|
+
include_context_cfg = agent_config.get("include_context")
|
|
524
|
+
include_context = bool(include_context_cfg) if include_context_cfg is not None else False
|
|
525
|
+
|
|
526
|
+
# Agent loop budget (max_iterations) can come from a data-edge pin or from config.
|
|
527
|
+
max_iterations_raw = resolved_inputs.get("max_iterations") if isinstance(resolved_inputs, dict) else None
|
|
528
|
+
max_iterations_override = _coerce_max_iterations(max_iterations_raw)
|
|
529
|
+
if max_iterations_override is None:
|
|
530
|
+
max_iterations_override = _coerce_max_iterations(agent_config.get("max_iterations"))
|
|
531
|
+
|
|
532
|
+
# Tools selection:
|
|
533
|
+
# - If the resolved inputs explicitly include `tools` (e.g. tools pin connected),
|
|
534
|
+
# respect it even if it's an empty list (disables tools).
|
|
535
|
+
# - Otherwise fall back to the Agent node's configuration.
|
|
536
|
+
if isinstance(resolved_inputs, dict) and "tools" in resolved_inputs:
|
|
537
|
+
tools_raw = resolved_inputs.get("tools")
|
|
538
|
+
else:
|
|
539
|
+
tools_raw = agent_config.get("tools")
|
|
540
|
+
allowed_tools: list[str] = []
|
|
541
|
+
if isinstance(tools_raw, list):
|
|
542
|
+
for t in tools_raw:
|
|
543
|
+
if isinstance(t, str) and t.strip():
|
|
544
|
+
allowed_tools.append(t.strip())
|
|
545
|
+
elif isinstance(tools_raw, tuple):
|
|
546
|
+
for t in tools_raw:
|
|
547
|
+
if isinstance(t, str) and t.strip():
|
|
548
|
+
allowed_tools.append(t.strip())
|
|
549
|
+
elif isinstance(tools_raw, str) and tools_raw.strip():
|
|
550
|
+
allowed_tools.append(tools_raw.strip())
|
|
551
|
+
|
|
552
|
+
# De-dup while preserving order.
|
|
553
|
+
seen_tools: set[str] = set()
|
|
554
|
+
allowed_tools = [t for t in allowed_tools if not (t in seen_tools or seen_tools.add(t))]
|
|
555
|
+
|
|
556
|
+
workflow_id_raw = agent_config.get("_react_workflow_id")
|
|
557
|
+
react_workflow_id = (
|
|
558
|
+
workflow_id_raw.strip()
|
|
559
|
+
if isinstance(workflow_id_raw, str) and workflow_id_raw.strip()
|
|
560
|
+
else visual_react_workflow_id(flow_id=flow.flow_id, node_id=node_id)
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
if phase == "init":
|
|
564
|
+
if not provider or not model:
|
|
565
|
+
run.vars["_flow_error"] = "Agent node missing provider/model configuration"
|
|
566
|
+
run.vars["_flow_error_node"] = node_id
|
|
567
|
+
out = {
|
|
568
|
+
"result": "Agent configuration error: missing provider/model",
|
|
569
|
+
"task": task,
|
|
570
|
+
"context": context,
|
|
571
|
+
"success": False,
|
|
572
|
+
"error": "missing provider/model",
|
|
573
|
+
"provider": provider or "unknown",
|
|
574
|
+
"model": model or "unknown",
|
|
575
|
+
}
|
|
576
|
+
_set_nested(run.vars, f"_temp.effects.{node_id}", out)
|
|
577
|
+
bucket["phase"] = "done"
|
|
578
|
+
flow._node_outputs[node_id] = {
|
|
579
|
+
"result": out,
|
|
580
|
+
"scratchpad": {"node_id": node_id, "steps": []},
|
|
581
|
+
"tool_calls": [],
|
|
582
|
+
"tool_results": [],
|
|
583
|
+
}
|
|
584
|
+
run.vars["_last_output"] = {"result": out}
|
|
585
|
+
if next_node:
|
|
586
|
+
return StepPlan(node_id=node_id, next_node=next_node)
|
|
587
|
+
return StepPlan(node_id=node_id, complete_output={"result": out, "success": False})
|
|
588
|
+
|
|
589
|
+
bucket["phase"] = "subworkflow"
|
|
590
|
+
flow._node_outputs[node_id] = {"status": "running", "task": task, "context": context, "result": None}
|
|
591
|
+
|
|
592
|
+
return StepPlan(
|
|
593
|
+
node_id=node_id,
|
|
594
|
+
effect=Effect(
|
|
595
|
+
type=EffectType.START_SUBWORKFLOW,
|
|
596
|
+
payload={
|
|
597
|
+
"workflow_id": react_workflow_id,
|
|
598
|
+
"vars": _build_sub_vars(
|
|
599
|
+
run,
|
|
600
|
+
task=task,
|
|
601
|
+
context=context,
|
|
602
|
+
provider=provider,
|
|
603
|
+
model=model,
|
|
604
|
+
system_prompt=system_prompt,
|
|
605
|
+
allowed_tools=allowed_tools,
|
|
606
|
+
include_context=include_context,
|
|
607
|
+
max_iterations=max_iterations_override,
|
|
608
|
+
),
|
|
609
|
+
# Run Agent as a durable async subworkflow so the host can:
|
|
610
|
+
# - tick the child incrementally (real-time observability of each effect)
|
|
611
|
+
# - resume the parent once the child completes (async+wait mode)
|
|
612
|
+
"async": True,
|
|
613
|
+
"wait": True,
|
|
614
|
+
"include_traces": True,
|
|
615
|
+
},
|
|
616
|
+
result_key=f"_temp.agent.{node_id}.sub",
|
|
617
|
+
),
|
|
618
|
+
next_node=node_id,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
if phase == "subworkflow":
|
|
622
|
+
sub = bucket.get("sub")
|
|
623
|
+
if sub is None:
|
|
624
|
+
temp = _ensure_temp_dict(run)
|
|
625
|
+
agent_ns = temp.get("agent")
|
|
626
|
+
if isinstance(agent_ns, dict):
|
|
627
|
+
node_bucket = agent_ns.get(node_id)
|
|
628
|
+
if isinstance(node_bucket, dict):
|
|
629
|
+
sub = node_bucket.get("sub")
|
|
630
|
+
|
|
631
|
+
if not isinstance(sub, dict):
|
|
632
|
+
return StepPlan(node_id=node_id, next_node=node_id)
|
|
633
|
+
|
|
634
|
+
sub_run_id = sub.get("sub_run_id") if isinstance(sub.get("sub_run_id"), str) else None
|
|
635
|
+
output = sub.get("output")
|
|
636
|
+
output_dict = output if isinstance(output, dict) else {}
|
|
637
|
+
answer = str(output_dict.get("answer") or "")
|
|
638
|
+
iterations = output_dict.get("iterations")
|
|
639
|
+
|
|
640
|
+
node_traces = sub.get("node_traces")
|
|
641
|
+
scratchpad = {
|
|
642
|
+
"sub_run_id": sub_run_id,
|
|
643
|
+
"workflow_id": react_workflow_id,
|
|
644
|
+
"node_traces": node_traces if isinstance(node_traces, dict) else {},
|
|
645
|
+
"steps": _flatten_node_traces(node_traces),
|
|
646
|
+
}
|
|
647
|
+
bucket["scratchpad"] = scratchpad
|
|
648
|
+
tc, tr = _extract_tool_activity_from_steps(scratchpad.get("steps"))
|
|
649
|
+
|
|
650
|
+
result_obj = {
|
|
651
|
+
"result": answer,
|
|
652
|
+
"task": task,
|
|
653
|
+
"context": context,
|
|
654
|
+
"success": True,
|
|
655
|
+
"provider": provider,
|
|
656
|
+
"model": model,
|
|
657
|
+
"iterations": iterations,
|
|
658
|
+
"sub_run_id": sub_run_id,
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if schema_enabled and schema:
|
|
662
|
+
bucket["phase"] = "structured"
|
|
663
|
+
messages = [
|
|
664
|
+
{
|
|
665
|
+
"role": "user",
|
|
666
|
+
"content": (
|
|
667
|
+
"Convert the Agent answer into a JSON object matching the required schema. Return JSON only.\n\n"
|
|
668
|
+
f"Task:\n{task}\n\n"
|
|
669
|
+
f"Answer:\n{answer}"
|
|
670
|
+
),
|
|
671
|
+
}
|
|
672
|
+
]
|
|
673
|
+
return StepPlan(
|
|
674
|
+
node_id=node_id,
|
|
675
|
+
effect=Effect(
|
|
676
|
+
type=EffectType.LLM_CALL,
|
|
677
|
+
payload={
|
|
678
|
+
"messages": messages,
|
|
679
|
+
"system_prompt": system_prompt,
|
|
680
|
+
"provider": provider,
|
|
681
|
+
"model": model,
|
|
682
|
+
"response_schema": schema,
|
|
683
|
+
"response_schema_name": f"Agent_{node_id}",
|
|
684
|
+
"params": {"temperature": 0.2},
|
|
685
|
+
},
|
|
686
|
+
result_key=f"_temp.agent.{node_id}.structured",
|
|
687
|
+
),
|
|
688
|
+
next_node=node_id,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
_set_nested(run.vars, f"_temp.effects.{node_id}", result_obj)
|
|
692
|
+
bucket["phase"] = "done"
|
|
693
|
+
flow._node_outputs[node_id] = {"result": result_obj, "scratchpad": scratchpad, "tool_calls": tc, "tool_results": tr}
|
|
694
|
+
run.vars["_last_output"] = {"result": result_obj}
|
|
695
|
+
if next_node:
|
|
696
|
+
return StepPlan(node_id=node_id, next_node=next_node)
|
|
697
|
+
return StepPlan(node_id=node_id, complete_output={"result": result_obj, "success": True})
|
|
698
|
+
|
|
699
|
+
if phase == "structured":
|
|
700
|
+
structured_resp = bucket.get("structured")
|
|
701
|
+
if structured_resp is None:
|
|
702
|
+
temp = _ensure_temp_dict(run)
|
|
703
|
+
agent_bucket = temp.get("agent", {}).get(node_id, {}) if isinstance(temp.get("agent"), dict) else {}
|
|
704
|
+
structured_resp = agent_bucket.get("structured") if isinstance(agent_bucket, dict) else None
|
|
705
|
+
|
|
706
|
+
data = structured_resp.get("data") if isinstance(structured_resp, dict) else None
|
|
707
|
+
if data is None and isinstance(structured_resp, dict):
|
|
708
|
+
content = structured_resp.get("content")
|
|
709
|
+
if isinstance(content, str) and content.strip():
|
|
710
|
+
try:
|
|
711
|
+
data = json.loads(content)
|
|
712
|
+
except Exception:
|
|
713
|
+
data = None
|
|
714
|
+
|
|
715
|
+
if not isinstance(data, dict):
|
|
716
|
+
data = {}
|
|
717
|
+
|
|
718
|
+
_set_nested(run.vars, f"_temp.effects.{node_id}", data)
|
|
719
|
+
bucket["phase"] = "done"
|
|
720
|
+
scratchpad = bucket.get("scratchpad")
|
|
721
|
+
if not isinstance(scratchpad, dict):
|
|
722
|
+
scratchpad = {"node_id": node_id, "steps": []}
|
|
723
|
+
tc, tr = _extract_tool_activity_from_steps(scratchpad.get("steps"))
|
|
724
|
+
flow._node_outputs[node_id] = {"result": data, "scratchpad": scratchpad, "tool_calls": tc, "tool_results": tr}
|
|
725
|
+
run.vars["_last_output"] = {"result": data}
|
|
726
|
+
if next_node:
|
|
727
|
+
return StepPlan(node_id=node_id, next_node=next_node)
|
|
728
|
+
return StepPlan(node_id=node_id, complete_output={"result": data, "success": True})
|
|
729
|
+
|
|
730
|
+
if next_node:
|
|
731
|
+
return StepPlan(node_id=node_id, next_node=next_node)
|
|
732
|
+
return StepPlan(node_id=node_id, complete_output={"result": run.vars.get("_last_output"), "success": True})
|
|
733
|
+
|
|
734
|
+
return handler
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _create_visual_function_handler(
|
|
738
|
+
node_id: str,
|
|
739
|
+
func: Callable,
|
|
740
|
+
next_node: Optional[str],
|
|
741
|
+
input_key: Optional[str],
|
|
742
|
+
output_key: Optional[str],
|
|
743
|
+
flow: Flow,
|
|
744
|
+
branch_map: Optional[Dict[str, str]] = None,
|
|
745
|
+
) -> Callable:
|
|
746
|
+
"""Create a handler for visual flow function nodes.
|
|
747
|
+
|
|
748
|
+
Visual flows use data edges for passing values between nodes. This handler:
|
|
749
|
+
1. Syncs effect results from run.vars to flow._node_outputs
|
|
750
|
+
2. Calls the wrapped function with proper input
|
|
751
|
+
3. Updates _last_output for downstream nodes
|
|
752
|
+
"""
|
|
753
|
+
from abstractruntime.core.models import StepPlan
|
|
754
|
+
|
|
755
|
+
def handler(run: Any, ctx: Any) -> "StepPlan":
|
|
756
|
+
"""Execute the function and transition to next node."""
|
|
757
|
+
# Sync effect results from run.vars to flow._node_outputs
|
|
758
|
+
# This allows data edges from effect nodes to resolve correctly
|
|
759
|
+
if hasattr(flow, '_node_outputs') and hasattr(flow, '_data_edge_map'):
|
|
760
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
761
|
+
|
|
762
|
+
# Get input from _last_output (visual flow pattern)
|
|
763
|
+
# or from input_key if specified
|
|
764
|
+
if input_key:
|
|
765
|
+
input_data = run.vars.get(input_key)
|
|
766
|
+
else:
|
|
767
|
+
input_data = run.vars.get("_last_output") if "_last_output" in run.vars else run.vars
|
|
768
|
+
|
|
769
|
+
# Execute function (which is the data-aware wrapped handler)
|
|
770
|
+
try:
|
|
771
|
+
result = func(input_data)
|
|
772
|
+
except Exception as e:
|
|
773
|
+
run.vars["_flow_error"] = str(e)
|
|
774
|
+
run.vars["_flow_error_node"] = node_id
|
|
775
|
+
return StepPlan(
|
|
776
|
+
node_id=node_id,
|
|
777
|
+
complete_output={"error": str(e), "success": False, "node": node_id},
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
# Store result in _last_output for downstream nodes
|
|
781
|
+
run.vars["_last_output"] = result
|
|
782
|
+
|
|
783
|
+
# Persist per-node outputs for data-edge rehydration across pause/resume.
|
|
784
|
+
#
|
|
785
|
+
# Visual data edges read from `flow._node_outputs`, which is an in-memory
|
|
786
|
+
# cache. When a run pauses (ASK_USER / TOOL passthrough) and is resumed
|
|
787
|
+
# in a different process, we must be able to reconstruct upstream node
|
|
788
|
+
# outputs from persisted `RunState.vars`.
|
|
789
|
+
temp = run.vars.get("_temp")
|
|
790
|
+
if not isinstance(temp, dict):
|
|
791
|
+
temp = {}
|
|
792
|
+
run.vars["_temp"] = temp
|
|
793
|
+
persisted_outputs = temp.get("node_outputs")
|
|
794
|
+
if not isinstance(persisted_outputs, dict):
|
|
795
|
+
persisted_outputs = {}
|
|
796
|
+
temp["node_outputs"] = persisted_outputs
|
|
797
|
+
persisted_outputs[node_id] = result
|
|
798
|
+
|
|
799
|
+
# Also store in output_key if specified
|
|
800
|
+
if output_key:
|
|
801
|
+
_set_nested(run.vars, output_key, result)
|
|
802
|
+
|
|
803
|
+
if branch_map is not None:
|
|
804
|
+
branch = result.get("branch") if isinstance(result, dict) else None
|
|
805
|
+
if not isinstance(branch, str) or not branch:
|
|
806
|
+
run.vars["_flow_error"] = "Branching node did not return a string 'branch' value"
|
|
807
|
+
run.vars["_flow_error_node"] = node_id
|
|
808
|
+
return StepPlan(
|
|
809
|
+
node_id=node_id,
|
|
810
|
+
complete_output={
|
|
811
|
+
"error": "Branching node did not return a string 'branch' value",
|
|
812
|
+
"success": False,
|
|
813
|
+
"node": node_id,
|
|
814
|
+
},
|
|
815
|
+
)
|
|
816
|
+
chosen = branch_map.get(branch)
|
|
817
|
+
if not isinstance(chosen, str) or not chosen:
|
|
818
|
+
# Blueprint-style behavior: if the chosen execution pin isn't connected,
|
|
819
|
+
# treat it as a clean completion instead of an error.
|
|
820
|
+
if branch in {"true", "false", "default"} or branch.startswith("case:"):
|
|
821
|
+
return StepPlan(
|
|
822
|
+
node_id=node_id,
|
|
823
|
+
complete_output={"result": result, "success": True},
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
run.vars["_flow_error"] = f"Unknown branch '{branch}'"
|
|
827
|
+
run.vars["_flow_error_node"] = node_id
|
|
828
|
+
return StepPlan(
|
|
829
|
+
node_id=node_id,
|
|
830
|
+
complete_output={
|
|
831
|
+
"error": f"Unknown branch '{branch}'",
|
|
832
|
+
"success": False,
|
|
833
|
+
"node": node_id,
|
|
834
|
+
},
|
|
835
|
+
)
|
|
836
|
+
return StepPlan(node_id=node_id, next_node=chosen)
|
|
837
|
+
|
|
838
|
+
# Continue to next node or complete
|
|
839
|
+
if next_node:
|
|
840
|
+
return StepPlan(node_id=node_id, next_node=next_node)
|
|
841
|
+
return StepPlan(
|
|
842
|
+
node_id=node_id,
|
|
843
|
+
complete_output={"result": result, "success": True},
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
return handler
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _sync_effect_results_to_node_outputs(run: Any, flow: Flow) -> None:
|
|
850
|
+
"""Sync effect results from run.vars to flow._node_outputs.
|
|
851
|
+
|
|
852
|
+
When an effect (like ask_user) completes, its result is stored in run.vars
|
|
853
|
+
at the result_key. But visual flow data edges read from flow._node_outputs.
|
|
854
|
+
This function syncs those results so data edges resolve correctly.
|
|
855
|
+
"""
|
|
856
|
+
# Attach a live reference to the current run vars so pure nodes (e.g. Get Variable)
|
|
857
|
+
# can read the up-to-date workflow state during data-edge resolution.
|
|
858
|
+
try:
|
|
859
|
+
if hasattr(run, "vars") and isinstance(run.vars, dict):
|
|
860
|
+
flow._run_vars = run.vars # type: ignore[attr-defined]
|
|
861
|
+
except Exception:
|
|
862
|
+
pass
|
|
863
|
+
|
|
864
|
+
# IMPORTANT: `flow._node_outputs` is an in-memory cache used by the visual executor
|
|
865
|
+
# to resolve data edges (including lazy "pure" nodes like compare/subtract/concat).
|
|
866
|
+
#
|
|
867
|
+
# A single compiled `Flow` instance can be executed by multiple `RunState`s in the
|
|
868
|
+
# same process when using subworkflows (START_SUBWORKFLOW) — especially with
|
|
869
|
+
# self-recursion or mutual recursion. In that situation, stale cached outputs from
|
|
870
|
+
# another run can break correctness (e.g. a cached `compare` result keeps a base-case
|
|
871
|
+
# from ever becoming false), leading to infinite recursion.
|
|
872
|
+
#
|
|
873
|
+
# We isolate caches per run_id by resetting the dict *in-place* when the active run
|
|
874
|
+
# changes, then rehydrating persisted outputs from `run.vars["_temp"]`.
|
|
875
|
+
node_outputs = flow._node_outputs
|
|
876
|
+
try:
|
|
877
|
+
rid = getattr(run, "run_id", None)
|
|
878
|
+
if isinstance(rid, str) and rid:
|
|
879
|
+
active = getattr(flow, "_active_run_id", None)
|
|
880
|
+
if active != rid:
|
|
881
|
+
base = getattr(flow, "_static_node_outputs", None)
|
|
882
|
+
# Backward-compat: if the baseline wasn't set (older flows), infer it
|
|
883
|
+
# on first use — at this point it should contain only literal nodes.
|
|
884
|
+
if not isinstance(base, dict):
|
|
885
|
+
base = dict(node_outputs) if isinstance(node_outputs, dict) else {}
|
|
886
|
+
try:
|
|
887
|
+
flow._static_node_outputs = dict(base) # type: ignore[attr-defined]
|
|
888
|
+
except Exception:
|
|
889
|
+
pass
|
|
890
|
+
if isinstance(node_outputs, dict):
|
|
891
|
+
node_outputs.clear()
|
|
892
|
+
if isinstance(base, dict):
|
|
893
|
+
node_outputs.update(base)
|
|
894
|
+
try:
|
|
895
|
+
flow._active_run_id = rid # type: ignore[attr-defined]
|
|
896
|
+
except Exception:
|
|
897
|
+
pass
|
|
898
|
+
except Exception:
|
|
899
|
+
# Best-effort; never let cache isolation break execution.
|
|
900
|
+
pass
|
|
901
|
+
|
|
902
|
+
temp_data = run.vars.get("_temp", {})
|
|
903
|
+
if not isinstance(temp_data, dict):
|
|
904
|
+
return
|
|
905
|
+
|
|
906
|
+
# Restore persisted outputs for executed (non-effect) nodes.
|
|
907
|
+
persisted = temp_data.get("node_outputs")
|
|
908
|
+
if isinstance(persisted, dict):
|
|
909
|
+
for nid, out in persisted.items():
|
|
910
|
+
if isinstance(nid, str) and nid:
|
|
911
|
+
node_outputs[nid] = out
|
|
912
|
+
|
|
913
|
+
effects = temp_data.get("effects")
|
|
914
|
+
if not isinstance(effects, dict):
|
|
915
|
+
effects = {}
|
|
916
|
+
|
|
917
|
+
def _get_span_id(raw: Any) -> Optional[str]:
|
|
918
|
+
if not isinstance(raw, dict):
|
|
919
|
+
return None
|
|
920
|
+
results = raw.get("results")
|
|
921
|
+
if not isinstance(results, list) or not results:
|
|
922
|
+
return None
|
|
923
|
+
first = results[0]
|
|
924
|
+
if not isinstance(first, dict):
|
|
925
|
+
return None
|
|
926
|
+
meta = first.get("meta")
|
|
927
|
+
if not isinstance(meta, dict):
|
|
928
|
+
return None
|
|
929
|
+
span_id = meta.get("span_id")
|
|
930
|
+
if isinstance(span_id, str) and span_id.strip():
|
|
931
|
+
return span_id.strip()
|
|
932
|
+
return None
|
|
933
|
+
|
|
934
|
+
def _as_dict_list(value: Any) -> List[Dict[str, Any]]:
|
|
935
|
+
"""Normalize a value into a list of dicts (best-effort, JSON-safe)."""
|
|
936
|
+
if value is None:
|
|
937
|
+
return []
|
|
938
|
+
if isinstance(value, dict):
|
|
939
|
+
return [dict(value)]
|
|
940
|
+
if isinstance(value, list):
|
|
941
|
+
out: List[Dict[str, Any]] = []
|
|
942
|
+
for x in value:
|
|
943
|
+
if isinstance(x, dict):
|
|
944
|
+
out.append(dict(x))
|
|
945
|
+
return out
|
|
946
|
+
return []
|
|
947
|
+
|
|
948
|
+
def _extract_agent_tool_activity(scratchpad: Any) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
949
|
+
"""Extract tool call requests and tool results from an agent scratchpad.
|
|
950
|
+
|
|
951
|
+
This is *post-run* ergonomics: it does not provide real-time streaming while the agent runs.
|
|
952
|
+
For real-time tool observability, hosts should subscribe to the ledger and/or node_traces.
|
|
953
|
+
"""
|
|
954
|
+
sp = scratchpad if isinstance(scratchpad, dict) else None
|
|
955
|
+
if sp is None:
|
|
956
|
+
return [], []
|
|
957
|
+
|
|
958
|
+
node_traces = sp.get("node_traces")
|
|
959
|
+
if not isinstance(node_traces, dict):
|
|
960
|
+
# Allow passing a single node trace directly.
|
|
961
|
+
if isinstance(sp.get("steps"), list) and sp.get("node_id") is not None:
|
|
962
|
+
node_traces = {str(sp.get("node_id")): sp}
|
|
963
|
+
else:
|
|
964
|
+
return [], []
|
|
965
|
+
|
|
966
|
+
# Flatten steps across nodes and sort by timestamp (ISO strings are lexicographically sortable).
|
|
967
|
+
steps: List[Tuple[str, Dict[str, Any]]] = []
|
|
968
|
+
for _nid, trace_any in node_traces.items():
|
|
969
|
+
trace = trace_any if isinstance(trace_any, dict) else None
|
|
970
|
+
if trace is None:
|
|
971
|
+
continue
|
|
972
|
+
entries = trace.get("steps")
|
|
973
|
+
if not isinstance(entries, list):
|
|
974
|
+
continue
|
|
975
|
+
for entry_any in entries:
|
|
976
|
+
entry = entry_any if isinstance(entry_any, dict) else None
|
|
977
|
+
if entry is None:
|
|
978
|
+
continue
|
|
979
|
+
ts = entry.get("ts")
|
|
980
|
+
ts_s = ts if isinstance(ts, str) else ""
|
|
981
|
+
steps.append((ts_s, entry))
|
|
982
|
+
steps.sort(key=lambda x: x[0])
|
|
983
|
+
|
|
984
|
+
tool_calls: List[Dict[str, Any]] = []
|
|
985
|
+
tool_results: List[Dict[str, Any]] = []
|
|
986
|
+
for _ts, entry in steps:
|
|
987
|
+
effect = entry.get("effect")
|
|
988
|
+
if not isinstance(effect, dict):
|
|
989
|
+
continue
|
|
990
|
+
if str(effect.get("type") or "") != "tool_calls":
|
|
991
|
+
continue
|
|
992
|
+
payload = effect.get("payload")
|
|
993
|
+
payload_d = payload if isinstance(payload, dict) else {}
|
|
994
|
+
tool_calls.extend(_as_dict_list(payload_d.get("tool_calls")))
|
|
995
|
+
|
|
996
|
+
result = entry.get("result")
|
|
997
|
+
if not isinstance(result, dict):
|
|
998
|
+
continue
|
|
999
|
+
results = result.get("results")
|
|
1000
|
+
tool_results.extend(_as_dict_list(results))
|
|
1001
|
+
|
|
1002
|
+
return tool_calls, tool_results
|
|
1003
|
+
|
|
1004
|
+
for node_id, flow_node in flow.nodes.items():
|
|
1005
|
+
effect_type = flow_node.effect_type
|
|
1006
|
+
if not effect_type:
|
|
1007
|
+
continue
|
|
1008
|
+
|
|
1009
|
+
raw = effects.get(node_id)
|
|
1010
|
+
if raw is None:
|
|
1011
|
+
# Backward-compat for older runs/tests that stored by effect type.
|
|
1012
|
+
legacy_key = f"{effect_type}_response"
|
|
1013
|
+
raw = temp_data.get(legacy_key)
|
|
1014
|
+
if raw is None:
|
|
1015
|
+
continue
|
|
1016
|
+
|
|
1017
|
+
current = node_outputs.get(node_id)
|
|
1018
|
+
if not isinstance(current, dict):
|
|
1019
|
+
current = {}
|
|
1020
|
+
node_outputs[node_id] = current
|
|
1021
|
+
else:
|
|
1022
|
+
# If this node previously produced a pre-effect placeholder from the
|
|
1023
|
+
# visual executor (e.g. `_pending_effect`), remove it now that we have
|
|
1024
|
+
# the durable effect outcome in `run.vars["_temp"]["effects"]`.
|
|
1025
|
+
current.pop("_pending_effect", None)
|
|
1026
|
+
|
|
1027
|
+
mapped_value: Any = None
|
|
1028
|
+
|
|
1029
|
+
if effect_type == "ask_user":
|
|
1030
|
+
if isinstance(raw, dict):
|
|
1031
|
+
# raw is usually {"response": "..."} (resume payload)
|
|
1032
|
+
current.update(raw)
|
|
1033
|
+
mapped_value = raw.get("response")
|
|
1034
|
+
elif effect_type == "answer_user":
|
|
1035
|
+
if isinstance(raw, dict):
|
|
1036
|
+
current.update(raw)
|
|
1037
|
+
mapped_value = raw.get("message")
|
|
1038
|
+
else:
|
|
1039
|
+
current["message"] = raw
|
|
1040
|
+
mapped_value = raw
|
|
1041
|
+
elif effect_type == "llm_call":
|
|
1042
|
+
if isinstance(raw, dict):
|
|
1043
|
+
current["response"] = raw.get("content")
|
|
1044
|
+
# Convenience pin: expose tool_calls directly, instead of forcing consumers
|
|
1045
|
+
# to drill into `result.tool_calls` via a Break Object node.
|
|
1046
|
+
current["tool_calls"] = _as_dict_list(raw.get("tool_calls"))
|
|
1047
|
+
# Expose the full normalized LLM result as an object output pin (`result`).
|
|
1048
|
+
# This enables deterministic state-machine workflows to branch on:
|
|
1049
|
+
# - tool_calls
|
|
1050
|
+
# - usage / model / finish_reason
|
|
1051
|
+
# - trace_id / metadata for observability
|
|
1052
|
+
current["result"] = raw
|
|
1053
|
+
current["gen_time"] = raw.get("gen_time")
|
|
1054
|
+
current["ttft_ms"] = raw.get("ttft_ms")
|
|
1055
|
+
current["raw"] = raw
|
|
1056
|
+
mapped_value = current["response"]
|
|
1057
|
+
elif effect_type == "tool_calls":
|
|
1058
|
+
# Effect outcome is produced by AbstractRuntime TOOL_CALLS handler:
|
|
1059
|
+
# - executed: {"mode":"executed","results":[{call_id,name,success,output,error}, ...]}
|
|
1060
|
+
# - passthrough/untrusted: {"mode": "...", "tool_calls": [...]}
|
|
1061
|
+
if isinstance(raw, dict):
|
|
1062
|
+
mode = raw.get("mode")
|
|
1063
|
+
results = raw.get("results")
|
|
1064
|
+
if not isinstance(results, list):
|
|
1065
|
+
results = []
|
|
1066
|
+
current["results"] = results
|
|
1067
|
+
# Only treat non-executed modes as failure (results are unavailable).
|
|
1068
|
+
if isinstance(mode, str) and mode.strip() and mode != "executed":
|
|
1069
|
+
current["success"] = False
|
|
1070
|
+
else:
|
|
1071
|
+
current["success"] = all(isinstance(r, dict) and r.get("success") is True for r in results)
|
|
1072
|
+
current["raw"] = raw
|
|
1073
|
+
mapped_value = current["results"]
|
|
1074
|
+
elif effect_type == "agent":
|
|
1075
|
+
current["result"] = raw
|
|
1076
|
+
scratchpad = None
|
|
1077
|
+
agent_ns = temp_data.get("agent")
|
|
1078
|
+
if isinstance(agent_ns, dict):
|
|
1079
|
+
bucket = agent_ns.get(node_id)
|
|
1080
|
+
if isinstance(bucket, dict):
|
|
1081
|
+
scratchpad = bucket.get("scratchpad")
|
|
1082
|
+
|
|
1083
|
+
if scratchpad is None:
|
|
1084
|
+
# Fallback: use this node's own trace if present.
|
|
1085
|
+
try:
|
|
1086
|
+
from abstractruntime.core.vars import get_node_trace as _get_node_trace
|
|
1087
|
+
except Exception: # pragma: no cover
|
|
1088
|
+
_get_node_trace = None # type: ignore[assignment]
|
|
1089
|
+
if callable(_get_node_trace):
|
|
1090
|
+
scratchpad = _get_node_trace(run.vars, node_id)
|
|
1091
|
+
|
|
1092
|
+
current["scratchpad"] = scratchpad if scratchpad is not None else {"node_id": node_id, "steps": []}
|
|
1093
|
+
# Convenience pins: expose tool activity extracted from the scratchpad trace.
|
|
1094
|
+
# This is intentionally best-effort and does not change agent execution behavior.
|
|
1095
|
+
tc, tr = _extract_agent_tool_activity(current.get("scratchpad"))
|
|
1096
|
+
current["tool_calls"] = tc
|
|
1097
|
+
current["tool_results"] = tr
|
|
1098
|
+
mapped_value = raw
|
|
1099
|
+
elif effect_type == "wait_event":
|
|
1100
|
+
current["event_data"] = raw
|
|
1101
|
+
mapped_value = raw
|
|
1102
|
+
elif effect_type == "on_event":
|
|
1103
|
+
# Custom event listener: the resume payload is a structured envelope.
|
|
1104
|
+
if isinstance(raw, dict):
|
|
1105
|
+
current["event"] = raw
|
|
1106
|
+
current["payload"] = raw.get("payload")
|
|
1107
|
+
current["event_id"] = raw.get("event_id")
|
|
1108
|
+
current["name"] = raw.get("name")
|
|
1109
|
+
mapped_value = raw
|
|
1110
|
+
else:
|
|
1111
|
+
current["event"] = raw
|
|
1112
|
+
mapped_value = raw
|
|
1113
|
+
elif effect_type == "on_schedule":
|
|
1114
|
+
cfg = flow_node.effect_config if isinstance(flow_node.effect_config, dict) else {}
|
|
1115
|
+
schedule_cfg = cfg.get("schedule")
|
|
1116
|
+
schedule_str = str(schedule_cfg or "").strip() if schedule_cfg is not None else ""
|
|
1117
|
+
recurrent_cfg = cfg.get("recurrent")
|
|
1118
|
+
recurrent_flag = True if recurrent_cfg is None else bool(recurrent_cfg)
|
|
1119
|
+
# ISO timestamps are treated as one-shot; recurrence is disabled.
|
|
1120
|
+
if schedule_str and not isinstance(schedule_cfg, (int, float)) and schedule_str:
|
|
1121
|
+
import re
|
|
1122
|
+
|
|
1123
|
+
if not re.match(r"^\\s*\\d+(?:\\.\\d+)?\\s*(ms|s|m|h|d)\\s*$", schedule_str, re.IGNORECASE):
|
|
1124
|
+
recurrent_flag = False
|
|
1125
|
+
|
|
1126
|
+
if isinstance(raw, dict):
|
|
1127
|
+
current.update(raw)
|
|
1128
|
+
ts = raw.get("timestamp")
|
|
1129
|
+
if ts is None:
|
|
1130
|
+
ts = raw.get("scheduled_for")
|
|
1131
|
+
current["timestamp"] = ts
|
|
1132
|
+
current["recurrent"] = recurrent_flag
|
|
1133
|
+
mapped_value = ts if ts is not None else raw
|
|
1134
|
+
else:
|
|
1135
|
+
current["timestamp"] = raw
|
|
1136
|
+
current["recurrent"] = recurrent_flag
|
|
1137
|
+
mapped_value = raw
|
|
1138
|
+
elif effect_type == "wait_until":
|
|
1139
|
+
if isinstance(raw, dict):
|
|
1140
|
+
current.update(raw)
|
|
1141
|
+
else:
|
|
1142
|
+
current["result"] = raw
|
|
1143
|
+
mapped_value = raw
|
|
1144
|
+
elif effect_type == "emit_event":
|
|
1145
|
+
# Custom event emission result (dispatch summary).
|
|
1146
|
+
if isinstance(raw, dict):
|
|
1147
|
+
current.update(raw)
|
|
1148
|
+
mapped_value = raw
|
|
1149
|
+
else:
|
|
1150
|
+
current["result"] = raw
|
|
1151
|
+
mapped_value = raw
|
|
1152
|
+
elif effect_type == "memory_note":
|
|
1153
|
+
span_id = _get_span_id(raw)
|
|
1154
|
+
current["note_id"] = span_id
|
|
1155
|
+
current["raw"] = raw
|
|
1156
|
+
mapped_value = span_id
|
|
1157
|
+
elif effect_type == "memory_query":
|
|
1158
|
+
# Runtime returns a tool-results envelope:
|
|
1159
|
+
# {"mode":"executed","results":[{call_id,name,success,output,error,meta?}, ...]}
|
|
1160
|
+
rendered = ""
|
|
1161
|
+
matches: list[Any] = []
|
|
1162
|
+
span_ids: list[Any] = []
|
|
1163
|
+
if isinstance(raw, dict):
|
|
1164
|
+
results_list = raw.get("results")
|
|
1165
|
+
if isinstance(results_list, list) and results_list:
|
|
1166
|
+
first = results_list[0]
|
|
1167
|
+
if isinstance(first, dict):
|
|
1168
|
+
out = first.get("output")
|
|
1169
|
+
if isinstance(out, str):
|
|
1170
|
+
rendered = out
|
|
1171
|
+
meta = first.get("meta")
|
|
1172
|
+
if isinstance(meta, dict):
|
|
1173
|
+
m = meta.get("matches")
|
|
1174
|
+
if isinstance(m, list):
|
|
1175
|
+
matches = m
|
|
1176
|
+
sids = meta.get("span_ids")
|
|
1177
|
+
if isinstance(sids, list):
|
|
1178
|
+
span_ids = sids
|
|
1179
|
+
|
|
1180
|
+
current["rendered"] = rendered
|
|
1181
|
+
current["results"] = matches
|
|
1182
|
+
current["span_ids"] = span_ids
|
|
1183
|
+
current["raw"] = raw
|
|
1184
|
+
mapped_value = current["results"]
|
|
1185
|
+
elif effect_type == "memory_rehydrate":
|
|
1186
|
+
if isinstance(raw, dict):
|
|
1187
|
+
current["inserted"] = raw.get("inserted")
|
|
1188
|
+
current["skipped"] = raw.get("skipped")
|
|
1189
|
+
current["artifacts"] = raw.get("artifacts")
|
|
1190
|
+
else:
|
|
1191
|
+
current["inserted"] = 0
|
|
1192
|
+
current["skipped"] = 0
|
|
1193
|
+
current["artifacts"] = []
|
|
1194
|
+
current["raw"] = raw
|
|
1195
|
+
mapped_value = raw
|
|
1196
|
+
elif effect_type == "start_subworkflow":
|
|
1197
|
+
if isinstance(raw, dict):
|
|
1198
|
+
current["sub_run_id"] = raw.get("sub_run_id")
|
|
1199
|
+
out = raw.get("output")
|
|
1200
|
+
if isinstance(out, dict) and "result" in out:
|
|
1201
|
+
result_value = out.get("result")
|
|
1202
|
+
current["output"] = result_value
|
|
1203
|
+
current["child_output"] = out
|
|
1204
|
+
else:
|
|
1205
|
+
result_value = out
|
|
1206
|
+
current["output"] = result_value
|
|
1207
|
+
if isinstance(out, dict):
|
|
1208
|
+
current["child_output"] = out
|
|
1209
|
+
mapped_value = current.get("output")
|
|
1210
|
+
|
|
1211
|
+
cfg = flow_node.effect_config or {}
|
|
1212
|
+
out_pins = cfg.get("output_pins")
|
|
1213
|
+
if isinstance(out_pins, list) and out_pins:
|
|
1214
|
+
if isinstance(result_value, dict):
|
|
1215
|
+
for pid in out_pins:
|
|
1216
|
+
if isinstance(pid, str) and pid:
|
|
1217
|
+
if pid == "output":
|
|
1218
|
+
continue
|
|
1219
|
+
current[pid] = result_value.get(pid)
|
|
1220
|
+
elif len(out_pins) == 1 and isinstance(out_pins[0], str) and out_pins[0]:
|
|
1221
|
+
current[out_pins[0]] = result_value
|
|
1222
|
+
else:
|
|
1223
|
+
current["output"] = raw
|
|
1224
|
+
mapped_value = raw
|
|
1225
|
+
|
|
1226
|
+
# Optional: also write the mapped output to run.vars if configured.
|
|
1227
|
+
if flow_node.output_key and mapped_value is not None:
|
|
1228
|
+
_set_nested(run.vars, flow_node.output_key, mapped_value)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def _set_nested(target: Dict[str, Any], dotted_key: str, value: Any) -> None:
|
|
1232
|
+
"""Set nested dict value using dot notation."""
|
|
1233
|
+
parts = dotted_key.split(".")
|
|
1234
|
+
cur = target
|
|
1235
|
+
for p in parts[:-1]:
|
|
1236
|
+
nxt = cur.get(p)
|
|
1237
|
+
if not isinstance(nxt, dict):
|
|
1238
|
+
nxt = {}
|
|
1239
|
+
cur[p] = nxt
|
|
1240
|
+
cur = nxt
|
|
1241
|
+
cur[parts[-1]] = value
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def compile_flow(flow: Flow) -> "WorkflowSpec":
|
|
1245
|
+
"""Compile a Flow definition into an AbstractRuntime WorkflowSpec.
|
|
1246
|
+
|
|
1247
|
+
This function transforms a declarative Flow definition into an executable
|
|
1248
|
+
WorkflowSpec that can be run by AbstractRuntime. Each flow node is converted
|
|
1249
|
+
to a workflow node handler based on its type:
|
|
1250
|
+
|
|
1251
|
+
- Functions: Executed directly within the workflow
|
|
1252
|
+
- Agents: Run as subworkflows using START_SUBWORKFLOW effect
|
|
1253
|
+
- Nested Flows: Compiled recursively and run as subworkflows
|
|
1254
|
+
|
|
1255
|
+
Args:
|
|
1256
|
+
flow: The Flow definition to compile
|
|
1257
|
+
|
|
1258
|
+
Returns:
|
|
1259
|
+
A WorkflowSpec that can be executed by AbstractRuntime
|
|
1260
|
+
|
|
1261
|
+
Raises:
|
|
1262
|
+
ValueError: If the flow is invalid (no entry node, missing nodes, etc.)
|
|
1263
|
+
TypeError: If a node handler is of unknown type
|
|
1264
|
+
|
|
1265
|
+
Example:
|
|
1266
|
+
>>> flow = Flow("my_flow")
|
|
1267
|
+
>>> flow.add_node("start", my_func)
|
|
1268
|
+
>>> flow.set_entry("start")
|
|
1269
|
+
>>> spec = compile_flow(flow)
|
|
1270
|
+
>>> runtime.start(workflow=spec)
|
|
1271
|
+
"""
|
|
1272
|
+
from abstractruntime.core.spec import WorkflowSpec
|
|
1273
|
+
|
|
1274
|
+
# Validate flow
|
|
1275
|
+
errors = flow.validate()
|
|
1276
|
+
if errors:
|
|
1277
|
+
raise ValueError(f"Invalid flow: {'; '.join(errors)}")
|
|
1278
|
+
|
|
1279
|
+
outgoing: Dict[str, list] = {}
|
|
1280
|
+
for edge in flow.edges:
|
|
1281
|
+
outgoing.setdefault(edge.source, []).append(edge)
|
|
1282
|
+
|
|
1283
|
+
# Build next-node map (linear) and branch maps (If/Else).
|
|
1284
|
+
next_node_map: Dict[str, Optional[str]] = {}
|
|
1285
|
+
branch_maps: Dict[str, Dict[str, str]] = {}
|
|
1286
|
+
control_specs: Dict[str, Dict[str, Any]] = {}
|
|
1287
|
+
|
|
1288
|
+
def _is_supported_branch_handle(handle: str) -> bool:
|
|
1289
|
+
return handle in {"true", "false", "default"} or handle.startswith("case:")
|
|
1290
|
+
|
|
1291
|
+
for node_id in flow.nodes:
|
|
1292
|
+
outs = outgoing.get(node_id, [])
|
|
1293
|
+
if not outs:
|
|
1294
|
+
next_node_map[node_id] = None
|
|
1295
|
+
continue
|
|
1296
|
+
|
|
1297
|
+
flow_node = flow.nodes.get(node_id)
|
|
1298
|
+
node_effect_type = getattr(flow_node, "effect_type", None) if flow_node else None
|
|
1299
|
+
|
|
1300
|
+
# Sequence / Parallel: deterministic fan-out scheduling (Blueprint-style).
|
|
1301
|
+
#
|
|
1302
|
+
# Important: these nodes may have 0..N connected outputs; even a single
|
|
1303
|
+
# `then:0` edge should compile (it is not "branching" based on data).
|
|
1304
|
+
if node_effect_type in {"sequence", "parallel"}:
|
|
1305
|
+
# Validate all execution edges have a handle
|
|
1306
|
+
handles: list[str] = []
|
|
1307
|
+
targets_by_handle: Dict[str, str] = {}
|
|
1308
|
+
for e in outs:
|
|
1309
|
+
h = getattr(e, "source_handle", None)
|
|
1310
|
+
if not isinstance(h, str) or not h:
|
|
1311
|
+
raise ValueError(
|
|
1312
|
+
f"Control node '{node_id}' has an execution edge with no source_handle."
|
|
1313
|
+
)
|
|
1314
|
+
handles.append(h)
|
|
1315
|
+
targets_by_handle[h] = e.target
|
|
1316
|
+
|
|
1317
|
+
cfg = getattr(flow_node, "effect_config", None) if flow_node else None
|
|
1318
|
+
cfg_dict = cfg if isinstance(cfg, dict) else {}
|
|
1319
|
+
then_handles = cfg_dict.get("then_handles")
|
|
1320
|
+
if not isinstance(then_handles, list):
|
|
1321
|
+
then_handles = [h for h in handles if h.startswith("then:")]
|
|
1322
|
+
|
|
1323
|
+
def _then_key(h: str) -> int:
|
|
1324
|
+
try:
|
|
1325
|
+
if h.startswith("then:"):
|
|
1326
|
+
return int(h.split(":", 1)[1])
|
|
1327
|
+
except Exception:
|
|
1328
|
+
pass
|
|
1329
|
+
return 10**9
|
|
1330
|
+
|
|
1331
|
+
then_handles = sorted(then_handles, key=_then_key)
|
|
1332
|
+
else:
|
|
1333
|
+
then_handles = [str(h) for h in then_handles if isinstance(h, str) and h]
|
|
1334
|
+
|
|
1335
|
+
allowed = set(then_handles)
|
|
1336
|
+
completed_target: Optional[str] = None
|
|
1337
|
+
if node_effect_type == "parallel":
|
|
1338
|
+
allowed.add("completed")
|
|
1339
|
+
completed_target = targets_by_handle.get("completed")
|
|
1340
|
+
|
|
1341
|
+
unknown = [h for h in handles if h not in allowed]
|
|
1342
|
+
if unknown:
|
|
1343
|
+
raise ValueError(
|
|
1344
|
+
f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
control_specs[node_id] = {
|
|
1348
|
+
"kind": node_effect_type,
|
|
1349
|
+
"then_handles": then_handles,
|
|
1350
|
+
"targets_by_handle": targets_by_handle,
|
|
1351
|
+
"completed_target": completed_target,
|
|
1352
|
+
}
|
|
1353
|
+
next_node_map[node_id] = None
|
|
1354
|
+
continue
|
|
1355
|
+
|
|
1356
|
+
# Loop (Foreach): structured scheduling via `loop` (body) and `done` (completed).
|
|
1357
|
+
#
|
|
1358
|
+
# This is a scheduler node (like Sequence/Parallel), not data-driven branching.
|
|
1359
|
+
if node_effect_type == "loop":
|
|
1360
|
+
handles: list[str] = []
|
|
1361
|
+
targets_by_handle: Dict[str, str] = {}
|
|
1362
|
+
for e in outs:
|
|
1363
|
+
h = getattr(e, "source_handle", None)
|
|
1364
|
+
if not isinstance(h, str) or not h:
|
|
1365
|
+
raise ValueError(
|
|
1366
|
+
f"Control node '{node_id}' has an execution edge with no source_handle."
|
|
1367
|
+
)
|
|
1368
|
+
handles.append(h)
|
|
1369
|
+
targets_by_handle[h] = e.target
|
|
1370
|
+
|
|
1371
|
+
allowed = {"loop", "done"}
|
|
1372
|
+
unknown = [h for h in handles if h not in allowed]
|
|
1373
|
+
if unknown:
|
|
1374
|
+
raise ValueError(
|
|
1375
|
+
f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
|
|
1376
|
+
)
|
|
1377
|
+
|
|
1378
|
+
control_specs[node_id] = {
|
|
1379
|
+
"kind": "loop",
|
|
1380
|
+
"loop_target": targets_by_handle.get("loop"),
|
|
1381
|
+
"done_target": targets_by_handle.get("done"),
|
|
1382
|
+
}
|
|
1383
|
+
next_node_map[node_id] = None
|
|
1384
|
+
continue
|
|
1385
|
+
|
|
1386
|
+
# While: structured scheduling via `loop` (body) and `done` (completed),
|
|
1387
|
+
# gated by a boolean condition pin resolved via data edges.
|
|
1388
|
+
if node_effect_type == "while":
|
|
1389
|
+
handles = []
|
|
1390
|
+
targets_by_handle = {}
|
|
1391
|
+
for e in outs:
|
|
1392
|
+
h = getattr(e, "source_handle", None)
|
|
1393
|
+
if not isinstance(h, str) or not h:
|
|
1394
|
+
raise ValueError(
|
|
1395
|
+
f"Control node '{node_id}' has an execution edge with no source_handle."
|
|
1396
|
+
)
|
|
1397
|
+
handles.append(h)
|
|
1398
|
+
targets_by_handle[h] = e.target
|
|
1399
|
+
|
|
1400
|
+
allowed = {"loop", "done"}
|
|
1401
|
+
unknown = [h for h in handles if h not in allowed]
|
|
1402
|
+
if unknown:
|
|
1403
|
+
raise ValueError(
|
|
1404
|
+
f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
|
|
1405
|
+
)
|
|
1406
|
+
|
|
1407
|
+
control_specs[node_id] = {
|
|
1408
|
+
"kind": "while",
|
|
1409
|
+
"loop_target": targets_by_handle.get("loop"),
|
|
1410
|
+
"done_target": targets_by_handle.get("done"),
|
|
1411
|
+
}
|
|
1412
|
+
next_node_map[node_id] = None
|
|
1413
|
+
continue
|
|
1414
|
+
|
|
1415
|
+
# For: structured scheduling via `loop` (body) and `done` (completed),
|
|
1416
|
+
# over a numeric range resolved via data edges (start/end/step).
|
|
1417
|
+
if node_effect_type == "for":
|
|
1418
|
+
handles = []
|
|
1419
|
+
targets_by_handle = {}
|
|
1420
|
+
for e in outs:
|
|
1421
|
+
h = getattr(e, "source_handle", None)
|
|
1422
|
+
if not isinstance(h, str) or not h:
|
|
1423
|
+
raise ValueError(
|
|
1424
|
+
f"Control node '{node_id}' has an execution edge with no source_handle."
|
|
1425
|
+
)
|
|
1426
|
+
handles.append(h)
|
|
1427
|
+
targets_by_handle[h] = e.target
|
|
1428
|
+
|
|
1429
|
+
allowed = {"loop", "done"}
|
|
1430
|
+
unknown = [h for h in handles if h not in allowed]
|
|
1431
|
+
if unknown:
|
|
1432
|
+
raise ValueError(
|
|
1433
|
+
f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
control_specs[node_id] = {
|
|
1437
|
+
"kind": "for",
|
|
1438
|
+
"loop_target": targets_by_handle.get("loop"),
|
|
1439
|
+
"done_target": targets_by_handle.get("done"),
|
|
1440
|
+
}
|
|
1441
|
+
next_node_map[node_id] = None
|
|
1442
|
+
continue
|
|
1443
|
+
|
|
1444
|
+
if len(outs) == 1:
|
|
1445
|
+
h = getattr(outs[0], "source_handle", None)
|
|
1446
|
+
if isinstance(h, str) and h and h != "exec-out":
|
|
1447
|
+
if not _is_supported_branch_handle(h):
|
|
1448
|
+
raise ValueError(
|
|
1449
|
+
f"Node '{node_id}' has unsupported branching output '{h}'. "
|
|
1450
|
+
"Branching is not yet supported."
|
|
1451
|
+
)
|
|
1452
|
+
branch_maps[node_id] = {h: outs[0].target} # type: ignore[arg-type]
|
|
1453
|
+
next_node_map[node_id] = None
|
|
1454
|
+
else:
|
|
1455
|
+
next_node_map[node_id] = outs[0].target
|
|
1456
|
+
continue
|
|
1457
|
+
|
|
1458
|
+
handles: list[str] = []
|
|
1459
|
+
for e in outs:
|
|
1460
|
+
h = getattr(e, "source_handle", None)
|
|
1461
|
+
if not isinstance(h, str) or not h:
|
|
1462
|
+
handles = []
|
|
1463
|
+
break
|
|
1464
|
+
handles.append(h)
|
|
1465
|
+
|
|
1466
|
+
if len(handles) != len(outs) or len(set(handles)) != len(handles):
|
|
1467
|
+
raise ValueError(
|
|
1468
|
+
f"Node '{node_id}' has multiple outgoing edges. "
|
|
1469
|
+
"Branching is not yet supported."
|
|
1470
|
+
)
|
|
1471
|
+
|
|
1472
|
+
# Minimal branching support: If/Else uses `true` / `false` execution outputs.
|
|
1473
|
+
if set(handles) <= {"true", "false"}:
|
|
1474
|
+
branch_maps[node_id] = {e.source_handle: e.target for e in outs} # type: ignore[arg-type]
|
|
1475
|
+
next_node_map[node_id] = None
|
|
1476
|
+
continue
|
|
1477
|
+
|
|
1478
|
+
# Switch branching: stable case handles + optional default.
|
|
1479
|
+
if all(h == "default" or h.startswith("case:") for h in handles):
|
|
1480
|
+
branch_maps[node_id] = {e.source_handle: e.target for e in outs} # type: ignore[arg-type]
|
|
1481
|
+
next_node_map[node_id] = None
|
|
1482
|
+
continue
|
|
1483
|
+
|
|
1484
|
+
raise ValueError(
|
|
1485
|
+
f"Node '{node_id}' has multiple outgoing edges. "
|
|
1486
|
+
"Branching is not yet supported."
|
|
1487
|
+
)
|
|
1488
|
+
|
|
1489
|
+
# Determine exit node if not set
|
|
1490
|
+
exit_node = flow.exit_node
|
|
1491
|
+
if not exit_node:
|
|
1492
|
+
terminal_nodes = flow.get_terminal_nodes()
|
|
1493
|
+
if len(terminal_nodes) == 1:
|
|
1494
|
+
exit_node = terminal_nodes[0]
|
|
1495
|
+
elif len(terminal_nodes) > 1:
|
|
1496
|
+
# Multiple terminals - each will complete the flow when reached
|
|
1497
|
+
pass
|
|
1498
|
+
|
|
1499
|
+
# Create node handlers
|
|
1500
|
+
handlers: Dict[str, Callable] = {}
|
|
1501
|
+
|
|
1502
|
+
def _wrap_return_to_active_control(
|
|
1503
|
+
handler: Callable,
|
|
1504
|
+
*,
|
|
1505
|
+
node_id: str,
|
|
1506
|
+
visual_type: Optional[str],
|
|
1507
|
+
) -> Callable:
|
|
1508
|
+
"""If a node tries to complete the run inside an active control block, return to the scheduler.
|
|
1509
|
+
|
|
1510
|
+
This is crucial for Blueprint-style nodes:
|
|
1511
|
+
- branch chains can end (no outgoing exec edges) without ending the whole flow
|
|
1512
|
+
- Sequence/Parallel can then continue scheduling other branches
|
|
1513
|
+
"""
|
|
1514
|
+
from abstractruntime.core.models import StepPlan
|
|
1515
|
+
|
|
1516
|
+
try:
|
|
1517
|
+
from .adapters.control_adapter import get_active_control_node_id
|
|
1518
|
+
except Exception: # pragma: no cover
|
|
1519
|
+
get_active_control_node_id = None # type: ignore[assignment]
|
|
1520
|
+
|
|
1521
|
+
def wrapped(run: Any, ctx: Any) -> "StepPlan":
|
|
1522
|
+
plan: StepPlan = handler(run, ctx)
|
|
1523
|
+
if not callable(get_active_control_node_id):
|
|
1524
|
+
return plan
|
|
1525
|
+
|
|
1526
|
+
active = get_active_control_node_id(run.vars)
|
|
1527
|
+
if not isinstance(active, str) or not active:
|
|
1528
|
+
return plan
|
|
1529
|
+
if active == node_id:
|
|
1530
|
+
return plan
|
|
1531
|
+
|
|
1532
|
+
# Explicit end node should always terminate the run, even inside a control block.
|
|
1533
|
+
if visual_type == "on_flow_end":
|
|
1534
|
+
return plan
|
|
1535
|
+
|
|
1536
|
+
# If the node is about to complete the run, treat it as "branch complete" instead.
|
|
1537
|
+
if plan.complete_output is not None:
|
|
1538
|
+
return StepPlan(node_id=plan.node_id, next_node=active)
|
|
1539
|
+
|
|
1540
|
+
# Terminal effect node: runtime would auto-complete if next_node is missing.
|
|
1541
|
+
if plan.effect is not None and not plan.next_node:
|
|
1542
|
+
return StepPlan(node_id=plan.node_id, effect=plan.effect, next_node=active)
|
|
1543
|
+
|
|
1544
|
+
# Defensive fallback.
|
|
1545
|
+
if plan.effect is None and not plan.next_node and plan.complete_output is None:
|
|
1546
|
+
return StepPlan(node_id=plan.node_id, next_node=active)
|
|
1547
|
+
|
|
1548
|
+
return plan
|
|
1549
|
+
|
|
1550
|
+
return wrapped
|
|
1551
|
+
|
|
1552
|
+
for node_id, flow_node in flow.nodes.items():
|
|
1553
|
+
next_node = next_node_map.get(node_id)
|
|
1554
|
+
branch_map = branch_maps.get(node_id)
|
|
1555
|
+
handler_obj = getattr(flow_node, "handler", None)
|
|
1556
|
+
effect_type = getattr(flow_node, "effect_type", None)
|
|
1557
|
+
effect_config = getattr(flow_node, "effect_config", None) or {}
|
|
1558
|
+
visual_type = effect_config.get("_visual_type") if isinstance(effect_config, dict) else None
|
|
1559
|
+
|
|
1560
|
+
# Check for effect/control nodes first
|
|
1561
|
+
if effect_type == "sequence":
|
|
1562
|
+
from .adapters.control_adapter import create_sequence_node_handler
|
|
1563
|
+
|
|
1564
|
+
spec = control_specs.get(node_id) or {}
|
|
1565
|
+
handlers[node_id] = create_sequence_node_handler(
|
|
1566
|
+
node_id=node_id,
|
|
1567
|
+
ordered_then_handles=list(spec.get("then_handles") or []),
|
|
1568
|
+
targets_by_handle=dict(spec.get("targets_by_handle") or {}),
|
|
1569
|
+
)
|
|
1570
|
+
elif effect_type == "parallel":
|
|
1571
|
+
from .adapters.control_adapter import create_parallel_node_handler
|
|
1572
|
+
|
|
1573
|
+
spec = control_specs.get(node_id) or {}
|
|
1574
|
+
handlers[node_id] = create_parallel_node_handler(
|
|
1575
|
+
node_id=node_id,
|
|
1576
|
+
ordered_then_handles=list(spec.get("then_handles") or []),
|
|
1577
|
+
targets_by_handle=dict(spec.get("targets_by_handle") or {}),
|
|
1578
|
+
completed_target=spec.get("completed_target"),
|
|
1579
|
+
)
|
|
1580
|
+
elif effect_type == "loop":
|
|
1581
|
+
from .adapters.control_adapter import create_loop_node_handler
|
|
1582
|
+
|
|
1583
|
+
spec = control_specs.get(node_id) or {}
|
|
1584
|
+
loop_data_handler = handler_obj if callable(handler_obj) else None
|
|
1585
|
+
|
|
1586
|
+
# Precompute upstream pure-node ids for cache invalidation (best-effort).
|
|
1587
|
+
#
|
|
1588
|
+
# Pure nodes (e.g. concat/split/break_object) are cached in `flow._node_outputs`.
|
|
1589
|
+
# Inside a Loop, the inputs to those pure nodes often change per-iteration
|
|
1590
|
+
# (index/item, evolving scratchpad vars, etc.). If we don't invalidate, the
|
|
1591
|
+
# loop body may reuse stale values from iteration 0.
|
|
1592
|
+
pure_ids = getattr(flow, "_pure_node_ids", None) if flow is not None else None
|
|
1593
|
+
pure_ids = set(pure_ids) if isinstance(pure_ids, (set, list, tuple)) else set()
|
|
1594
|
+
|
|
1595
|
+
def _resolve_items(
|
|
1596
|
+
run: Any,
|
|
1597
|
+
_handler: Any = loop_data_handler,
|
|
1598
|
+
_node_id: str = node_id,
|
|
1599
|
+
) -> list[Any]:
|
|
1600
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
1601
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
1602
|
+
if not callable(_handler):
|
|
1603
|
+
return []
|
|
1604
|
+
last_output = run.vars.get("_last_output", {})
|
|
1605
|
+
try:
|
|
1606
|
+
resolved = _handler(last_output)
|
|
1607
|
+
except Exception as e:
|
|
1608
|
+
# Surface this as a workflow error (don't silently treat as empty).
|
|
1609
|
+
try:
|
|
1610
|
+
run.vars["_flow_error"] = f"Loop items resolution failed: {e}"
|
|
1611
|
+
run.vars["_flow_error_node"] = _node_id
|
|
1612
|
+
except Exception:
|
|
1613
|
+
pass
|
|
1614
|
+
raise
|
|
1615
|
+
if not isinstance(resolved, dict):
|
|
1616
|
+
return []
|
|
1617
|
+
raw = resolved.get("items")
|
|
1618
|
+
if isinstance(raw, list):
|
|
1619
|
+
return raw
|
|
1620
|
+
if isinstance(raw, tuple):
|
|
1621
|
+
return list(raw)
|
|
1622
|
+
if raw is None:
|
|
1623
|
+
return []
|
|
1624
|
+
return [raw]
|
|
1625
|
+
|
|
1626
|
+
base_loop = create_loop_node_handler(
|
|
1627
|
+
node_id=node_id,
|
|
1628
|
+
loop_target=spec.get("loop_target"),
|
|
1629
|
+
done_target=spec.get("done_target"),
|
|
1630
|
+
resolve_items=_resolve_items,
|
|
1631
|
+
)
|
|
1632
|
+
|
|
1633
|
+
def _wrapped_loop(
|
|
1634
|
+
run: Any,
|
|
1635
|
+
ctx: Any,
|
|
1636
|
+
*,
|
|
1637
|
+
_base: Any = base_loop,
|
|
1638
|
+
_pure_ids: set[str] = pure_ids,
|
|
1639
|
+
) -> StepPlan:
|
|
1640
|
+
# Ensure pure nodes feeding the loop body are re-evaluated per iteration.
|
|
1641
|
+
if flow is not None and _pure_ids and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
1642
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
1643
|
+
node_outputs = getattr(flow, "_node_outputs", None)
|
|
1644
|
+
if isinstance(node_outputs, dict):
|
|
1645
|
+
for nid in _pure_ids:
|
|
1646
|
+
node_outputs.pop(nid, None)
|
|
1647
|
+
plan = _base(run, ctx)
|
|
1648
|
+
# The loop scheduler persists `{item,index,total}` into run.vars, but
|
|
1649
|
+
# UI node_complete events read from `flow._node_outputs`. Sync after
|
|
1650
|
+
# scheduling so observability reflects the current iteration.
|
|
1651
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
1652
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
1653
|
+
return plan
|
|
1654
|
+
|
|
1655
|
+
handlers[node_id] = _wrapped_loop
|
|
1656
|
+
elif effect_type == "agent":
|
|
1657
|
+
data_aware_handler = handler_obj if callable(handler_obj) else None
|
|
1658
|
+
handlers[node_id] = _create_visual_agent_effect_handler(
|
|
1659
|
+
node_id=node_id,
|
|
1660
|
+
next_node=next_node,
|
|
1661
|
+
agent_config=effect_config if isinstance(effect_config, dict) else {},
|
|
1662
|
+
data_aware_handler=data_aware_handler,
|
|
1663
|
+
flow=flow,
|
|
1664
|
+
)
|
|
1665
|
+
elif effect_type == "while":
|
|
1666
|
+
from .adapters.control_adapter import create_while_node_handler
|
|
1667
|
+
|
|
1668
|
+
spec = control_specs.get(node_id) or {}
|
|
1669
|
+
while_data_handler = handler_obj if callable(handler_obj) else None
|
|
1670
|
+
|
|
1671
|
+
# Precompute upstream pure-node ids for cache invalidation (best-effort).
|
|
1672
|
+
pure_ids = getattr(flow, "_pure_node_ids", None) if flow is not None else None
|
|
1673
|
+
pure_ids = set(pure_ids) if isinstance(pure_ids, (set, list, tuple)) else set()
|
|
1674
|
+
|
|
1675
|
+
data_edge_map = getattr(flow, "_data_edge_map", None) if flow is not None else None
|
|
1676
|
+
data_edge_map = data_edge_map if isinstance(data_edge_map, dict) else {}
|
|
1677
|
+
|
|
1678
|
+
upstream_pure: set[str] = set()
|
|
1679
|
+
if pure_ids:
|
|
1680
|
+
stack2 = [node_id]
|
|
1681
|
+
seen2: set[str] = set()
|
|
1682
|
+
while stack2:
|
|
1683
|
+
cur = stack2.pop()
|
|
1684
|
+
if cur in seen2:
|
|
1685
|
+
continue
|
|
1686
|
+
seen2.add(cur)
|
|
1687
|
+
deps = data_edge_map.get(cur)
|
|
1688
|
+
if not isinstance(deps, dict):
|
|
1689
|
+
continue
|
|
1690
|
+
for _pin, src in deps.items():
|
|
1691
|
+
if not isinstance(src, tuple) or len(src) != 2:
|
|
1692
|
+
continue
|
|
1693
|
+
src_node = src[0]
|
|
1694
|
+
if not isinstance(src_node, str) or not src_node:
|
|
1695
|
+
continue
|
|
1696
|
+
stack2.append(src_node)
|
|
1697
|
+
if src_node in pure_ids:
|
|
1698
|
+
upstream_pure.add(src_node)
|
|
1699
|
+
|
|
1700
|
+
def _resolve_condition(
|
|
1701
|
+
run: Any,
|
|
1702
|
+
_handler: Any = while_data_handler,
|
|
1703
|
+
_node_id: str = node_id,
|
|
1704
|
+
_upstream_pure: set[str] = upstream_pure,
|
|
1705
|
+
) -> bool:
|
|
1706
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
1707
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
1708
|
+
# Ensure pure nodes feeding the condition are re-evaluated per iteration.
|
|
1709
|
+
if _upstream_pure and hasattr(flow, "_node_outputs"):
|
|
1710
|
+
node_outputs = getattr(flow, "_node_outputs", None)
|
|
1711
|
+
if isinstance(node_outputs, dict):
|
|
1712
|
+
for nid in _upstream_pure:
|
|
1713
|
+
node_outputs.pop(nid, None)
|
|
1714
|
+
|
|
1715
|
+
if not callable(_handler):
|
|
1716
|
+
return False
|
|
1717
|
+
|
|
1718
|
+
last_output = run.vars.get("_last_output", {})
|
|
1719
|
+
try:
|
|
1720
|
+
resolved = _handler(last_output)
|
|
1721
|
+
except Exception as e:
|
|
1722
|
+
try:
|
|
1723
|
+
run.vars["_flow_error"] = f"While condition resolution failed: {e}"
|
|
1724
|
+
run.vars["_flow_error_node"] = _node_id
|
|
1725
|
+
except Exception:
|
|
1726
|
+
pass
|
|
1727
|
+
raise
|
|
1728
|
+
|
|
1729
|
+
if isinstance(resolved, dict) and "condition" in resolved:
|
|
1730
|
+
return bool(resolved.get("condition"))
|
|
1731
|
+
return bool(resolved)
|
|
1732
|
+
|
|
1733
|
+
base_while = create_while_node_handler(
|
|
1734
|
+
node_id=node_id,
|
|
1735
|
+
loop_target=spec.get("loop_target"),
|
|
1736
|
+
done_target=spec.get("done_target"),
|
|
1737
|
+
resolve_condition=_resolve_condition,
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
def _wrapped_while(
|
|
1741
|
+
run: Any,
|
|
1742
|
+
ctx: Any,
|
|
1743
|
+
*,
|
|
1744
|
+
_base: Any = base_while,
|
|
1745
|
+
) -> StepPlan:
|
|
1746
|
+
plan = _base(run, ctx)
|
|
1747
|
+
# While scheduler persists `index` into run.vars; sync so WS/UI
|
|
1748
|
+
# node_complete events show the latest iteration count.
|
|
1749
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
1750
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
1751
|
+
return plan
|
|
1752
|
+
|
|
1753
|
+
handlers[node_id] = _wrapped_while
|
|
1754
|
+
elif effect_type == "for":
|
|
1755
|
+
from .adapters.control_adapter import create_for_node_handler
|
|
1756
|
+
|
|
1757
|
+
spec = control_specs.get(node_id) or {}
|
|
1758
|
+
for_data_handler = handler_obj if callable(handler_obj) else None
|
|
1759
|
+
|
|
1760
|
+
# Precompute upstream pure-node ids for cache invalidation (best-effort).
|
|
1761
|
+
pure_ids = getattr(flow, "_pure_node_ids", None) if flow is not None else None
|
|
1762
|
+
pure_ids = set(pure_ids) if isinstance(pure_ids, (set, list, tuple)) else set()
|
|
1763
|
+
|
|
1764
|
+
def _resolve_range(
|
|
1765
|
+
run: Any,
|
|
1766
|
+
_handler: Any = for_data_handler,
|
|
1767
|
+
_node_id: str = node_id,
|
|
1768
|
+
) -> Dict[str, Any]:
|
|
1769
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
1770
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
1771
|
+
if not callable(_handler):
|
|
1772
|
+
return {}
|
|
1773
|
+
last_output = run.vars.get("_last_output", {})
|
|
1774
|
+
try:
|
|
1775
|
+
resolved = _handler(last_output)
|
|
1776
|
+
except Exception as e:
|
|
1777
|
+
try:
|
|
1778
|
+
run.vars["_flow_error"] = f"For range resolution failed: {e}"
|
|
1779
|
+
run.vars["_flow_error_node"] = _node_id
|
|
1780
|
+
except Exception:
|
|
1781
|
+
pass
|
|
1782
|
+
raise
|
|
1783
|
+
return resolved if isinstance(resolved, dict) else {}
|
|
1784
|
+
|
|
1785
|
+
base_for = create_for_node_handler(
|
|
1786
|
+
node_id=node_id,
|
|
1787
|
+
loop_target=spec.get("loop_target"),
|
|
1788
|
+
done_target=spec.get("done_target"),
|
|
1789
|
+
resolve_range=_resolve_range,
|
|
1790
|
+
)
|
|
1791
|
+
|
|
1792
|
+
def _wrapped_for(
|
|
1793
|
+
run: Any,
|
|
1794
|
+
ctx: Any,
|
|
1795
|
+
*,
|
|
1796
|
+
_base: Any = base_for,
|
|
1797
|
+
_pure_ids: set[str] = pure_ids,
|
|
1798
|
+
) -> StepPlan:
|
|
1799
|
+
# Ensure pure nodes feeding the loop body are re-evaluated per iteration.
|
|
1800
|
+
if flow is not None and _pure_ids and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
1801
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
1802
|
+
node_outputs = getattr(flow, "_node_outputs", None)
|
|
1803
|
+
if isinstance(node_outputs, dict):
|
|
1804
|
+
for nid in _pure_ids:
|
|
1805
|
+
node_outputs.pop(nid, None)
|
|
1806
|
+
plan = _base(run, ctx)
|
|
1807
|
+
# For scheduler persists `{i,index,total}` into run.vars; sync so
|
|
1808
|
+
# WS/UI node_complete events show the current iteration.
|
|
1809
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
1810
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
1811
|
+
return plan
|
|
1812
|
+
|
|
1813
|
+
handlers[node_id] = _wrapped_for
|
|
1814
|
+
elif effect_type == "on_event":
|
|
1815
|
+
from .adapters.event_adapter import create_on_event_node_handler
|
|
1816
|
+
|
|
1817
|
+
on_event_data_handler = handler_obj if callable(handler_obj) else None
|
|
1818
|
+
|
|
1819
|
+
def _resolve_inputs(
|
|
1820
|
+
run: Any,
|
|
1821
|
+
_handler: Any = on_event_data_handler,
|
|
1822
|
+
) -> Dict[str, Any]:
|
|
1823
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
1824
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
1825
|
+
if not callable(_handler):
|
|
1826
|
+
return {}
|
|
1827
|
+
last_output = run.vars.get("_last_output", {})
|
|
1828
|
+
try:
|
|
1829
|
+
resolved = _handler(last_output)
|
|
1830
|
+
except Exception:
|
|
1831
|
+
resolved = {}
|
|
1832
|
+
return resolved if isinstance(resolved, dict) else {}
|
|
1833
|
+
|
|
1834
|
+
# Blank/unspecified name is treated as "listen to any event" (wildcard).
|
|
1835
|
+
default_name = ""
|
|
1836
|
+
scope = "session"
|
|
1837
|
+
if isinstance(effect_config, dict):
|
|
1838
|
+
raw_name = effect_config.get("name") or effect_config.get("event_name")
|
|
1839
|
+
if isinstance(raw_name, str) and raw_name.strip():
|
|
1840
|
+
default_name = raw_name
|
|
1841
|
+
raw_scope = effect_config.get("scope")
|
|
1842
|
+
if isinstance(raw_scope, str) and raw_scope.strip():
|
|
1843
|
+
scope = raw_scope
|
|
1844
|
+
|
|
1845
|
+
handlers[node_id] = create_on_event_node_handler(
|
|
1846
|
+
node_id=node_id,
|
|
1847
|
+
next_node=next_node,
|
|
1848
|
+
resolve_inputs=_resolve_inputs if callable(on_event_data_handler) else None,
|
|
1849
|
+
default_name=default_name,
|
|
1850
|
+
scope=scope,
|
|
1851
|
+
flow=flow,
|
|
1852
|
+
)
|
|
1853
|
+
elif effect_type == "on_schedule":
|
|
1854
|
+
from .adapters.event_adapter import create_on_schedule_node_handler
|
|
1855
|
+
|
|
1856
|
+
on_schedule_data_handler = handler_obj if callable(handler_obj) else None
|
|
1857
|
+
|
|
1858
|
+
def _resolve_inputs(
|
|
1859
|
+
run: Any,
|
|
1860
|
+
_handler: Any = on_schedule_data_handler,
|
|
1861
|
+
) -> Dict[str, Any]:
|
|
1862
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
1863
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
1864
|
+
if not callable(_handler):
|
|
1865
|
+
return {}
|
|
1866
|
+
last_output = run.vars.get("_last_output", {})
|
|
1867
|
+
try:
|
|
1868
|
+
resolved = _handler(last_output)
|
|
1869
|
+
except Exception:
|
|
1870
|
+
resolved = {}
|
|
1871
|
+
return resolved if isinstance(resolved, dict) else {}
|
|
1872
|
+
|
|
1873
|
+
schedule = "15s"
|
|
1874
|
+
recurrent = True
|
|
1875
|
+
if isinstance(effect_config, dict):
|
|
1876
|
+
raw_schedule = effect_config.get("schedule")
|
|
1877
|
+
if isinstance(raw_schedule, str) and raw_schedule.strip():
|
|
1878
|
+
schedule = raw_schedule.strip()
|
|
1879
|
+
raw_recurrent = effect_config.get("recurrent")
|
|
1880
|
+
if isinstance(raw_recurrent, bool):
|
|
1881
|
+
recurrent = raw_recurrent
|
|
1882
|
+
|
|
1883
|
+
handlers[node_id] = create_on_schedule_node_handler(
|
|
1884
|
+
node_id=node_id,
|
|
1885
|
+
next_node=next_node,
|
|
1886
|
+
resolve_inputs=_resolve_inputs if callable(on_schedule_data_handler) else None,
|
|
1887
|
+
schedule=schedule,
|
|
1888
|
+
recurrent=recurrent,
|
|
1889
|
+
flow=flow,
|
|
1890
|
+
)
|
|
1891
|
+
elif effect_type == "emit_event":
|
|
1892
|
+
from .adapters.event_adapter import create_emit_event_node_handler
|
|
1893
|
+
|
|
1894
|
+
emit_data_handler = handler_obj if callable(handler_obj) else None
|
|
1895
|
+
|
|
1896
|
+
def _resolve_inputs(
|
|
1897
|
+
run: Any,
|
|
1898
|
+
_handler: Any = emit_data_handler,
|
|
1899
|
+
) -> Dict[str, Any]:
|
|
1900
|
+
if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
|
|
1901
|
+
_sync_effect_results_to_node_outputs(run, flow)
|
|
1902
|
+
if not callable(_handler):
|
|
1903
|
+
return {}
|
|
1904
|
+
last_output = run.vars.get("_last_output", {})
|
|
1905
|
+
try:
|
|
1906
|
+
resolved = _handler(last_output)
|
|
1907
|
+
except Exception:
|
|
1908
|
+
resolved = {}
|
|
1909
|
+
return resolved if isinstance(resolved, dict) else {}
|
|
1910
|
+
|
|
1911
|
+
default_name = ""
|
|
1912
|
+
default_session_id: Optional[str] = None
|
|
1913
|
+
scope = "session"
|
|
1914
|
+
if isinstance(effect_config, dict):
|
|
1915
|
+
raw_name = effect_config.get("name") or effect_config.get("event_name")
|
|
1916
|
+
if isinstance(raw_name, str) and raw_name.strip():
|
|
1917
|
+
default_name = raw_name
|
|
1918
|
+
raw_session = effect_config.get("session_id")
|
|
1919
|
+
if raw_session is None:
|
|
1920
|
+
raw_session = effect_config.get("sessionId")
|
|
1921
|
+
if isinstance(raw_session, str) and raw_session.strip():
|
|
1922
|
+
default_session_id = raw_session.strip()
|
|
1923
|
+
raw_scope = effect_config.get("scope")
|
|
1924
|
+
if isinstance(raw_scope, str) and raw_scope.strip():
|
|
1925
|
+
scope = raw_scope
|
|
1926
|
+
|
|
1927
|
+
handlers[node_id] = create_emit_event_node_handler(
|
|
1928
|
+
node_id=node_id,
|
|
1929
|
+
next_node=next_node,
|
|
1930
|
+
resolve_inputs=_resolve_inputs,
|
|
1931
|
+
default_name=default_name,
|
|
1932
|
+
default_session_id=default_session_id,
|
|
1933
|
+
scope=scope,
|
|
1934
|
+
)
|
|
1935
|
+
elif effect_type:
|
|
1936
|
+
# Pass the handler_obj as data_aware_handler if it's callable
|
|
1937
|
+
# This allows visual flows to resolve data edges before creating effects
|
|
1938
|
+
data_aware_handler = handler_obj if callable(handler_obj) else None
|
|
1939
|
+
handlers[node_id] = _create_effect_node_handler(
|
|
1940
|
+
node_id=node_id,
|
|
1941
|
+
effect_type=effect_type,
|
|
1942
|
+
effect_config=effect_config,
|
|
1943
|
+
next_node=next_node,
|
|
1944
|
+
input_key=getattr(flow_node, "input_key", None),
|
|
1945
|
+
output_key=getattr(flow_node, "output_key", None),
|
|
1946
|
+
data_aware_handler=data_aware_handler,
|
|
1947
|
+
flow=flow,
|
|
1948
|
+
)
|
|
1949
|
+
elif _is_agent(handler_obj):
|
|
1950
|
+
handlers[node_id] = create_agent_node_handler(
|
|
1951
|
+
node_id=node_id,
|
|
1952
|
+
agent=handler_obj,
|
|
1953
|
+
next_node=next_node,
|
|
1954
|
+
input_key=getattr(flow_node, "input_key", None),
|
|
1955
|
+
output_key=getattr(flow_node, "output_key", None),
|
|
1956
|
+
)
|
|
1957
|
+
elif _is_flow(handler_obj):
|
|
1958
|
+
# Nested flow - compile recursively
|
|
1959
|
+
nested_spec = compile_flow(handler_obj)
|
|
1960
|
+
handlers[node_id] = create_subflow_node_handler(
|
|
1961
|
+
node_id=node_id,
|
|
1962
|
+
nested_workflow=nested_spec,
|
|
1963
|
+
next_node=next_node,
|
|
1964
|
+
input_key=getattr(flow_node, "input_key", None),
|
|
1965
|
+
output_key=getattr(flow_node, "output_key", None),
|
|
1966
|
+
)
|
|
1967
|
+
elif visual_type == "set_var":
|
|
1968
|
+
from .adapters.variable_adapter import create_set_var_node_handler
|
|
1969
|
+
|
|
1970
|
+
data_aware_handler = handler_obj if callable(handler_obj) else None
|
|
1971
|
+
handlers[node_id] = create_set_var_node_handler(
|
|
1972
|
+
node_id=node_id,
|
|
1973
|
+
next_node=next_node,
|
|
1974
|
+
data_aware_handler=data_aware_handler,
|
|
1975
|
+
flow=flow,
|
|
1976
|
+
)
|
|
1977
|
+
elif visual_type == "set_var_property":
|
|
1978
|
+
from .adapters.variable_adapter import create_set_var_property_node_handler
|
|
1979
|
+
|
|
1980
|
+
data_aware_handler = handler_obj if callable(handler_obj) else None
|
|
1981
|
+
handlers[node_id] = create_set_var_property_node_handler(
|
|
1982
|
+
node_id=node_id,
|
|
1983
|
+
next_node=next_node,
|
|
1984
|
+
data_aware_handler=data_aware_handler,
|
|
1985
|
+
flow=flow,
|
|
1986
|
+
)
|
|
1987
|
+
elif visual_type == "set_vars":
|
|
1988
|
+
from .adapters.variable_adapter import create_set_vars_node_handler
|
|
1989
|
+
|
|
1990
|
+
data_aware_handler = handler_obj if callable(handler_obj) else None
|
|
1991
|
+
handlers[node_id] = create_set_vars_node_handler(
|
|
1992
|
+
node_id=node_id,
|
|
1993
|
+
next_node=next_node,
|
|
1994
|
+
data_aware_handler=data_aware_handler,
|
|
1995
|
+
flow=flow,
|
|
1996
|
+
)
|
|
1997
|
+
elif callable(handler_obj):
|
|
1998
|
+
# Check if this is a visual flow handler (has closure access to node_outputs)
|
|
1999
|
+
# Visual flow handlers need special handling to resolve data edges
|
|
2000
|
+
handlers[node_id] = _create_visual_function_handler(
|
|
2001
|
+
node_id=node_id,
|
|
2002
|
+
func=handler_obj,
|
|
2003
|
+
next_node=next_node,
|
|
2004
|
+
input_key=getattr(flow_node, "input_key", None),
|
|
2005
|
+
output_key=getattr(flow_node, "output_key", None),
|
|
2006
|
+
branch_map=branch_map,
|
|
2007
|
+
flow=flow,
|
|
2008
|
+
)
|
|
2009
|
+
else:
|
|
2010
|
+
raise TypeError(
|
|
2011
|
+
f"Unknown handler type for node '{node_id}': {type(handler_obj)}. "
|
|
2012
|
+
"Expected agent, function, or Flow."
|
|
2013
|
+
)
|
|
2014
|
+
|
|
2015
|
+
# Blueprint-style control flow: terminal nodes inside Sequence/Parallel should
|
|
2016
|
+
# return to the active scheduler instead of completing the whole run.
|
|
2017
|
+
handlers[node_id] = _wrap_return_to_active_control(
|
|
2018
|
+
handlers[node_id],
|
|
2019
|
+
node_id=node_id,
|
|
2020
|
+
visual_type=visual_type if isinstance(visual_type, str) else None,
|
|
2021
|
+
)
|
|
2022
|
+
|
|
2023
|
+
return WorkflowSpec(
|
|
2024
|
+
workflow_id=flow.flow_id,
|
|
2025
|
+
entry_node=flow.entry_node,
|
|
2026
|
+
nodes=handlers,
|
|
2027
|
+
)
|