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,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
+ )