AbstractRuntime 0.4.0__py3-none-any.whl → 0.4.1__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 (65) hide show
  1. abstractruntime/__init__.py +76 -1
  2. abstractruntime/core/config.py +68 -1
  3. abstractruntime/core/models.py +5 -0
  4. abstractruntime/core/policy.py +74 -3
  5. abstractruntime/core/runtime.py +1002 -126
  6. abstractruntime/core/vars.py +8 -2
  7. abstractruntime/evidence/recorder.py +1 -1
  8. abstractruntime/history_bundle.py +772 -0
  9. abstractruntime/integrations/abstractcore/__init__.py +3 -0
  10. abstractruntime/integrations/abstractcore/default_tools.py +127 -3
  11. abstractruntime/integrations/abstractcore/effect_handlers.py +2440 -99
  12. abstractruntime/integrations/abstractcore/embeddings_client.py +69 -0
  13. abstractruntime/integrations/abstractcore/factory.py +68 -20
  14. abstractruntime/integrations/abstractcore/llm_client.py +447 -15
  15. abstractruntime/integrations/abstractcore/mcp_worker.py +1 -0
  16. abstractruntime/integrations/abstractcore/session_attachments.py +946 -0
  17. abstractruntime/integrations/abstractcore/tool_executor.py +31 -10
  18. abstractruntime/integrations/abstractcore/workspace_scoped_tools.py +561 -0
  19. abstractruntime/integrations/abstractmemory/__init__.py +3 -0
  20. abstractruntime/integrations/abstractmemory/effect_handlers.py +946 -0
  21. abstractruntime/memory/active_context.py +6 -1
  22. abstractruntime/memory/kg_packets.py +164 -0
  23. abstractruntime/memory/memact_composer.py +175 -0
  24. abstractruntime/memory/recall_levels.py +163 -0
  25. abstractruntime/memory/token_budget.py +86 -0
  26. abstractruntime/storage/__init__.py +4 -1
  27. abstractruntime/storage/artifacts.py +158 -30
  28. abstractruntime/storage/base.py +17 -1
  29. abstractruntime/storage/commands.py +339 -0
  30. abstractruntime/storage/in_memory.py +41 -1
  31. abstractruntime/storage/json_files.py +195 -12
  32. abstractruntime/storage/observable.py +38 -1
  33. abstractruntime/storage/offloading.py +433 -0
  34. abstractruntime/storage/sqlite.py +836 -0
  35. abstractruntime/visualflow_compiler/__init__.py +29 -0
  36. abstractruntime/visualflow_compiler/adapters/__init__.py +11 -0
  37. abstractruntime/visualflow_compiler/adapters/agent_adapter.py +126 -0
  38. abstractruntime/visualflow_compiler/adapters/context_adapter.py +109 -0
  39. abstractruntime/visualflow_compiler/adapters/control_adapter.py +615 -0
  40. abstractruntime/visualflow_compiler/adapters/effect_adapter.py +1051 -0
  41. abstractruntime/visualflow_compiler/adapters/event_adapter.py +307 -0
  42. abstractruntime/visualflow_compiler/adapters/function_adapter.py +97 -0
  43. abstractruntime/visualflow_compiler/adapters/memact_adapter.py +114 -0
  44. abstractruntime/visualflow_compiler/adapters/subflow_adapter.py +74 -0
  45. abstractruntime/visualflow_compiler/adapters/variable_adapter.py +316 -0
  46. abstractruntime/visualflow_compiler/compiler.py +3832 -0
  47. abstractruntime/visualflow_compiler/flow.py +247 -0
  48. abstractruntime/visualflow_compiler/visual/__init__.py +13 -0
  49. abstractruntime/visualflow_compiler/visual/agent_ids.py +29 -0
  50. abstractruntime/visualflow_compiler/visual/builtins.py +1376 -0
  51. abstractruntime/visualflow_compiler/visual/code_executor.py +214 -0
  52. abstractruntime/visualflow_compiler/visual/executor.py +2804 -0
  53. abstractruntime/visualflow_compiler/visual/models.py +211 -0
  54. abstractruntime/workflow_bundle/__init__.py +52 -0
  55. abstractruntime/workflow_bundle/models.py +236 -0
  56. abstractruntime/workflow_bundle/packer.py +317 -0
  57. abstractruntime/workflow_bundle/reader.py +87 -0
  58. abstractruntime/workflow_bundle/registry.py +587 -0
  59. abstractruntime-0.4.1.dist-info/METADATA +177 -0
  60. abstractruntime-0.4.1.dist-info/RECORD +86 -0
  61. abstractruntime-0.4.0.dist-info/METADATA +0 -167
  62. abstractruntime-0.4.0.dist-info/RECORD +0 -49
  63. {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/WHEEL +0 -0
  64. {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/entry_points.txt +0 -0
  65. {abstractruntime-0.4.0.dist-info → abstractruntime-0.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,3832 @@
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 .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_kg_assert_handler,
17
+ create_memory_kg_query_handler,
18
+ create_memory_kg_resolve_handler,
19
+ create_memory_note_handler,
20
+ create_memory_query_handler,
21
+ create_memory_tag_handler,
22
+ create_memory_compact_handler,
23
+ create_memory_rehydrate_handler,
24
+ create_llm_call_handler,
25
+ create_tool_calls_handler,
26
+ create_call_tool_handler,
27
+ create_start_subworkflow_handler,
28
+ )
29
+
30
+ if TYPE_CHECKING:
31
+ from abstractruntime.core.models import StepPlan
32
+ from abstractruntime.core.spec import WorkflowSpec
33
+
34
+
35
+ def _is_agent(obj: Any) -> bool:
36
+ """Check if object is an agent (has workflow attribute)."""
37
+ return hasattr(obj, "workflow") and hasattr(obj, "start") and hasattr(obj, "step")
38
+
39
+
40
+ def _is_flow(obj: Any) -> bool:
41
+ """Check if object behaves like a Flow.
42
+
43
+ Accept duck-typed Flow implementations (e.g. AbstractFlow's Flow class)
44
+ so hosts can compile nested flows without depending on a specific class
45
+ identity.
46
+ """
47
+ if isinstance(obj, Flow):
48
+ return True
49
+ try:
50
+ flow_id = getattr(obj, "flow_id", None)
51
+ nodes = getattr(obj, "nodes", None)
52
+ edges = getattr(obj, "edges", None)
53
+ validate = getattr(obj, "validate", None)
54
+ return (
55
+ isinstance(flow_id, str)
56
+ and isinstance(nodes, dict)
57
+ and isinstance(edges, list)
58
+ and callable(validate)
59
+ )
60
+ except Exception:
61
+ return False
62
+
63
+
64
+ def _create_effect_node_handler(
65
+ node_id: str,
66
+ effect_type: str,
67
+ effect_config: Dict[str, Any],
68
+ next_node: Optional[str],
69
+ input_key: Optional[str],
70
+ output_key: Optional[str],
71
+ data_aware_handler: Optional[Callable] = None,
72
+ *,
73
+ flow: Optional[Flow] = None,
74
+ ) -> Callable:
75
+ """Create a node handler for effect nodes.
76
+
77
+ Effect nodes produce AbstractRuntime Effects that can pause execution
78
+ and wait for external input.
79
+
80
+ If data_aware_handler is provided (from visual flow's executor), it will
81
+ be called first to resolve data edge inputs before creating the effect.
82
+ """
83
+ from abstractruntime.core.models import StepPlan, Effect, EffectType
84
+
85
+ # Build the base effect handler
86
+ if effect_type == "ask_user":
87
+ base_handler = create_ask_user_handler(
88
+ node_id=node_id,
89
+ next_node=next_node,
90
+ input_key=input_key,
91
+ output_key=output_key,
92
+ allow_free_text=effect_config.get("allowFreeText", True),
93
+ )
94
+ elif effect_type == "answer_user":
95
+ base_handler = create_answer_user_handler(
96
+ node_id=node_id,
97
+ next_node=next_node,
98
+ input_key=input_key,
99
+ output_key=output_key,
100
+ )
101
+ elif effect_type == "wait_until":
102
+ base_handler = create_wait_until_handler(
103
+ node_id=node_id,
104
+ next_node=next_node,
105
+ input_key=input_key,
106
+ output_key=output_key,
107
+ duration_type=effect_config.get("durationType", "seconds"),
108
+ )
109
+ elif effect_type == "wait_event":
110
+ base_handler = create_wait_event_handler(
111
+ node_id=node_id,
112
+ next_node=next_node,
113
+ input_key=input_key,
114
+ output_key=output_key,
115
+ )
116
+ elif effect_type == "memory_note":
117
+ base_handler = create_memory_note_handler(
118
+ node_id=node_id,
119
+ next_node=next_node,
120
+ input_key=input_key,
121
+ output_key=output_key,
122
+ )
123
+ elif effect_type == "memory_query":
124
+ base_handler = create_memory_query_handler(
125
+ node_id=node_id,
126
+ next_node=next_node,
127
+ input_key=input_key,
128
+ output_key=output_key,
129
+ )
130
+ elif effect_type == "memory_tag":
131
+ base_handler = create_memory_tag_handler(
132
+ node_id=node_id,
133
+ next_node=next_node,
134
+ input_key=input_key,
135
+ output_key=output_key,
136
+ )
137
+ elif effect_type == "memory_compact":
138
+ base_handler = create_memory_compact_handler(
139
+ node_id=node_id,
140
+ next_node=next_node,
141
+ input_key=input_key,
142
+ output_key=output_key,
143
+ )
144
+ elif effect_type == "memory_rehydrate":
145
+ base_handler = create_memory_rehydrate_handler(
146
+ node_id=node_id,
147
+ next_node=next_node,
148
+ input_key=input_key,
149
+ output_key=output_key,
150
+ )
151
+ elif effect_type == "memory_kg_assert":
152
+ base_handler = create_memory_kg_assert_handler(
153
+ node_id=node_id,
154
+ next_node=next_node,
155
+ input_key=input_key,
156
+ output_key=output_key,
157
+ )
158
+ elif effect_type == "memory_kg_query":
159
+ base_handler = create_memory_kg_query_handler(
160
+ node_id=node_id,
161
+ next_node=next_node,
162
+ input_key=input_key,
163
+ output_key=output_key,
164
+ )
165
+ elif effect_type == "memory_kg_resolve":
166
+ base_handler = create_memory_kg_resolve_handler(
167
+ node_id=node_id,
168
+ next_node=next_node,
169
+ input_key=input_key,
170
+ output_key=output_key,
171
+ )
172
+ elif effect_type == "llm_call":
173
+ base_handler = create_llm_call_handler(
174
+ node_id=node_id,
175
+ next_node=next_node,
176
+ input_key=input_key,
177
+ output_key=output_key,
178
+ provider=effect_config.get("provider"),
179
+ model=effect_config.get("model"),
180
+ temperature=effect_config.get("temperature", 0.7),
181
+ seed=effect_config.get("seed", -1),
182
+ )
183
+ elif effect_type == "tool_calls":
184
+ base_handler = create_tool_calls_handler(
185
+ node_id=node_id,
186
+ next_node=next_node,
187
+ input_key=input_key,
188
+ output_key=output_key,
189
+ allowed_tools=effect_config.get("allowed_tools") if isinstance(effect_config, dict) else None,
190
+ )
191
+ elif effect_type == "call_tool":
192
+ base_handler = create_call_tool_handler(
193
+ node_id=node_id,
194
+ next_node=next_node,
195
+ input_key=input_key,
196
+ output_key=output_key,
197
+ allowed_tools=effect_config.get("allowed_tools") if isinstance(effect_config, dict) else None,
198
+ )
199
+ elif effect_type == "start_subworkflow":
200
+ base_handler = create_start_subworkflow_handler(
201
+ node_id=node_id,
202
+ next_node=next_node,
203
+ input_key=input_key,
204
+ output_key=output_key,
205
+ workflow_id=effect_config.get("workflow_id"),
206
+ )
207
+ else:
208
+ raise ValueError(f"Unknown effect type: {effect_type}")
209
+
210
+ # If no data-aware handler, just return the base effect handler
211
+ if data_aware_handler is None:
212
+ return base_handler
213
+
214
+ # Wrap to resolve data edges before creating the effect
215
+ def wrapped_effect_handler(run: Any, ctx: Any) -> "StepPlan":
216
+ """Resolve data edges via executor handler, then create the proper Effect."""
217
+ if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
218
+ _sync_effect_results_to_node_outputs(run, flow)
219
+
220
+ # Call the data-aware handler to resolve data edge inputs
221
+ # This reads from flow._node_outputs which has literal values
222
+ last_output = run.vars.get("_last_output", {})
223
+ resolved = data_aware_handler(last_output)
224
+
225
+ # Check if this returned a _pending_effect marker (from executor's effect handlers)
226
+ if isinstance(resolved, dict) and "_pending_effect" in resolved:
227
+ pending = resolved["_pending_effect"]
228
+ effect_type_str = pending.get("type", "")
229
+
230
+ # Get the EffectType enum value by name (avoid building dict with all members)
231
+ eff_type = None
232
+ try:
233
+ eff_type = EffectType(effect_type_str)
234
+ except ValueError:
235
+ pass # Unknown effect type
236
+ if eff_type:
237
+ # ------------------------------------------------------------
238
+ # 467: Memory-source access pins for Visual LLM Call nodes
239
+ # ------------------------------------------------------------
240
+ #
241
+ # Visual executor passes memory controls through on the pending effect dict.
242
+ # Here we:
243
+ # - schedule runtime-owned MEMORY_* effects before the LLM call
244
+ # - inject KG Active Memory into the call (bounded, deterministic)
245
+ # - map session attachment index pin -> LLM params override
246
+ #
247
+ # IMPORTANT:
248
+ # - Pre-call effects MUST NOT write to `_temp.effects.{node_id}` because that
249
+ # slot is reserved for the LLM_CALL outcome and is synced to node outputs.
250
+ if eff_type == EffectType.LLM_CALL and isinstance(pending, dict):
251
+ try:
252
+ import hashlib
253
+ import json
254
+
255
+ def _ensure_dict(parent: Any, key: str) -> dict:
256
+ if not isinstance(parent, dict):
257
+ return {}
258
+ cur = parent.get(key)
259
+ if not isinstance(cur, dict):
260
+ cur = {}
261
+ parent[key] = cur
262
+ return cur
263
+
264
+ def _coerce_boolish(value: Any) -> Optional[bool]:
265
+ if value is None:
266
+ return None
267
+ if isinstance(value, bool):
268
+ return bool(value)
269
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
270
+ try:
271
+ return float(value) != 0.0
272
+ except Exception:
273
+ return None
274
+ if isinstance(value, str):
275
+ s = value.strip().lower()
276
+ if not s:
277
+ return None
278
+ if s in {"false", "0", "no", "off"}:
279
+ return False
280
+ if s in {"true", "1", "yes", "on"}:
281
+ return True
282
+ return None
283
+
284
+ def _coerce_int(value: Any) -> Optional[int]:
285
+ if value is None or isinstance(value, bool):
286
+ return None
287
+ try:
288
+ iv = int(float(value))
289
+ except Exception:
290
+ return None
291
+ return iv
292
+
293
+ def _coerce_float(value: Any) -> Optional[float]:
294
+ if value is None or isinstance(value, bool):
295
+ return None
296
+ try:
297
+ fv = float(value)
298
+ except Exception:
299
+ return None
300
+ if not (fv == fv): # NaN
301
+ return None
302
+ return fv
303
+
304
+ def _nonempty_str(value: Any) -> str:
305
+ if not isinstance(value, str):
306
+ return ""
307
+ return value.strip()
308
+
309
+ def _hash_fingerprint(obj: dict) -> str:
310
+ try:
311
+ raw = json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
312
+ except Exception:
313
+ raw = str(obj)
314
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
315
+
316
+ temp = _ensure_dict(run.vars, "_temp")
317
+ memory_sources = _ensure_dict(temp, "memory_sources")
318
+ bucket = memory_sources.get(node_id)
319
+ if not isinstance(bucket, dict):
320
+ bucket = {}
321
+ memory_sources[node_id] = bucket
322
+ meta = bucket.get("_meta")
323
+ if not isinstance(meta, dict):
324
+ meta = {}
325
+ bucket["_meta"] = meta
326
+
327
+ # Memory config (tri-state booleans: absent => no override).
328
+ use_span_memory = _coerce_boolish(pending.get("use_span_memory")) if "use_span_memory" in pending else None
329
+ use_kg_memory = _coerce_boolish(pending.get("use_kg_memory")) if "use_kg_memory" in pending else None
330
+ use_semantic_search = (
331
+ _coerce_boolish(pending.get("use_semantic_search")) if "use_semantic_search" in pending else None
332
+ )
333
+ use_session_attachments = (
334
+ _coerce_boolish(pending.get("use_session_attachments")) if "use_session_attachments" in pending else None
335
+ )
336
+
337
+ recall_level = _nonempty_str(pending.get("recall_level"))
338
+ memory_scope = _nonempty_str(pending.get("memory_scope")) or "run"
339
+
340
+ # Derive query text (best-effort) from pins or the call prompt.
341
+ query_text = _nonempty_str(pending.get("memory_query"))
342
+ if not query_text:
343
+ query_text = _nonempty_str(pending.get("prompt"))
344
+ if not query_text and isinstance(pending.get("messages"), list):
345
+ # Fallback: last user message in a message-based call.
346
+ for m in reversed(list(pending.get("messages") or [])):
347
+ if not isinstance(m, dict):
348
+ continue
349
+ if m.get("role") != "user":
350
+ continue
351
+ c = m.get("content")
352
+ if isinstance(c, str) and c.strip():
353
+ query_text = c.strip()
354
+ break
355
+
356
+ # Re-entry / loop safety:
357
+ # If the node is executed again with different inputs, clear the per-node
358
+ # memory-source bucket so we don't reuse stale recall results.
359
+ fp = _hash_fingerprint(
360
+ {
361
+ "prompt": _nonempty_str(pending.get("prompt")),
362
+ "system_prompt": _nonempty_str(pending.get("system_prompt")),
363
+ "provider": _nonempty_str(pending.get("provider")),
364
+ "model": _nonempty_str(pending.get("model")),
365
+ "memory_query": query_text,
366
+ "memory_scope": memory_scope,
367
+ "recall_level": recall_level,
368
+ "use_span_memory": use_span_memory,
369
+ "use_kg_memory": use_kg_memory,
370
+ "use_semantic_search": use_semantic_search,
371
+ "use_session_attachments": use_session_attachments,
372
+ "max_span_messages": _coerce_int(pending.get("max_span_messages")),
373
+ "kg_max_input_tokens": _coerce_int(pending.get("kg_max_input_tokens")),
374
+ "kg_limit": _coerce_int(pending.get("kg_limit")),
375
+ "kg_min_score": _coerce_float(pending.get("kg_min_score")),
376
+ }
377
+ )
378
+ if meta.get("fingerprint") != fp:
379
+ try:
380
+ bucket.clear()
381
+ except Exception:
382
+ bucket = {}
383
+ memory_sources[node_id] = bucket
384
+ meta = {"fingerprint": fp}
385
+ bucket["_meta"] = meta
386
+
387
+ base_key = f"_temp.memory_sources.{node_id}"
388
+
389
+ # Reserved: semantic search over artifacts is planned (464). Avoid silently doing nothing.
390
+ if use_semantic_search is True:
391
+ warnings = meta.get("warnings")
392
+ if not isinstance(warnings, list):
393
+ warnings = []
394
+ meta["warnings"] = warnings
395
+ msg = "use_semantic_search is not implemented yet (planned: 464); ignoring."
396
+ if msg not in warnings:
397
+ warnings.append(msg)
398
+
399
+ # Phase 1: span-index recall (metadata)
400
+ if use_span_memory is True and "span_query" not in bucket and query_text:
401
+ mq_payload: Dict[str, Any] = {"query": query_text, "scope": memory_scope, "return": "meta"}
402
+ if recall_level:
403
+ mq_payload["recall_level"] = recall_level
404
+ return StepPlan(
405
+ node_id=node_id,
406
+ effect=Effect(
407
+ type=EffectType.MEMORY_QUERY,
408
+ payload=mq_payload,
409
+ result_key=f"{base_key}.span_query",
410
+ ),
411
+ next_node=node_id,
412
+ )
413
+
414
+ # Phase 2: span rehydration into context.messages
415
+ if use_span_memory is True and "span_rehydrate" not in bucket:
416
+ span_ids: list[str] = []
417
+ span_query = bucket.get("span_query")
418
+ if isinstance(span_query, dict):
419
+ results = span_query.get("results")
420
+ if isinstance(results, list) and results:
421
+ first = results[0] if isinstance(results[0], dict) else None
422
+ meta2 = first.get("meta") if isinstance(first, dict) else None
423
+ if isinstance(meta2, dict):
424
+ raw_ids = meta2.get("span_ids")
425
+ if isinstance(raw_ids, list):
426
+ span_ids = [str(x).strip() for x in raw_ids if str(x).strip()]
427
+ meta["span_ids"] = span_ids
428
+
429
+ mr_payload: Dict[str, Any] = {"span_ids": span_ids, "placement": "after_summary"}
430
+ if recall_level:
431
+ mr_payload["recall_level"] = recall_level
432
+ max_span_messages = _coerce_int(pending.get("max_span_messages"))
433
+ if max_span_messages is None and not recall_level:
434
+ # Safe default (explicit warning): without recall_level, MEMORY_REHYDRATE would
435
+ # otherwise insert all messages from selected spans.
436
+ max_span_messages = 80
437
+ warnings = meta.get("warnings")
438
+ if not isinstance(warnings, list):
439
+ warnings = []
440
+ meta["warnings"] = warnings
441
+ msg = "use_span_memory enabled without recall_level/max_span_messages; defaulting max_span_messages=80."
442
+ if msg not in warnings:
443
+ warnings.append(msg)
444
+ if isinstance(max_span_messages, int):
445
+ mr_payload["max_messages"] = max_span_messages
446
+
447
+ return StepPlan(
448
+ node_id=node_id,
449
+ effect=Effect(
450
+ type=EffectType.MEMORY_REHYDRATE,
451
+ payload=mr_payload,
452
+ result_key=f"{base_key}.span_rehydrate",
453
+ ),
454
+ next_node=node_id,
455
+ )
456
+
457
+ # Phase 3: KG recall (packetized Active Memory)
458
+ if use_kg_memory is True and "kg_query" not in bucket and query_text:
459
+ kg_payload: Dict[str, Any] = {"query_text": query_text, "scope": memory_scope}
460
+ if recall_level:
461
+ kg_payload["recall_level"] = recall_level
462
+ kg_min_score = _coerce_float(pending.get("kg_min_score"))
463
+ if isinstance(kg_min_score, float):
464
+ kg_payload["min_score"] = kg_min_score
465
+ kg_limit = _coerce_int(pending.get("kg_limit"))
466
+ if isinstance(kg_limit, int):
467
+ kg_payload["limit"] = kg_limit
468
+ kg_budget = _coerce_int(pending.get("kg_max_input_tokens"))
469
+ if kg_budget is None and not recall_level:
470
+ # Safe default (explicit warning): ensure we actually get `active_memory_text`
471
+ # even when recall_level is not set.
472
+ kg_budget = 1200
473
+ warnings = meta.get("warnings")
474
+ if not isinstance(warnings, list):
475
+ warnings = []
476
+ meta["warnings"] = warnings
477
+ msg = "use_kg_memory enabled without recall_level/kg_max_input_tokens; defaulting kg_max_input_tokens=1200."
478
+ if msg not in warnings:
479
+ warnings.append(msg)
480
+ if isinstance(kg_budget, int):
481
+ kg_payload["max_input_tokens"] = kg_budget
482
+ model_name = pending.get("model")
483
+ if isinstance(model_name, str) and model_name.strip():
484
+ kg_payload["model"] = model_name.strip()
485
+
486
+ return StepPlan(
487
+ node_id=node_id,
488
+ effect=Effect(
489
+ type=EffectType.MEMORY_KG_QUERY,
490
+ payload=kg_payload,
491
+ result_key=f"{base_key}.kg_query",
492
+ ),
493
+ next_node=node_id,
494
+ )
495
+
496
+ # Map session attachment index pin -> params override (explicit, auditable).
497
+ if use_session_attachments is not None:
498
+ params = pending.get("params")
499
+ if not isinstance(params, dict):
500
+ params = {}
501
+ pending["params"] = params
502
+ params["include_session_attachments_index"] = bool(use_session_attachments)
503
+
504
+ # Inject KG Active Memory.
505
+ if use_kg_memory is True:
506
+ active_text = ""
507
+ kg_query = bucket.get("kg_query")
508
+ if isinstance(kg_query, dict):
509
+ am = kg_query.get("active_memory_text")
510
+ active_text = am.strip() if isinstance(am, str) else ""
511
+ if active_text:
512
+ meta["kg_active_memory_text_len"] = len(active_text)
513
+
514
+ # Avoid duplicating the same block when the node loops.
515
+ def _has_active_memory_block(msgs: list[Any]) -> bool:
516
+ for m in msgs:
517
+ if not isinstance(m, dict) or m.get("role") != "system":
518
+ continue
519
+ c = m.get("content")
520
+ if isinstance(c, str) and "## KG ACTIVE MEMORY" in c:
521
+ return True
522
+ return False
523
+
524
+ if isinstance(pending.get("messages"), list):
525
+ msgs = [dict(m) for m in pending.get("messages") if isinstance(m, dict)]
526
+ if not _has_active_memory_block(msgs):
527
+ insert_at = 0
528
+ while insert_at < len(msgs):
529
+ if msgs[insert_at].get("role") != "system":
530
+ break
531
+ insert_at += 1
532
+ msgs.insert(insert_at, {"role": "system", "content": active_text})
533
+ pending["messages"] = msgs
534
+ else:
535
+ sys_raw = pending.get("system_prompt") or pending.get("system")
536
+ sys_text = sys_raw.strip() if isinstance(sys_raw, str) else ""
537
+ combined = f"{sys_text}\n\n{active_text}".strip() if sys_text else active_text
538
+ pending["system_prompt"] = combined
539
+
540
+ # Span rehydrate without conversation history:
541
+ # If span recall is enabled but include_context is false, inject the rehydrated
542
+ # messages as the call's message history so the current call can use them.
543
+ include_ctx = pending.get("include_context") is True or pending.get("use_context") is True
544
+ if use_span_memory is True and not include_ctx and "messages" not in pending:
545
+ span_ids = meta.get("span_ids")
546
+ span_ids_set = {str(x).strip() for x in span_ids if isinstance(x, str) and x.strip()} if isinstance(span_ids, list) else set()
547
+ recalled_msgs: list[Dict[str, Any]] = []
548
+ ctx_ns = run.vars.get("context") if isinstance(run.vars, dict) else None
549
+ raw_msgs = ctx_ns.get("messages") if isinstance(ctx_ns, dict) else None
550
+ if isinstance(raw_msgs, list) and span_ids_set:
551
+ for m_any in raw_msgs:
552
+ m = m_any if isinstance(m_any, dict) else None
553
+ if m is None:
554
+ continue
555
+ meta_m = m.get("metadata")
556
+ if not isinstance(meta_m, dict) or meta_m.get("rehydrated") is not True:
557
+ continue
558
+ src = meta_m.get("source_artifact_id")
559
+ src_id = str(src).strip() if isinstance(src, str) else ""
560
+ if src_id and src_id in span_ids_set:
561
+ recalled_msgs.append(dict(m))
562
+
563
+ if recalled_msgs:
564
+ sys_raw = pending.get("system_prompt") or pending.get("system")
565
+ sys_text = sys_raw.strip() if isinstance(sys_raw, str) else ""
566
+ prompt_raw = pending.get("prompt")
567
+ prompt_text = prompt_raw if isinstance(prompt_raw, str) else str(prompt_raw or "")
568
+
569
+ msgs: list[Dict[str, Any]] = []
570
+ if sys_text:
571
+ msgs.append({"role": "system", "content": sys_text})
572
+ msgs.extend(recalled_msgs)
573
+ msgs.append({"role": "user", "content": prompt_text})
574
+
575
+ # Best-effort token trim when an explicit max_input_tokens budget is provided.
576
+ try:
577
+ raw_max_in = pending.get("max_input_tokens")
578
+ max_in = int(raw_max_in) if raw_max_in is not None and not isinstance(raw_max_in, bool) else None
579
+ except Exception:
580
+ max_in = None
581
+ if isinstance(max_in, int) and max_in > 0:
582
+ try:
583
+ from abstractruntime.memory.token_budget import trim_messages_to_max_input_tokens
584
+
585
+ model_name = (
586
+ pending.get("model") if isinstance(pending.get("model"), str) else None
587
+ )
588
+ msgs = trim_messages_to_max_input_tokens(
589
+ msgs, max_input_tokens=int(max_in), model=model_name
590
+ )
591
+ except Exception:
592
+ pass
593
+
594
+ pending["messages"] = msgs
595
+ pending.pop("prompt", None)
596
+ pending.pop("system_prompt", None)
597
+ pending.pop("system", None)
598
+ except Exception:
599
+ # Never fail compilation/execution due to optional memory-source wiring.
600
+ pass
601
+
602
+ # Visual LLM Call UX: include the run's active context messages when possible.
603
+ #
604
+ # Why here (compiler) and not in AbstractRuntime:
605
+ # - LLM_CALL is a generic runtime effect; not all callers want implicit context.
606
+ # - Visual LLM Call nodes expect "Recall into context" to affect subsequent calls.
607
+ if (
608
+ eff_type == EffectType.LLM_CALL
609
+ and isinstance(pending, dict)
610
+ and "messages" not in pending
611
+ and pending.get("include_context") is True
612
+ ):
613
+ try:
614
+ from abstractruntime.memory.active_context import ActiveContextPolicy
615
+
616
+ base = ActiveContextPolicy.select_active_messages_for_llm_from_run(run)
617
+ messages = [dict(m) for m in base if isinstance(m, dict)]
618
+
619
+ sys_raw = pending.get("system_prompt") or pending.get("system")
620
+ sys_text = str(sys_raw).strip() if isinstance(sys_raw, str) else ""
621
+ if sys_text:
622
+ # Insert after existing system messages (system must precede user/assistant).
623
+ insert_at = 0
624
+ while insert_at < len(messages):
625
+ m = messages[insert_at]
626
+ if not isinstance(m, dict) or m.get("role") != "system":
627
+ break
628
+ insert_at += 1
629
+ messages.insert(insert_at, {"role": "system", "content": sys_text})
630
+
631
+ prompt_raw = pending.get("prompt")
632
+ prompt_text = prompt_raw if isinstance(prompt_raw, str) else str(prompt_raw or "")
633
+ messages.append({"role": "user", "content": prompt_text})
634
+
635
+ # Token-budget enforcement (ADR-0008):
636
+ # If `_limits.max_input_tokens` is set, trim the oldest non-system messages
637
+ # so the final LLM request remains bounded even when contexts grow large.
638
+ try:
639
+ limits = run.vars.get("_limits") if isinstance(run.vars, dict) else None
640
+ limits = limits if isinstance(limits, dict) else {}
641
+ raw_max_in = (
642
+ pending.get("max_input_tokens")
643
+ if isinstance(pending, dict) and "max_input_tokens" in pending
644
+ else limits.get("max_input_tokens")
645
+ )
646
+ if raw_max_in is not None and not isinstance(raw_max_in, bool):
647
+ max_in = int(raw_max_in)
648
+ else:
649
+ max_in = None
650
+ except Exception:
651
+ max_in = None
652
+ if isinstance(max_in, int) and max_in > 0:
653
+ try:
654
+ from abstractruntime.memory.token_budget import (
655
+ trim_messages_to_max_input_tokens,
656
+ )
657
+
658
+ model_name = (
659
+ pending.get("model") if isinstance(pending.get("model"), str) else None
660
+ )
661
+ messages = trim_messages_to_max_input_tokens(
662
+ messages, max_input_tokens=int(max_in), model=model_name
663
+ )
664
+ except Exception:
665
+ # Never fail compilation/execution due to token trimming.
666
+ pass
667
+
668
+ pending["messages"] = messages
669
+ # Avoid double-including prompt/system_prompt if the LLM client also
670
+ # builds messages from them.
671
+ pending.pop("prompt", None)
672
+ pending.pop("system_prompt", None)
673
+ pending.pop("system", None)
674
+ except Exception:
675
+ pass
676
+
677
+ # If the run has context attachments, forward them into the LLM call as `media`
678
+ # (unless the node/pins already provided an explicit `media` override).
679
+ if (
680
+ eff_type == EffectType.LLM_CALL
681
+ and isinstance(pending, dict)
682
+ and pending.get("include_context") is True
683
+ and "media" not in pending
684
+ ):
685
+ try:
686
+ ctx2 = run.vars.get("context") if isinstance(run.vars, dict) else None
687
+ attachments_raw = ctx2.get("attachments") if isinstance(ctx2, dict) else None
688
+ if isinstance(attachments_raw, list) and attachments_raw:
689
+ cleaned: list[Any] = []
690
+ for a in attachments_raw:
691
+ if isinstance(a, dict):
692
+ cleaned.append(dict(a))
693
+ elif isinstance(a, str) and a.strip():
694
+ cleaned.append(a.strip())
695
+ if cleaned:
696
+ pending["media"] = cleaned
697
+ except Exception:
698
+ pass
699
+
700
+ # Visual Subflow UX: optionally seed the child run's `context.messages` from the
701
+ # parent run's active context view (so LLM/Agent nodes inside the subflow can
702
+ # "Use context" without extra wiring).
703
+ if (
704
+ eff_type == EffectType.START_SUBWORKFLOW
705
+ and isinstance(pending, dict)
706
+ and pending.get("inherit_context") is True
707
+ ):
708
+ try:
709
+ from abstractruntime.memory.active_context import ActiveContextPolicy
710
+
711
+ inherited = ActiveContextPolicy.select_active_messages_for_llm_from_run(run)
712
+ inherited_msgs = [dict(m) for m in inherited if isinstance(m, dict)]
713
+ if inherited_msgs:
714
+ sub_vars = pending.get("vars")
715
+ if not isinstance(sub_vars, dict):
716
+ sub_vars = {}
717
+ sub_ctx = sub_vars.get("context")
718
+ if not isinstance(sub_ctx, dict):
719
+ sub_ctx = {}
720
+ sub_vars["context"] = sub_ctx
721
+
722
+ existing = sub_ctx.get("messages")
723
+ existing_msgs = [dict(m) for m in existing if isinstance(m, dict)] if isinstance(existing, list) else []
724
+
725
+ # Merge semantics:
726
+ # - preserve any explicit child system messages at the top
727
+ # - include the parent's active context (history) so recursive subflows
728
+ # see the latest turns even when the child pins provide a stale subset
729
+ # - keep any extra explicit child messages (e.g. an injected "next step")
730
+ #
731
+ # Dedup strictly by metadata.message_id when present. (Messages without
732
+ # ids are treated as distinct to avoid collapsing legitimate repeats.)
733
+ seen_ids: set[str] = set()
734
+
735
+ def _msg_id(m: Dict[str, Any]) -> str:
736
+ meta = m.get("metadata")
737
+ if isinstance(meta, dict):
738
+ mid = meta.get("message_id")
739
+ if isinstance(mid, str) and mid.strip():
740
+ return mid.strip()
741
+ return ""
742
+
743
+ def _append(dst: list[Dict[str, Any]], m: Dict[str, Any]) -> None:
744
+ mid = _msg_id(m)
745
+ if mid:
746
+ if mid in seen_ids:
747
+ return
748
+ seen_ids.add(mid)
749
+ dst.append(m)
750
+
751
+ merged: list[Dict[str, Any]] = []
752
+ # 1) Child system messages first (explicit overrides)
753
+ for m in existing_msgs:
754
+ if m.get("role") == "system":
755
+ _append(merged, m)
756
+ # 2) Parent system messages
757
+ for m in inherited_msgs:
758
+ if m.get("role") == "system":
759
+ _append(merged, m)
760
+ # 3) Parent conversation history
761
+ for m in inherited_msgs:
762
+ if m.get("role") != "system":
763
+ _append(merged, m)
764
+ # 4) Remaining child messages (extras)
765
+ for m in existing_msgs:
766
+ if m.get("role") != "system":
767
+ _append(merged, m)
768
+
769
+ sub_ctx["messages"] = merged
770
+
771
+ # Best-effort: also inherit parent context attachments, unless explicitly set on the child.
772
+ if "attachments" not in sub_ctx:
773
+ parent_ctx = run.vars.get("context") if isinstance(run.vars, dict) else None
774
+ raw_attachments = parent_ctx.get("attachments") if isinstance(parent_ctx, dict) else None
775
+ if isinstance(raw_attachments, list) and raw_attachments:
776
+ cleaned_att: list[Any] = []
777
+ for a in raw_attachments:
778
+ if isinstance(a, dict):
779
+ cleaned_att.append(dict(a))
780
+ elif isinstance(a, str) and a.strip():
781
+ cleaned_att.append(a.strip())
782
+ if cleaned_att:
783
+ sub_ctx["attachments"] = cleaned_att
784
+
785
+ pending["vars"] = sub_vars
786
+ except Exception:
787
+ pass
788
+ # Keep payload clean (runtime ignores it, but it clutters traces).
789
+ pending.pop("inherit_context", None)
790
+
791
+ # Build the Effect with resolved values from data edges
792
+ effect = Effect(
793
+ type=eff_type,
794
+ payload={
795
+ **pending,
796
+ "resume_to_node": next_node,
797
+ },
798
+ # Always store effect outcomes per-node; visual syncing can optionally copy to output_key.
799
+ result_key=f"_temp.effects.{node_id}",
800
+ )
801
+
802
+ return StepPlan(
803
+ node_id=node_id,
804
+ effect=effect,
805
+ next_node=next_node,
806
+ )
807
+
808
+ # Fallback: run.vars won't have the values, but try anyway
809
+ return base_handler(run, ctx)
810
+
811
+ return wrapped_effect_handler
812
+
813
+
814
+ def _create_visual_agent_effect_handler(
815
+ *,
816
+ node_id: str,
817
+ next_node: Optional[str],
818
+ agent_config: Dict[str, Any],
819
+ data_aware_handler: Optional[Callable[[Any], Any]],
820
+ flow: Flow,
821
+ ) -> Callable:
822
+ """Create a handler for the visual Agent node.
823
+
824
+ Visual Agent nodes delegate to AbstractAgent's canonical ReAct workflow
825
+ via `START_SUBWORKFLOW` (runtime-owned execution and persistence).
826
+
827
+ This handler:
828
+ - resolves `task` / `context` via data edges
829
+ - starts the configured ReAct subworkflow (sync; may wait)
830
+ - exposes the final agent result and trace ("scratchpad") via output pins
831
+ - optionally performs a final structured-output LLM_CALL (format-only pass)
832
+ """
833
+ import json
834
+
835
+ from abstractruntime.core.models import Effect, EffectType, StepPlan
836
+
837
+ from .visual.agent_ids import visual_react_workflow_id
838
+
839
+ def _ensure_temp_dict(run: Any) -> Dict[str, Any]:
840
+ temp = run.vars.get("_temp")
841
+ if not isinstance(temp, dict):
842
+ temp = {}
843
+ run.vars["_temp"] = temp
844
+ return temp
845
+
846
+ def _get_agent_bucket(run: Any) -> Dict[str, Any]:
847
+ temp = _ensure_temp_dict(run)
848
+ agent = temp.get("agent")
849
+ if not isinstance(agent, dict):
850
+ agent = {}
851
+ temp["agent"] = agent
852
+ bucket = agent.get(node_id)
853
+ if not isinstance(bucket, dict):
854
+ bucket = {}
855
+ agent[node_id] = bucket
856
+ return bucket
857
+
858
+ def _get_memory_sources_bucket(run: Any) -> Dict[str, Any]:
859
+ temp = _ensure_temp_dict(run)
860
+ mem = temp.get("memory_sources")
861
+ if not isinstance(mem, dict):
862
+ mem = {}
863
+ temp["memory_sources"] = mem
864
+ bucket = mem.get(node_id)
865
+ if not isinstance(bucket, dict):
866
+ bucket = {}
867
+ mem[node_id] = bucket
868
+ return bucket
869
+
870
+ def _resolve_inputs(run: Any) -> Dict[str, Any]:
871
+ if hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
872
+ _sync_effect_results_to_node_outputs(run, flow)
873
+
874
+ if not callable(data_aware_handler):
875
+ return {}
876
+ last_output = run.vars.get("_last_output", {})
877
+ try:
878
+ resolved = data_aware_handler(last_output)
879
+ except Exception:
880
+ resolved = {}
881
+ return resolved if isinstance(resolved, dict) else {}
882
+
883
+ def _flatten_node_traces(node_traces: Any) -> list[Dict[str, Any]]:
884
+ if not isinstance(node_traces, dict):
885
+ return []
886
+ out: list[Dict[str, Any]] = []
887
+ for trace in node_traces.values():
888
+ if not isinstance(trace, dict):
889
+ continue
890
+ steps = trace.get("steps")
891
+ if not isinstance(steps, list):
892
+ continue
893
+ for s in steps:
894
+ if isinstance(s, dict):
895
+ out.append(dict(s))
896
+ out.sort(key=lambda s: str(s.get("ts") or ""))
897
+ return out
898
+
899
+ def _as_dict_list(value: Any) -> list[Dict[str, Any]]:
900
+ if value is None:
901
+ return []
902
+ if isinstance(value, dict):
903
+ return [dict(value)]
904
+ if isinstance(value, list):
905
+ out: list[Dict[str, Any]] = []
906
+ for x in value:
907
+ if isinstance(x, dict):
908
+ out.append(dict(x))
909
+ return out
910
+ return []
911
+
912
+ def _extract_tool_activity_from_steps(steps: Any) -> tuple[list[Dict[str, Any]], list[Dict[str, Any]]]:
913
+ """Best-effort tool call/result extraction from flattened scratchpad steps."""
914
+ if not isinstance(steps, list):
915
+ return [], []
916
+ tool_calls: list[Dict[str, Any]] = []
917
+ tool_results: list[Dict[str, Any]] = []
918
+ for entry_any in steps:
919
+ entry = entry_any if isinstance(entry_any, dict) else None
920
+ if entry is None:
921
+ continue
922
+ effect = entry.get("effect")
923
+ if not isinstance(effect, dict) or str(effect.get("type") or "") != "tool_calls":
924
+ continue
925
+ payload = effect.get("payload")
926
+ payload_d = payload if isinstance(payload, dict) else {}
927
+ tool_calls.extend(_as_dict_list(payload_d.get("tool_calls")))
928
+
929
+ result = entry.get("result")
930
+ if not isinstance(result, dict):
931
+ continue
932
+ tool_results.extend(_as_dict_list(result.get("results")))
933
+ return tool_calls, tool_results
934
+
935
+ def _build_sub_vars(
936
+ run: Any,
937
+ *,
938
+ task: str,
939
+ context: Dict[str, Any],
940
+ provider: str,
941
+ model: str,
942
+ system_prompt: str,
943
+ allowed_tools: list[str],
944
+ temperature: float = 0.7,
945
+ seed: int = -1,
946
+ include_context: bool = False,
947
+ max_iterations: Optional[int] = None,
948
+ max_input_tokens: Optional[int] = None,
949
+ max_output_tokens: Optional[int] = None,
950
+ extra_messages: Optional[list[Dict[str, Any]]] = None,
951
+ include_session_attachments_index: Optional[bool] = None,
952
+ ) -> Dict[str, Any]:
953
+ parent_limits = run.vars.get("_limits")
954
+ limits = dict(parent_limits) if isinstance(parent_limits, dict) else {}
955
+ limits.setdefault("max_iterations", 25)
956
+ limits.setdefault("current_iteration", 0)
957
+ from abstractruntime.core.vars import DEFAULT_MAX_TOKENS
958
+
959
+ limits.setdefault("max_tokens", DEFAULT_MAX_TOKENS)
960
+ limits.setdefault("max_output_tokens", None)
961
+ limits.setdefault("max_input_tokens", None)
962
+ limits.setdefault("max_history_messages", -1)
963
+ limits.setdefault("estimated_tokens_used", 0)
964
+ limits.setdefault("warn_iterations_pct", 80)
965
+ limits.setdefault("warn_tokens_pct", 80)
966
+
967
+ if isinstance(max_iterations, int) and max_iterations > 0:
968
+ limits["max_iterations"] = int(max_iterations)
969
+
970
+ if isinstance(max_input_tokens, int) and max_input_tokens > 0:
971
+ limits["max_input_tokens"] = int(max_input_tokens)
972
+
973
+ if isinstance(max_output_tokens, int) and max_output_tokens > 0:
974
+ limits["max_output_tokens"] = int(max_output_tokens)
975
+
976
+ ctx_ns: Dict[str, Any] = {"task": str(task or ""), "messages": []}
977
+
978
+ # Optional: inherit the parent's active context as agent history (including Recall into context inserts).
979
+ # This is a visual-editor UX feature; it is disabled by default and can be enabled via
980
+ # agentConfig.include_context or the include_context input pin.
981
+ if bool(include_context):
982
+ try:
983
+ from abstractruntime.memory.active_context import ActiveContextPolicy
984
+
985
+ base = ActiveContextPolicy.select_active_messages_for_llm_from_run(run)
986
+ if isinstance(base, list):
987
+ ctx_ns["messages"] = [dict(m) for m in base if isinstance(m, dict)]
988
+ except Exception:
989
+ pass
990
+ # Best-effort: inherit parent attachments as part of context when "use context" is enabled.
991
+ try:
992
+ parent_ctx = run.vars.get("context") if isinstance(run.vars, dict) else None
993
+ raw_attachments = parent_ctx.get("attachments") if isinstance(parent_ctx, dict) else None
994
+ if isinstance(raw_attachments, list) and raw_attachments:
995
+ cleaned_att: list[Any] = []
996
+ for a in raw_attachments:
997
+ if isinstance(a, dict):
998
+ cleaned_att.append(dict(a))
999
+ elif isinstance(a, str) and a.strip():
1000
+ cleaned_att.append(a.strip())
1001
+ if cleaned_att:
1002
+ ctx_ns["attachments"] = cleaned_att
1003
+ except Exception:
1004
+ pass
1005
+
1006
+ # Explicit context.messages from a pin overrides the inherited run context.
1007
+ raw_msgs = context.get("messages") if isinstance(context, dict) else None
1008
+ if isinstance(raw_msgs, list):
1009
+ msgs = [dict(m) for m in raw_msgs if isinstance(m, dict)]
1010
+ if msgs:
1011
+ ctx_ns["messages"] = msgs
1012
+
1013
+ # Optional: inject additional memory/system messages (467).
1014
+ #
1015
+ # This is used by the Visual Agent node to include span/KG recall even when
1016
+ # include_context is false (without pulling in the full conversation history).
1017
+ if isinstance(extra_messages, list) and extra_messages:
1018
+ base_msgs = ctx_ns.get("messages")
1019
+ base_msgs = [dict(m) for m in base_msgs if isinstance(m, dict)] if isinstance(base_msgs, list) else []
1020
+ extra_msgs = [dict(m) for m in extra_messages if isinstance(m, dict)]
1021
+
1022
+ seen_ids: set[str] = set()
1023
+
1024
+ def _msg_id(m: Dict[str, Any]) -> str:
1025
+ meta = m.get("metadata")
1026
+ if isinstance(meta, dict):
1027
+ mid = meta.get("message_id")
1028
+ if isinstance(mid, str) and mid.strip():
1029
+ return mid.strip()
1030
+ return ""
1031
+
1032
+ def _append(dst: list[Dict[str, Any]], m: Dict[str, Any]) -> None:
1033
+ mid = _msg_id(m)
1034
+ if mid:
1035
+ if mid in seen_ids:
1036
+ return
1037
+ seen_ids.add(mid)
1038
+ dst.append(m)
1039
+
1040
+ merged: list[Dict[str, Any]] = []
1041
+ # Preserve base system messages at the very top.
1042
+ for m in base_msgs:
1043
+ if m.get("role") == "system":
1044
+ _append(merged, m)
1045
+ # Then injected system messages.
1046
+ for m in extra_msgs:
1047
+ if m.get("role") == "system":
1048
+ _append(merged, m)
1049
+ # Then base non-system (history/task context).
1050
+ for m in base_msgs:
1051
+ if m.get("role") != "system":
1052
+ _append(merged, m)
1053
+ # Finally injected non-system messages.
1054
+ for m in extra_msgs:
1055
+ if m.get("role") != "system":
1056
+ _append(merged, m)
1057
+
1058
+ ctx_ns["messages"] = merged
1059
+
1060
+ if isinstance(context, dict) and context:
1061
+ for k, v in context.items():
1062
+ if k in ("task", "messages"):
1063
+ continue
1064
+ ctx_ns[str(k)] = v
1065
+
1066
+ runtime_ns: Dict[str, Any] = {
1067
+ "inbox": [],
1068
+ "provider": provider,
1069
+ "model": model,
1070
+ "allowed_tools": list(allowed_tools),
1071
+ }
1072
+
1073
+ # Media input policy defaults (run-scoped):
1074
+ # If the parent run configured an explicit audio policy (e.g. AbstractCode sets
1075
+ # `_runtime.audio_policy="auto"` when audio attachments are present), inherit it
1076
+ # into the Agent subworkflow so all nested LLM calls behave consistently.
1077
+ try:
1078
+ parent_runtime = run.vars.get("_runtime") if isinstance(run.vars, dict) else None
1079
+ except Exception:
1080
+ parent_runtime = None
1081
+ if isinstance(parent_runtime, dict):
1082
+ ap = parent_runtime.get("audio_policy")
1083
+ if isinstance(ap, str) and ap.strip():
1084
+ runtime_ns.setdefault("audio_policy", ap.strip())
1085
+
1086
+ stt_lang = parent_runtime.get("stt_language")
1087
+ if stt_lang is None:
1088
+ stt_lang = parent_runtime.get("audio_language")
1089
+ if isinstance(stt_lang, str) and stt_lang.strip():
1090
+ runtime_ns.setdefault("stt_language", stt_lang.strip())
1091
+
1092
+ # Sampling controls:
1093
+ # - Do NOT force default values into the child runtime vars.
1094
+ # - When unset, step-level agent defaults (e.g. lower temperature for tool-followthrough)
1095
+ # must be able to take effect.
1096
+ #
1097
+ # Policy:
1098
+ # - temperature: only include when it differs from the framework default (0.7)
1099
+ # - seed: only include when explicitly set (>= 0)
1100
+ if abs(float(temperature) - 0.7) > 1e-9:
1101
+ runtime_ns["temperature"] = float(temperature)
1102
+ if int(seed) >= 0:
1103
+ runtime_ns["seed"] = int(seed)
1104
+ if include_session_attachments_index is not None:
1105
+ control = runtime_ns.get("control")
1106
+ if not isinstance(control, dict):
1107
+ control = {}
1108
+ runtime_ns["control"] = control
1109
+ control["include_session_attachments_index"] = bool(include_session_attachments_index)
1110
+ if isinstance(system_prompt, str) and system_prompt.strip():
1111
+ # IMPORTANT: Visual Agent `system` pin provides *additional* high-priority instructions.
1112
+ # We keep the canonical ReAct system prompt (iteration framing + evidence/tool-use rules)
1113
+ # and append this extra guidance so tool-followthrough remains robust.
1114
+ runtime_ns["system_prompt_extra"] = system_prompt
1115
+
1116
+ return {
1117
+ "context": ctx_ns,
1118
+ "scratchpad": {"iteration": 0, "max_iterations": int(limits.get("max_iterations") or 25)},
1119
+ # `_runtime` is durable; we store provider/model here so the ReAct subworkflow
1120
+ # can inject them into LLM_CALL payloads (and remain resumable).
1121
+ "_runtime": runtime_ns,
1122
+ "_temp": {},
1123
+ "_limits": limits,
1124
+ }
1125
+
1126
+ def _coerce_max_iterations(value: Any) -> Optional[int]:
1127
+ try:
1128
+ if value is None:
1129
+ return None
1130
+ if isinstance(value, bool):
1131
+ return None
1132
+ if isinstance(value, (int, float)):
1133
+ iv = int(float(value))
1134
+ return iv if iv > 0 else None
1135
+ if isinstance(value, str) and value.strip():
1136
+ iv = int(float(value.strip()))
1137
+ return iv if iv > 0 else None
1138
+ except Exception:
1139
+ return None
1140
+ return None
1141
+
1142
+ def _coerce_max_input_tokens(value: Any) -> Optional[int]:
1143
+ try:
1144
+ if value is None or isinstance(value, bool):
1145
+ return None
1146
+ if isinstance(value, (int, float)):
1147
+ iv = int(float(value))
1148
+ return iv if iv > 0 else None
1149
+ if isinstance(value, str) and value.strip():
1150
+ iv = int(float(value.strip()))
1151
+ return iv if iv > 0 else None
1152
+ except Exception:
1153
+ return None
1154
+ return None
1155
+
1156
+ def _coerce_max_output_tokens(value: Any) -> Optional[int]:
1157
+ # Same coercion rules as max_input_tokens: accept int/float/string, reject <=0.
1158
+ try:
1159
+ if value is None or isinstance(value, bool):
1160
+ return None
1161
+ if isinstance(value, (int, float)):
1162
+ iv = int(float(value))
1163
+ return iv if iv > 0 else None
1164
+ if isinstance(value, str) and value.strip():
1165
+ iv = int(float(value.strip()))
1166
+ return iv if iv > 0 else None
1167
+ except Exception:
1168
+ return None
1169
+ return None
1170
+
1171
+ def _coerce_temperature(value: Any, default: float = 0.7) -> float:
1172
+ try:
1173
+ if value is None or isinstance(value, bool):
1174
+ return float(default)
1175
+ return float(value)
1176
+ except Exception:
1177
+ return float(default)
1178
+
1179
+ def _coerce_seed(value: Any, default: int = -1) -> int:
1180
+ try:
1181
+ if value is None or isinstance(value, bool):
1182
+ return int(default)
1183
+ return int(value)
1184
+ except Exception:
1185
+ return int(default)
1186
+
1187
+ def handler(run: Any, ctx: Any) -> "StepPlan":
1188
+ del ctx
1189
+
1190
+ output_schema_cfg = agent_config.get("outputSchema") if isinstance(agent_config.get("outputSchema"), dict) else {}
1191
+ schema_enabled = bool(output_schema_cfg.get("enabled"))
1192
+ schema = output_schema_cfg.get("jsonSchema") if isinstance(output_schema_cfg.get("jsonSchema"), dict) else None
1193
+
1194
+ bucket = _get_agent_bucket(run)
1195
+ phase = str(bucket.get("phase") or "init")
1196
+
1197
+ # IMPORTANT: This visual Agent node can be executed multiple times within a single run
1198
+ # (e.g. inside a Loop/While/Sequence). The per-node bucket is durable and would otherwise
1199
+ # keep `phase="done"` and `resolved_inputs` from the first invocation, causing subsequent
1200
+ # invocations to skip work and reuse stale inputs/results.
1201
+ #
1202
+ # When we re-enter the node after it previously completed, reset the bucket to start a
1203
+ # fresh subworkflow invocation with the current upstream inputs.
1204
+ if phase == "done":
1205
+ try:
1206
+ bucket.clear()
1207
+ except Exception:
1208
+ # Best-effort; if clear fails, overwrite key fields below.
1209
+ pass
1210
+ # Clear any cached per-node memory-source recall outputs (467) so re-entry
1211
+ # (e.g. inside loops) re-computes recall for the new inputs.
1212
+ try:
1213
+ ms_bucket = _get_memory_sources_bucket(run)
1214
+ ms_bucket.clear()
1215
+ except Exception:
1216
+ pass
1217
+ phase = "init"
1218
+ bucket["phase"] = "init"
1219
+
1220
+ resolved_inputs = bucket.get("resolved_inputs")
1221
+ if not isinstance(resolved_inputs, dict) or phase == "init":
1222
+ resolved_inputs = _resolve_inputs(run)
1223
+ bucket["resolved_inputs"] = resolved_inputs if isinstance(resolved_inputs, dict) else {}
1224
+
1225
+ # Pin-driven structured output:
1226
+ # If `response_schema` is provided via an input pin (data edge), it overrides the node config
1227
+ # and enables the structured-output post-pass (durable LLM_CALL).
1228
+ pin_schema = resolved_inputs.get("response_schema") if isinstance(resolved_inputs, dict) else None
1229
+ if isinstance(pin_schema, dict) and pin_schema:
1230
+ schema = dict(pin_schema)
1231
+ schema_enabled = True
1232
+
1233
+ # Provider/model can come from Agent node config or from data-edge inputs (pins).
1234
+ provider_raw = resolved_inputs.get("provider") if isinstance(resolved_inputs, dict) else None
1235
+ model_raw = resolved_inputs.get("model") if isinstance(resolved_inputs, dict) else None
1236
+ if not isinstance(provider_raw, str) or not provider_raw.strip():
1237
+ provider_raw = agent_config.get("provider")
1238
+ if not isinstance(model_raw, str) or not model_raw.strip():
1239
+ model_raw = agent_config.get("model")
1240
+
1241
+ provider = str(provider_raw or "").strip().lower() if isinstance(provider_raw, str) else ""
1242
+ model = str(model_raw or "").strip() if isinstance(model_raw, str) else ""
1243
+
1244
+ task = str(resolved_inputs.get("task") or "")
1245
+ context_raw = resolved_inputs.get("context")
1246
+ context = context_raw if isinstance(context_raw, dict) else {}
1247
+ system_raw = resolved_inputs.get("system") if isinstance(resolved_inputs, dict) else None
1248
+ system_prompt = system_raw if isinstance(system_raw, str) else str(system_raw or "")
1249
+
1250
+ # Include parent run context (as agent history):
1251
+ # - Pin override wins when connected (resolved_inputs contains include_context)
1252
+ # - Otherwise fall back to node config (checkbox)
1253
+ # - Default: false
1254
+ include_context: bool
1255
+ if isinstance(resolved_inputs, dict) and "include_context" in resolved_inputs:
1256
+ include_context = bool(resolved_inputs.get("include_context"))
1257
+ else:
1258
+ include_context_cfg = agent_config.get("include_context")
1259
+ include_context = bool(include_context_cfg) if include_context_cfg is not None else False
1260
+
1261
+ # Agent loop budget (max_iterations) can come from a data-edge pin or from config.
1262
+ max_iterations_raw = resolved_inputs.get("max_iterations") if isinstance(resolved_inputs, dict) else None
1263
+ max_iterations_override = _coerce_max_iterations(max_iterations_raw)
1264
+ if max_iterations_override is None:
1265
+ max_iterations_override = _coerce_max_iterations(agent_config.get("max_iterations"))
1266
+
1267
+ # Token budget (max_input_tokens) can come from a data-edge pin or from config.
1268
+ max_input_tokens_raw = resolved_inputs.get("max_input_tokens") if isinstance(resolved_inputs, dict) else None
1269
+ max_input_tokens_override = _coerce_max_input_tokens(max_input_tokens_raw)
1270
+ if max_input_tokens_override is None:
1271
+ max_input_tokens_override = _coerce_max_input_tokens(agent_config.get("max_input_tokens"))
1272
+
1273
+ # Token budget (max_output_tokens) can come from a data-edge pin or from config.
1274
+ max_output_tokens_raw: Any = None
1275
+ if isinstance(resolved_inputs, dict):
1276
+ if "max_output_tokens" in resolved_inputs:
1277
+ max_output_tokens_raw = resolved_inputs.get("max_output_tokens")
1278
+ elif "max_out_tokens" in resolved_inputs:
1279
+ max_output_tokens_raw = resolved_inputs.get("max_out_tokens")
1280
+ max_output_tokens_override = _coerce_max_output_tokens(max_output_tokens_raw)
1281
+ if max_output_tokens_override is None:
1282
+ max_output_tokens_override = _coerce_max_output_tokens(agent_config.get("max_output_tokens"))
1283
+
1284
+ # Sampling controls can come from pins or from config.
1285
+ if isinstance(resolved_inputs, dict) and "temperature" in resolved_inputs:
1286
+ temperature_raw = resolved_inputs.get("temperature")
1287
+ else:
1288
+ temperature_raw = agent_config.get("temperature", 0.7)
1289
+ temperature = _coerce_temperature(temperature_raw, default=0.7)
1290
+
1291
+ if isinstance(resolved_inputs, dict) and "seed" in resolved_inputs:
1292
+ seed_raw = resolved_inputs.get("seed")
1293
+ else:
1294
+ seed_raw = agent_config.get("seed", -1)
1295
+ seed = _coerce_seed(seed_raw, default=-1)
1296
+
1297
+ # Tools selection:
1298
+ # - If the resolved inputs explicitly include `tools` (e.g. tools pin connected),
1299
+ # respect it even if it's an empty list (disables tools).
1300
+ # - Otherwise fall back to the Agent node's configuration.
1301
+ if isinstance(resolved_inputs, dict) and "tools" in resolved_inputs:
1302
+ tools_raw = resolved_inputs.get("tools")
1303
+ else:
1304
+ tools_raw = agent_config.get("tools")
1305
+ allowed_tools: list[str] = []
1306
+ if isinstance(tools_raw, list):
1307
+ for t in tools_raw:
1308
+ if isinstance(t, str) and t.strip():
1309
+ allowed_tools.append(t.strip())
1310
+ elif isinstance(tools_raw, tuple):
1311
+ for t in tools_raw:
1312
+ if isinstance(t, str) and t.strip():
1313
+ allowed_tools.append(t.strip())
1314
+ elif isinstance(tools_raw, str) and tools_raw.strip():
1315
+ allowed_tools.append(tools_raw.strip())
1316
+
1317
+ # De-dup while preserving order.
1318
+ seen_tools: set[str] = set()
1319
+ allowed_tools = [t for t in allowed_tools if not (t in seen_tools or seen_tools.add(t))]
1320
+
1321
+ workflow_id_raw = agent_config.get("_react_workflow_id")
1322
+ react_workflow_id = (
1323
+ workflow_id_raw.strip()
1324
+ if isinstance(workflow_id_raw, str) and workflow_id_raw.strip()
1325
+ else visual_react_workflow_id(flow_id=flow.flow_id, node_id=node_id)
1326
+ )
1327
+
1328
+ if phase == "init":
1329
+ if not provider or not model:
1330
+ run.vars["_flow_error"] = "Agent node missing provider/model configuration"
1331
+ run.vars["_flow_error_node"] = node_id
1332
+ out = {
1333
+ "response": "Agent configuration error: missing provider/model",
1334
+ "task": task,
1335
+ "context": context,
1336
+ "success": False,
1337
+ "error": "missing provider/model",
1338
+ "provider": provider or "unknown",
1339
+ "model": model or "unknown",
1340
+ }
1341
+ _set_nested(run.vars, f"_temp.effects.{node_id}", out)
1342
+ bucket["phase"] = "done"
1343
+
1344
+ scratchpad = {"node_id": node_id, "steps": [], "tool_calls": [], "tool_results": []}
1345
+ meta: Dict[str, Any] = {
1346
+ "schema": "abstractcode.agent.v1.meta",
1347
+ "version": 1,
1348
+ "provider": provider or "unknown",
1349
+ "model": model or "unknown",
1350
+ "tool_calls": 0,
1351
+ "tool_results": 0,
1352
+ }
1353
+ flow._node_outputs[node_id] = {
1354
+ "response": str(out.get("response") or ""),
1355
+ "success": False,
1356
+ "meta": meta,
1357
+ "scratchpad": scratchpad,
1358
+ # Backward-compat / convenience:
1359
+ "tool_calls": [],
1360
+ "tool_results": [],
1361
+ }
1362
+ run.vars["_last_output"] = dict(flow._node_outputs.get(node_id) or {})
1363
+ if next_node:
1364
+ return StepPlan(node_id=node_id, next_node=next_node)
1365
+ return StepPlan(
1366
+ node_id=node_id,
1367
+ complete_output={
1368
+ "response": str(out.get("response") or ""),
1369
+ "success": False,
1370
+ "meta": meta,
1371
+ "scratchpad": scratchpad,
1372
+ },
1373
+ )
1374
+
1375
+ # ------------------------------------------------------------
1376
+ # 467: Memory-source access pins (pre-agent recall)
1377
+ # ------------------------------------------------------------
1378
+ #
1379
+ # Before we start the durable Agent subworkflow, optionally run runtime-owned
1380
+ # MEMORY_* effects to recall from spans and/or the KG. Results are stored under
1381
+ # `_temp.memory_sources.{node_id}.*` and injected into the child run context.
1382
+ ms_bucket = _get_memory_sources_bucket(run)
1383
+ ms_meta = ms_bucket.get("_meta") if isinstance(ms_bucket.get("_meta"), dict) else {}
1384
+ if not isinstance(ms_meta, dict):
1385
+ ms_meta = {}
1386
+ ms_bucket["_meta"] = ms_meta
1387
+
1388
+ def _pin_bool_opt(name: str) -> Optional[bool]:
1389
+ if not isinstance(resolved_inputs, dict):
1390
+ return None
1391
+ if name not in resolved_inputs:
1392
+ return None
1393
+ return bool(resolved_inputs.get(name))
1394
+
1395
+ use_span_memory = _pin_bool_opt("use_span_memory")
1396
+ use_kg_memory = _pin_bool_opt("use_kg_memory")
1397
+ use_semantic_search = _pin_bool_opt("use_semantic_search")
1398
+ use_session_attachments = _pin_bool_opt("use_session_attachments")
1399
+
1400
+ memory_scope = (
1401
+ str(resolved_inputs.get("memory_scope") or "run").strip().lower()
1402
+ if isinstance(resolved_inputs, dict)
1403
+ else "run"
1404
+ ) or "run"
1405
+ recall_level = (
1406
+ str(resolved_inputs.get("recall_level") or "").strip().lower()
1407
+ if isinstance(resolved_inputs, dict)
1408
+ else ""
1409
+ )
1410
+
1411
+ mem_query = (
1412
+ str(resolved_inputs.get("memory_query") or "").strip()
1413
+ if isinstance(resolved_inputs, dict)
1414
+ else ""
1415
+ )
1416
+ query_text = mem_query or str(task or "").strip()
1417
+
1418
+ base_key = f"_temp.memory_sources.{node_id}"
1419
+
1420
+ def _coerce_int(value: Any) -> Optional[int]:
1421
+ if value is None or isinstance(value, bool):
1422
+ return None
1423
+ try:
1424
+ return int(float(value))
1425
+ except Exception:
1426
+ return None
1427
+
1428
+ def _coerce_float(value: Any) -> Optional[float]:
1429
+ if value is None or isinstance(value, bool):
1430
+ return None
1431
+ try:
1432
+ v = float(value)
1433
+ except Exception:
1434
+ return None
1435
+ return v if (v == v) else None
1436
+
1437
+ if use_semantic_search is True:
1438
+ warnings = ms_meta.get("warnings")
1439
+ if not isinstance(warnings, list):
1440
+ warnings = []
1441
+ ms_meta["warnings"] = warnings
1442
+ msg = "use_semantic_search is not implemented yet (planned: 464); ignoring."
1443
+ if msg not in warnings:
1444
+ warnings.append(msg)
1445
+
1446
+ # Span recall (metadata)
1447
+ if use_span_memory is True and "span_query" not in ms_bucket and query_text:
1448
+ mq_payload: Dict[str, Any] = {"query": query_text, "scope": memory_scope, "return": "meta"}
1449
+ if recall_level:
1450
+ mq_payload["recall_level"] = recall_level
1451
+ return StepPlan(
1452
+ node_id=node_id,
1453
+ effect=Effect(
1454
+ type=EffectType.MEMORY_QUERY,
1455
+ payload=mq_payload,
1456
+ result_key=f"{base_key}.span_query",
1457
+ ),
1458
+ next_node=node_id,
1459
+ )
1460
+
1461
+ # Span rehydrate into parent context (so we can forward a subset into the child).
1462
+ if use_span_memory is True and "span_rehydrate" not in ms_bucket:
1463
+ span_ids: list[str] = []
1464
+ span_query = ms_bucket.get("span_query")
1465
+ if isinstance(span_query, dict):
1466
+ results = span_query.get("results")
1467
+ if isinstance(results, list) and results:
1468
+ first = results[0] if isinstance(results[0], dict) else None
1469
+ meta2 = first.get("meta") if isinstance(first, dict) else None
1470
+ if isinstance(meta2, dict):
1471
+ raw_ids = meta2.get("span_ids")
1472
+ if isinstance(raw_ids, list):
1473
+ span_ids = [str(x).strip() for x in raw_ids if str(x).strip()]
1474
+ ms_meta["span_ids"] = span_ids
1475
+
1476
+ mr_payload: Dict[str, Any] = {"span_ids": span_ids, "placement": "after_summary"}
1477
+ if recall_level:
1478
+ mr_payload["recall_level"] = recall_level
1479
+ max_span_messages = (
1480
+ _coerce_int(resolved_inputs.get("max_span_messages"))
1481
+ if isinstance(resolved_inputs, dict)
1482
+ else None
1483
+ )
1484
+ if max_span_messages is None and not recall_level:
1485
+ # Safe default (explicit warning): without recall_level, MEMORY_REHYDRATE would
1486
+ # otherwise insert all messages from selected spans.
1487
+ max_span_messages = 80
1488
+ warnings = ms_meta.get("warnings")
1489
+ if not isinstance(warnings, list):
1490
+ warnings = []
1491
+ ms_meta["warnings"] = warnings
1492
+ msg = "use_span_memory enabled without recall_level/max_span_messages; defaulting max_span_messages=80."
1493
+ if msg not in warnings:
1494
+ warnings.append(msg)
1495
+ if isinstance(max_span_messages, int):
1496
+ mr_payload["max_messages"] = max_span_messages
1497
+
1498
+ return StepPlan(
1499
+ node_id=node_id,
1500
+ effect=Effect(
1501
+ type=EffectType.MEMORY_REHYDRATE,
1502
+ payload=mr_payload,
1503
+ result_key=f"{base_key}.span_rehydrate",
1504
+ ),
1505
+ next_node=node_id,
1506
+ )
1507
+
1508
+ # KG recall (packetized active memory)
1509
+ if use_kg_memory is True and "kg_query" not in ms_bucket and query_text:
1510
+ kg_payload: Dict[str, Any] = {"query_text": query_text, "scope": memory_scope}
1511
+ if recall_level:
1512
+ kg_payload["recall_level"] = recall_level
1513
+
1514
+ kg_min_score = (
1515
+ _coerce_float(resolved_inputs.get("kg_min_score")) if isinstance(resolved_inputs, dict) else None
1516
+ )
1517
+ if isinstance(kg_min_score, float):
1518
+ kg_payload["min_score"] = kg_min_score
1519
+ kg_limit = _coerce_int(resolved_inputs.get("kg_limit")) if isinstance(resolved_inputs, dict) else None
1520
+ if isinstance(kg_limit, int):
1521
+ kg_payload["limit"] = kg_limit
1522
+ kg_budget = (
1523
+ _coerce_int(resolved_inputs.get("kg_max_input_tokens"))
1524
+ if isinstance(resolved_inputs, dict)
1525
+ else None
1526
+ )
1527
+ if kg_budget is None and not recall_level:
1528
+ # Safe default (explicit warning): ensure we actually get `active_memory_text`
1529
+ # even when recall_level is not set.
1530
+ kg_budget = 1200
1531
+ warnings = ms_meta.get("warnings")
1532
+ if not isinstance(warnings, list):
1533
+ warnings = []
1534
+ ms_meta["warnings"] = warnings
1535
+ msg = "use_kg_memory enabled without recall_level/kg_max_input_tokens; defaulting kg_max_input_tokens=1200."
1536
+ if msg not in warnings:
1537
+ warnings.append(msg)
1538
+ if isinstance(kg_budget, int):
1539
+ kg_payload["max_input_tokens"] = kg_budget
1540
+ if model:
1541
+ kg_payload["model"] = model
1542
+
1543
+ return StepPlan(
1544
+ node_id=node_id,
1545
+ effect=Effect(
1546
+ type=EffectType.MEMORY_KG_QUERY,
1547
+ payload=kg_payload,
1548
+ result_key=f"{base_key}.kg_query",
1549
+ ),
1550
+ next_node=node_id,
1551
+ )
1552
+
1553
+ bucket["phase"] = "subworkflow"
1554
+ flow._node_outputs[node_id] = {
1555
+ "status": "running",
1556
+ "task": task,
1557
+ "context": context,
1558
+ "response": "",
1559
+ "success": None,
1560
+ "meta": None,
1561
+ "scratchpad": None,
1562
+ }
1563
+
1564
+ # Memory injection into the Agent subworkflow context (467).
1565
+ extra_messages: list[Dict[str, Any]] = []
1566
+ if use_kg_memory is True:
1567
+ kg_query = ms_bucket.get("kg_query")
1568
+ active_text = kg_query.get("active_memory_text") if isinstance(kg_query, dict) else None
1569
+ if isinstance(active_text, str) and active_text.strip():
1570
+ extra_messages.append(
1571
+ {
1572
+ "role": "system",
1573
+ "content": active_text.strip(),
1574
+ "metadata": {
1575
+ "kind": "kg_active_memory",
1576
+ "message_id": f"kg_active_memory:{node_id}",
1577
+ },
1578
+ }
1579
+ )
1580
+
1581
+ if use_span_memory is True:
1582
+ span_ids_any = ms_meta.get("span_ids")
1583
+ span_ids_set = (
1584
+ {str(x).strip() for x in span_ids_any if isinstance(x, str) and str(x).strip()}
1585
+ if isinstance(span_ids_any, list)
1586
+ else set()
1587
+ )
1588
+ ctx_ns = run.vars.get("context") if isinstance(run.vars, dict) else None
1589
+ raw_msgs = ctx_ns.get("messages") if isinstance(ctx_ns, dict) else None
1590
+ if isinstance(raw_msgs, list) and span_ids_set:
1591
+ for m_any in raw_msgs:
1592
+ m = m_any if isinstance(m_any, dict) else None
1593
+ if m is None:
1594
+ continue
1595
+ meta_m = m.get("metadata")
1596
+ if not isinstance(meta_m, dict) or meta_m.get("rehydrated") is not True:
1597
+ continue
1598
+ src = meta_m.get("source_artifact_id")
1599
+ src_id = str(src).strip() if isinstance(src, str) else ""
1600
+ if src_id and src_id in span_ids_set:
1601
+ extra_messages.append(dict(m))
1602
+
1603
+ return StepPlan(
1604
+ node_id=node_id,
1605
+ effect=Effect(
1606
+ type=EffectType.START_SUBWORKFLOW,
1607
+ payload={
1608
+ "workflow_id": react_workflow_id,
1609
+ "vars": _build_sub_vars(
1610
+ run,
1611
+ task=task,
1612
+ context=context,
1613
+ provider=provider,
1614
+ model=model,
1615
+ system_prompt=system_prompt,
1616
+ allowed_tools=allowed_tools,
1617
+ temperature=temperature,
1618
+ seed=seed,
1619
+ include_context=include_context,
1620
+ max_iterations=max_iterations_override,
1621
+ max_input_tokens=max_input_tokens_override,
1622
+ max_output_tokens=max_output_tokens_override,
1623
+ extra_messages=extra_messages or None,
1624
+ include_session_attachments_index=use_session_attachments,
1625
+ ),
1626
+ # Run Agent as a durable async subworkflow so the host can:
1627
+ # - tick the child incrementally (real-time observability of each effect)
1628
+ # - resume the parent once the child completes (async+wait mode)
1629
+ "async": True,
1630
+ "wait": True,
1631
+ "include_traces": True,
1632
+ },
1633
+ result_key=f"_temp.agent.{node_id}.sub",
1634
+ ),
1635
+ next_node=node_id,
1636
+ )
1637
+
1638
+ if phase == "subworkflow":
1639
+ sub = bucket.get("sub")
1640
+ if sub is None:
1641
+ temp = _ensure_temp_dict(run)
1642
+ agent_ns = temp.get("agent")
1643
+ if isinstance(agent_ns, dict):
1644
+ node_bucket = agent_ns.get(node_id)
1645
+ if isinstance(node_bucket, dict):
1646
+ sub = node_bucket.get("sub")
1647
+
1648
+ if not isinstance(sub, dict):
1649
+ return StepPlan(node_id=node_id, next_node=node_id)
1650
+
1651
+ sub_run_id = sub.get("sub_run_id") if isinstance(sub.get("sub_run_id"), str) else None
1652
+ output = sub.get("output")
1653
+ output_dict = output if isinstance(output, dict) else {}
1654
+ answer = str(output_dict.get("answer") or "")
1655
+ iterations = output_dict.get("iterations")
1656
+
1657
+ node_traces = sub.get("node_traces")
1658
+ raw_messages = output_dict.get("messages")
1659
+
1660
+ def _normalize_messages(raw: Any) -> list[Dict[str, Any]]:
1661
+ if isinstance(raw, list):
1662
+ out: list[Dict[str, Any]] = []
1663
+ for m in raw:
1664
+ if isinstance(m, dict):
1665
+ out.append(dict(m))
1666
+ else:
1667
+ out.append({"role": "assistant", "content": str(m)})
1668
+ return out
1669
+ msgs: list[Dict[str, Any]] = []
1670
+ if str(task or "").strip():
1671
+ msgs.append({"role": "user", "content": str(task)})
1672
+ if answer.strip():
1673
+ msgs.append({"role": "assistant", "content": answer})
1674
+ return msgs
1675
+
1676
+ messages_out = _normalize_messages(raw_messages)
1677
+ context_extra: Dict[str, Any] = {}
1678
+ if isinstance(context, dict) and context:
1679
+ for k, v in context.items():
1680
+ if k in ("task", "messages"):
1681
+ continue
1682
+ context_extra[str(k)] = v
1683
+ scratchpad = {
1684
+ "sub_run_id": sub_run_id,
1685
+ "workflow_id": react_workflow_id,
1686
+ "task": str(task or ""),
1687
+ "messages": messages_out,
1688
+ "node_traces": node_traces if isinstance(node_traces, dict) else {},
1689
+ "steps": _flatten_node_traces(node_traces),
1690
+ }
1691
+ if context_extra:
1692
+ scratchpad["context_extra"] = context_extra
1693
+ bucket["scratchpad"] = scratchpad
1694
+ bucket["messages"] = messages_out
1695
+ tc, tr = _extract_tool_activity_from_steps(scratchpad.get("steps"))
1696
+ bucket["answer"] = answer
1697
+ bucket["success"] = True
1698
+ bucket["provider"] = provider
1699
+ bucket["model"] = model
1700
+ bucket["output_mode"] = "unstructured"
1701
+ if iterations is not None:
1702
+ bucket["iterations"] = iterations
1703
+ if sub_run_id:
1704
+ bucket["sub_run_id"] = sub_run_id
1705
+
1706
+ # Agent `result` is intentionally minimal in unstructured mode.
1707
+ # - The user-facing answer string is available on the `response` output pin.
1708
+ # - Execution metadata belongs in `meta`.
1709
+ # - The agent-internal transcript lives in `scratchpad.messages`.
1710
+ result_obj: Dict[str, Any] = {"success": True}
1711
+
1712
+ scratchpad_out = dict(scratchpad)
1713
+ scratchpad_out["tool_calls"] = tc
1714
+ scratchpad_out["tool_results"] = tr
1715
+
1716
+ meta: Dict[str, Any] = {
1717
+ "schema": "abstractcode.agent.v1.meta",
1718
+ "version": 1,
1719
+ "output_mode": "unstructured",
1720
+ "provider": provider,
1721
+ "model": model,
1722
+ "tool_calls": len(tc),
1723
+ "tool_results": len(tr),
1724
+ }
1725
+ if sub_run_id:
1726
+ meta["sub_run_id"] = sub_run_id
1727
+ if iterations is not None:
1728
+ meta["iterations"] = iterations
1729
+
1730
+ # When "Include/Use context" is enabled on the Agent node, persist the turn
1731
+ # into the parent run's active context so subsequent Agent/Subflow/LLM_CALL
1732
+ # nodes can see prior interactions (critical for recursive subflows).
1733
+ try:
1734
+ if include_context:
1735
+ already = bucket.get("context_appended_sub_run_id")
1736
+ if already != sub_run_id:
1737
+ ctx_ns = run.vars.get("context")
1738
+ if not isinstance(ctx_ns, dict):
1739
+ ctx_ns = {}
1740
+ run.vars["context"] = ctx_ns
1741
+ msgs = ctx_ns.get("messages")
1742
+ if not isinstance(msgs, list):
1743
+ msgs = []
1744
+ ctx_ns["messages"] = msgs
1745
+
1746
+ def _has_message_id(message_id: str) -> bool:
1747
+ for m in msgs:
1748
+ if not isinstance(m, dict):
1749
+ continue
1750
+ meta = m.get("metadata")
1751
+ if not isinstance(meta, dict):
1752
+ continue
1753
+ if meta.get("message_id") == message_id:
1754
+ return True
1755
+ return False
1756
+
1757
+ def _append(role: str, content: str, *, suffix: str) -> None:
1758
+ text = str(content or "").strip()
1759
+ if not text:
1760
+ return
1761
+ mid = f"agent:{sub_run_id or run.run_id}:{node_id}:{suffix}"
1762
+ if _has_message_id(mid):
1763
+ return
1764
+ meta: Dict[str, Any] = {"kind": "agent_turn", "node_id": node_id, "message_id": mid}
1765
+ if sub_run_id:
1766
+ meta["sub_run_id"] = sub_run_id
1767
+ msgs.append({"role": role, "content": text, "metadata": meta})
1768
+
1769
+ def _truncate(text: str, *, max_chars: int) -> str:
1770
+ if max_chars <= 0:
1771
+ return text
1772
+ if len(text) <= max_chars:
1773
+ return text
1774
+ suffix = f"\n… (truncated, {len(text):,} chars total)"
1775
+ keep = max_chars - len(suffix)
1776
+ if keep < 200:
1777
+ keep = max_chars
1778
+ suffix = ""
1779
+ return text[:keep].rstrip() + suffix
1780
+
1781
+ def _append_tool_observations() -> None:
1782
+ # Persist a compact transcript of tool results executed by this Agent sub-run.
1783
+ # This is critical for outer loops (e.g. RALPH) that re-invoke the agent and
1784
+ # need evidence continuity to avoid repeating the same tool calls forever.
1785
+ if not isinstance(tr, list) or not tr:
1786
+ return
1787
+
1788
+ # Optional: attach arguments when available (from tool_calls).
1789
+ call_by_id: Dict[str, Dict[str, Any]] = {}
1790
+ if isinstance(tc, list):
1791
+ for c in tc:
1792
+ if not isinstance(c, dict):
1793
+ continue
1794
+ cid = str(c.get("call_id") or "").strip()
1795
+ if cid:
1796
+ call_by_id[cid] = dict(c)
1797
+
1798
+ max_results = 20
1799
+ for i, r in enumerate(tr[:max_results]):
1800
+ if not isinstance(r, dict):
1801
+ continue
1802
+ name = str(r.get("name") or "tool").strip() or "tool"
1803
+ call_id = str(r.get("call_id") or "").strip()
1804
+ success = bool(r.get("success"))
1805
+
1806
+ args = None
1807
+ if call_id and call_id in call_by_id:
1808
+ raw_args = call_by_id[call_id].get("arguments")
1809
+ if isinstance(raw_args, dict) and raw_args:
1810
+ args = raw_args
1811
+
1812
+ output = r.get("output")
1813
+ error = r.get("error")
1814
+
1815
+ try:
1816
+ rendered_out = json.dumps(output, ensure_ascii=False, indent=2) if isinstance(output, (dict, list)) else str(output or "")
1817
+ except Exception:
1818
+ rendered_out = "" if output is None else str(output)
1819
+ rendered_out = rendered_out.strip()
1820
+
1821
+ details = str(error or rendered_out).strip() if not success else rendered_out
1822
+ if args is not None:
1823
+ try:
1824
+ args_txt = json.dumps(args, ensure_ascii=False, sort_keys=True)
1825
+ except Exception:
1826
+ args_txt = str(args)
1827
+ details = f"args={args_txt}\n{details}".strip()
1828
+
1829
+ details = _truncate(details, max_chars=2000)
1830
+ content = f"[{name}]: {details}" if success else f"[{name}]: Error: {details}"
1831
+
1832
+ # Store as an assistant message for broad provider compatibility.
1833
+ # (Some OpenAI-compatible servers are strict about role='tool' message shape.)
1834
+ suffix = f"tool:{call_id or i}"
1835
+ mid = f"agent:{sub_run_id or run.run_id}:{node_id}:{suffix}"
1836
+ if _has_message_id(mid):
1837
+ continue
1838
+ meta: Dict[str, Any] = {
1839
+ "kind": "tool_observation",
1840
+ "node_id": node_id,
1841
+ "message_id": mid,
1842
+ "tool_name": name,
1843
+ "success": success,
1844
+ }
1845
+ if call_id:
1846
+ meta["call_id"] = call_id
1847
+ if sub_run_id:
1848
+ meta["sub_run_id"] = sub_run_id
1849
+ msgs.append({"role": "assistant", "content": content, "metadata": meta})
1850
+
1851
+ if len(tr) > max_results:
1852
+ omitted = len(tr) - max_results
1853
+ mid = f"agent:{sub_run_id or run.run_id}:{node_id}:tool:omitted"
1854
+ if not _has_message_id(mid):
1855
+ msgs.append(
1856
+ {
1857
+ "role": "assistant",
1858
+ "content": f"[tools]: (omitted {omitted} additional tool results for brevity; see sub_run_id={sub_run_id})",
1859
+ "metadata": {
1860
+ "kind": "tool_observation_summary",
1861
+ "node_id": node_id,
1862
+ "message_id": mid,
1863
+ "sub_run_id": sub_run_id,
1864
+ },
1865
+ }
1866
+ )
1867
+
1868
+ _append("user", task, suffix="task")
1869
+ _append_tool_observations()
1870
+ _append("assistant", answer, suffix="answer")
1871
+ bucket["context_appended_sub_run_id"] = sub_run_id
1872
+ except Exception:
1873
+ # Context persistence must never break Agent execution.
1874
+ pass
1875
+
1876
+ if schema_enabled and schema:
1877
+ bucket["phase"] = "structured"
1878
+ messages = [
1879
+ {
1880
+ "role": "user",
1881
+ "content": (
1882
+ "Convert the Agent answer into a JSON object matching the required schema. Return JSON only.\n\n"
1883
+ f"Task:\n{task}\n\n"
1884
+ f"Answer:\n{answer}"
1885
+ ),
1886
+ }
1887
+ ]
1888
+ return StepPlan(
1889
+ node_id=node_id,
1890
+ effect=Effect(
1891
+ type=EffectType.LLM_CALL,
1892
+ payload={
1893
+ "messages": messages,
1894
+ "system_prompt": system_prompt,
1895
+ "provider": provider,
1896
+ "model": model,
1897
+ "response_schema": schema,
1898
+ "response_schema_name": f"Agent_{node_id}",
1899
+ "params": {"temperature": temperature, "seed": seed} if seed >= 0 else {"temperature": temperature},
1900
+ },
1901
+ result_key=f"_temp.agent.{node_id}.structured",
1902
+ ),
1903
+ next_node=node_id,
1904
+ )
1905
+
1906
+ _set_nested(run.vars, f"_temp.effects.{node_id}", result_obj)
1907
+ bucket["phase"] = "done"
1908
+ flow._node_outputs[node_id] = {
1909
+ "response": answer,
1910
+ "success": True,
1911
+ "meta": meta,
1912
+ "scratchpad": scratchpad_out,
1913
+ # Backward-compat / convenience:
1914
+ "tool_calls": tc,
1915
+ "tool_results": tr,
1916
+ }
1917
+ run.vars["_last_output"] = dict(flow._node_outputs.get(node_id) or {})
1918
+ if next_node:
1919
+ return StepPlan(node_id=node_id, next_node=next_node)
1920
+ return StepPlan(
1921
+ node_id=node_id,
1922
+ complete_output={
1923
+ "response": answer,
1924
+ "success": True,
1925
+ "meta": meta,
1926
+ "scratchpad": scratchpad_out,
1927
+ },
1928
+ )
1929
+
1930
+ if phase == "structured":
1931
+ structured_resp = bucket.get("structured")
1932
+ if structured_resp is None:
1933
+ temp = _ensure_temp_dict(run)
1934
+ agent_bucket = temp.get("agent", {}).get(node_id, {}) if isinstance(temp.get("agent"), dict) else {}
1935
+ structured_resp = agent_bucket.get("structured") if isinstance(agent_bucket, dict) else None
1936
+
1937
+ data = structured_resp.get("data") if isinstance(structured_resp, dict) else None
1938
+ if data is None and isinstance(structured_resp, dict):
1939
+ content = structured_resp.get("content")
1940
+ if isinstance(content, str) and content.strip():
1941
+ try:
1942
+ data = json.loads(content)
1943
+ except Exception:
1944
+ data = None
1945
+
1946
+ if not isinstance(data, dict):
1947
+ data = {}
1948
+
1949
+ _set_nested(run.vars, f"_temp.effects.{node_id}", data)
1950
+ bucket["phase"] = "done"
1951
+ scratchpad = bucket.get("scratchpad")
1952
+ if not isinstance(scratchpad, dict):
1953
+ scratchpad = {"node_id": node_id, "steps": []}
1954
+ tc, tr = _extract_tool_activity_from_steps(scratchpad.get("steps"))
1955
+ answer = bucket.get("answer") if isinstance(bucket.get("answer"), str) else ""
1956
+ scratchpad_out = dict(scratchpad)
1957
+ if "messages" not in scratchpad_out:
1958
+ msgs = bucket.get("messages")
1959
+ if isinstance(msgs, list):
1960
+ scratchpad_out["messages"] = list(msgs)
1961
+ scratchpad_out["tool_calls"] = tc
1962
+ scratchpad_out["tool_results"] = tr
1963
+
1964
+ meta: Dict[str, Any] = {
1965
+ "schema": "abstractcode.agent.v1.meta",
1966
+ "version": 1,
1967
+ "output_mode": "structured",
1968
+ "provider": provider,
1969
+ "model": model,
1970
+ "tool_calls": len(tc),
1971
+ "tool_results": len(tr),
1972
+ }
1973
+ sr = scratchpad_out.get("sub_run_id")
1974
+ if isinstance(sr, str) and sr.strip():
1975
+ meta["sub_run_id"] = sr.strip()
1976
+
1977
+ try:
1978
+ response_text = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
1979
+ except Exception:
1980
+ response_text = str(data)
1981
+
1982
+ flow._node_outputs[node_id] = {
1983
+ "response": response_text,
1984
+ "success": True,
1985
+ "meta": meta,
1986
+ "scratchpad": scratchpad_out,
1987
+ # Backward-compat / convenience:
1988
+ "tool_calls": tc,
1989
+ "tool_results": tr,
1990
+ }
1991
+ bucket["output_mode"] = "structured"
1992
+ run.vars["_last_output"] = dict(flow._node_outputs.get(node_id) or {})
1993
+ if next_node:
1994
+ return StepPlan(node_id=node_id, next_node=next_node)
1995
+ return StepPlan(
1996
+ node_id=node_id,
1997
+ complete_output={
1998
+ "response": response_text,
1999
+ "success": True,
2000
+ "meta": meta,
2001
+ "scratchpad": scratchpad_out,
2002
+ },
2003
+ )
2004
+
2005
+ if next_node:
2006
+ return StepPlan(node_id=node_id, next_node=next_node)
2007
+ last = run.vars.get("_last_output")
2008
+ if isinstance(last, dict):
2009
+ return StepPlan(
2010
+ node_id=node_id,
2011
+ complete_output={
2012
+ "response": str(last.get("response") or ""),
2013
+ "success": bool(last.get("success")) if "success" in last else True,
2014
+ "meta": last.get("meta") if isinstance(last.get("meta"), dict) else {},
2015
+ "scratchpad": last.get("scratchpad"),
2016
+ },
2017
+ )
2018
+ return StepPlan(
2019
+ node_id=node_id,
2020
+ complete_output={"response": str(last) if last is not None else "", "success": True, "meta": {}, "scratchpad": None},
2021
+ )
2022
+
2023
+ return handler
2024
+
2025
+
2026
+ def _create_visual_function_handler(
2027
+ node_id: str,
2028
+ func: Callable,
2029
+ next_node: Optional[str],
2030
+ input_key: Optional[str],
2031
+ output_key: Optional[str],
2032
+ flow: Flow,
2033
+ branch_map: Optional[Dict[str, str]] = None,
2034
+ ) -> Callable:
2035
+ """Create a handler for visual flow function nodes.
2036
+
2037
+ Visual flows use data edges for passing values between nodes. This handler:
2038
+ 1. Syncs effect results from run.vars to flow._node_outputs
2039
+ 2. Calls the wrapped function with proper input
2040
+ 3. Updates _last_output for downstream nodes
2041
+ """
2042
+ from abstractruntime.core.models import StepPlan
2043
+
2044
+ def handler(run: Any, ctx: Any) -> "StepPlan":
2045
+ """Execute the function and transition to next node."""
2046
+ def _to_complete_output(value: Any) -> Dict[str, Any]:
2047
+ if isinstance(value, dict):
2048
+ out = dict(value)
2049
+ out.setdefault("success", True)
2050
+ return out
2051
+ return {"response": value, "success": True}
2052
+
2053
+ # Sync effect results from run.vars to flow._node_outputs
2054
+ # This allows data edges from effect nodes to resolve correctly
2055
+ if hasattr(flow, '_node_outputs') and hasattr(flow, '_data_edge_map'):
2056
+ _sync_effect_results_to_node_outputs(run, flow)
2057
+
2058
+ # Get input from _last_output (visual flow pattern)
2059
+ # or from input_key if specified
2060
+ if input_key:
2061
+ input_data = run.vars.get(input_key)
2062
+ else:
2063
+ input_data = run.vars.get("_last_output") if "_last_output" in run.vars else run.vars
2064
+
2065
+ # Execute function (which is the data-aware wrapped handler)
2066
+ try:
2067
+ result = func(input_data)
2068
+ except Exception as e:
2069
+ run.vars["_flow_error"] = str(e)
2070
+ run.vars["_flow_error_node"] = node_id
2071
+ return StepPlan(
2072
+ node_id=node_id,
2073
+ complete_output={"error": str(e), "success": False, "node": node_id},
2074
+ )
2075
+
2076
+ # Store result in _last_output for downstream nodes
2077
+ run.vars["_last_output"] = result
2078
+
2079
+ # Persist per-node outputs for data-edge rehydration across pause/resume.
2080
+ #
2081
+ # Visual data edges read from `flow._node_outputs`, which is an in-memory
2082
+ # cache. When a run pauses (ASK_USER / TOOL passthrough) and is resumed
2083
+ # in a different process, we must be able to reconstruct upstream node
2084
+ # outputs from persisted `RunState.vars`.
2085
+ temp = run.vars.get("_temp")
2086
+ if not isinstance(temp, dict):
2087
+ temp = {}
2088
+ run.vars["_temp"] = temp
2089
+ persisted_outputs = temp.get("node_outputs")
2090
+ if not isinstance(persisted_outputs, dict):
2091
+ persisted_outputs = {}
2092
+ temp["node_outputs"] = persisted_outputs
2093
+ persisted_outputs[node_id] = result
2094
+
2095
+ # Also store in output_key if specified
2096
+ if output_key:
2097
+ _set_nested(run.vars, output_key, result)
2098
+
2099
+ if branch_map is not None:
2100
+ branch = result.get("branch") if isinstance(result, dict) else None
2101
+ if not isinstance(branch, str) or not branch:
2102
+ run.vars["_flow_error"] = "Branching node did not return a string 'branch' value"
2103
+ run.vars["_flow_error_node"] = node_id
2104
+ return StepPlan(
2105
+ node_id=node_id,
2106
+ complete_output={
2107
+ "error": "Branching node did not return a string 'branch' value",
2108
+ "success": False,
2109
+ "node": node_id,
2110
+ },
2111
+ )
2112
+ chosen = branch_map.get(branch)
2113
+ if not isinstance(chosen, str) or not chosen:
2114
+ # Blueprint-style behavior: if the chosen execution pin isn't connected,
2115
+ # treat it as a clean completion instead of an error.
2116
+ if branch in {"true", "false", "default"} or branch.startswith("case:"):
2117
+ return StepPlan(
2118
+ node_id=node_id,
2119
+ complete_output=_to_complete_output(result),
2120
+ )
2121
+
2122
+ run.vars["_flow_error"] = f"Unknown branch '{branch}'"
2123
+ run.vars["_flow_error_node"] = node_id
2124
+ return StepPlan(
2125
+ node_id=node_id,
2126
+ complete_output={
2127
+ "error": f"Unknown branch '{branch}'",
2128
+ "success": False,
2129
+ "node": node_id,
2130
+ },
2131
+ )
2132
+ return StepPlan(node_id=node_id, next_node=chosen)
2133
+
2134
+ # Continue to next node or complete
2135
+ if next_node:
2136
+ return StepPlan(node_id=node_id, next_node=next_node)
2137
+ return StepPlan(
2138
+ node_id=node_id,
2139
+ complete_output=_to_complete_output(result),
2140
+ )
2141
+
2142
+ return handler
2143
+
2144
+
2145
+ def _sync_effect_results_to_node_outputs(run: Any, flow: Flow) -> None:
2146
+ """Sync effect results from run.vars to flow._node_outputs.
2147
+
2148
+ When an effect (like ask_user) completes, its result is stored in run.vars
2149
+ at the result_key. But visual flow data edges read from flow._node_outputs.
2150
+ This function syncs those results so data edges resolve correctly.
2151
+ """
2152
+ # Attach a live reference to the current run vars so pure nodes (e.g. Get Variable)
2153
+ # can read the up-to-date workflow state during data-edge resolution.
2154
+ try:
2155
+ if hasattr(run, "vars") and isinstance(run.vars, dict):
2156
+ flow._run_vars = run.vars # type: ignore[attr-defined]
2157
+ except Exception:
2158
+ pass
2159
+
2160
+ # IMPORTANT: `flow._node_outputs` is an in-memory cache used by the visual executor
2161
+ # to resolve data edges (including lazy "pure" nodes like compare/subtract/concat).
2162
+ #
2163
+ # A single compiled `Flow` instance can be executed by multiple `RunState`s in the
2164
+ # same process when using subworkflows (START_SUBWORKFLOW) — especially with
2165
+ # self-recursion or mutual recursion. In that situation, stale cached outputs from
2166
+ # another run can break correctness (e.g. a cached `compare` result keeps a base-case
2167
+ # from ever becoming false), leading to infinite recursion.
2168
+ #
2169
+ # We isolate caches per run_id by resetting the dict *in-place* when the active run
2170
+ # changes, then rehydrating persisted outputs from `run.vars["_temp"]`.
2171
+ node_outputs = flow._node_outputs
2172
+ try:
2173
+ rid = getattr(run, "run_id", None)
2174
+ if isinstance(rid, str) and rid:
2175
+ active = getattr(flow, "_active_run_id", None)
2176
+ if active != rid:
2177
+ base = getattr(flow, "_static_node_outputs", None)
2178
+ # Backward-compat: if the baseline wasn't set (older flows), infer it
2179
+ # on first use — at this point it should contain only literal nodes.
2180
+ if not isinstance(base, dict):
2181
+ base = dict(node_outputs) if isinstance(node_outputs, dict) else {}
2182
+ try:
2183
+ flow._static_node_outputs = dict(base) # type: ignore[attr-defined]
2184
+ except Exception:
2185
+ pass
2186
+ if isinstance(node_outputs, dict):
2187
+ node_outputs.clear()
2188
+ if isinstance(base, dict):
2189
+ node_outputs.update(base)
2190
+ try:
2191
+ flow._active_run_id = rid # type: ignore[attr-defined]
2192
+ except Exception:
2193
+ pass
2194
+ except Exception:
2195
+ # Best-effort; never let cache isolation break execution.
2196
+ pass
2197
+
2198
+ temp_data = run.vars.get("_temp", {})
2199
+ if not isinstance(temp_data, dict):
2200
+ return
2201
+
2202
+ # Restore persisted outputs for executed (non-effect) nodes.
2203
+ persisted = temp_data.get("node_outputs")
2204
+ if isinstance(persisted, dict):
2205
+ for nid, out in persisted.items():
2206
+ if isinstance(nid, str) and nid:
2207
+ node_outputs[nid] = out
2208
+
2209
+ effects = temp_data.get("effects")
2210
+ if not isinstance(effects, dict):
2211
+ effects = {}
2212
+
2213
+ # Determine which output pins are actually referenced by data edges.
2214
+ # This lets us keep legacy pins (e.g. `result`) working for older saved flows,
2215
+ # without emitting them for new flows that don't wire them.
2216
+ referenced_source_pins: set[tuple[str, str]] = set()
2217
+ try:
2218
+ data_edge_map = getattr(flow, "_data_edge_map", None)
2219
+ if isinstance(data_edge_map, dict):
2220
+ for target_pins in data_edge_map.values():
2221
+ if not isinstance(target_pins, dict):
2222
+ continue
2223
+ for src_any in target_pins.values():
2224
+ if not (isinstance(src_any, tuple) and len(src_any) == 2):
2225
+ continue
2226
+ src_node_id, src_pin_id = src_any
2227
+ if isinstance(src_node_id, str) and isinstance(src_pin_id, str):
2228
+ referenced_source_pins.add((src_node_id, src_pin_id))
2229
+ except Exception:
2230
+ referenced_source_pins = set()
2231
+
2232
+ def _get_span_id(raw: Any) -> Optional[str]:
2233
+ if not isinstance(raw, dict):
2234
+ return None
2235
+ results = raw.get("results")
2236
+ if not isinstance(results, list) or not results:
2237
+ return None
2238
+ first = results[0]
2239
+ if not isinstance(first, dict):
2240
+ return None
2241
+ meta = first.get("meta")
2242
+ if not isinstance(meta, dict):
2243
+ return None
2244
+ span_id = meta.get("span_id")
2245
+ if isinstance(span_id, str) and span_id.strip():
2246
+ return span_id.strip()
2247
+ return None
2248
+
2249
+ def _as_dict_list(value: Any) -> List[Dict[str, Any]]:
2250
+ """Normalize a value into a list of dicts (best-effort, JSON-safe)."""
2251
+ if value is None:
2252
+ return []
2253
+ if isinstance(value, dict):
2254
+ return [dict(value)]
2255
+ if isinstance(value, list):
2256
+ out: List[Dict[str, Any]] = []
2257
+ for x in value:
2258
+ if isinstance(x, dict):
2259
+ out.append(dict(x))
2260
+ return out
2261
+ return []
2262
+
2263
+ def _extract_agent_tool_activity(scratchpad: Any) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
2264
+ """Extract tool call requests and tool results from an agent scratchpad.
2265
+
2266
+ This is *post-run* ergonomics: it does not provide real-time streaming while the agent runs.
2267
+ For real-time tool observability, hosts should subscribe to the ledger and/or node_traces.
2268
+ """
2269
+ sp = scratchpad if isinstance(scratchpad, dict) else None
2270
+ if sp is None:
2271
+ return [], []
2272
+
2273
+ node_traces = sp.get("node_traces")
2274
+ if not isinstance(node_traces, dict):
2275
+ # Allow passing a single node trace directly.
2276
+ if isinstance(sp.get("steps"), list) and sp.get("node_id") is not None:
2277
+ node_traces = {str(sp.get("node_id")): sp}
2278
+ else:
2279
+ return [], []
2280
+
2281
+ # Flatten steps across nodes and sort by timestamp (ISO strings are lexicographically sortable).
2282
+ steps: List[Tuple[str, Dict[str, Any]]] = []
2283
+ for _nid, trace_any in node_traces.items():
2284
+ trace = trace_any if isinstance(trace_any, dict) else None
2285
+ if trace is None:
2286
+ continue
2287
+ entries = trace.get("steps")
2288
+ if not isinstance(entries, list):
2289
+ continue
2290
+ for entry_any in entries:
2291
+ entry = entry_any if isinstance(entry_any, dict) else None
2292
+ if entry is None:
2293
+ continue
2294
+ ts = entry.get("ts")
2295
+ ts_s = ts if isinstance(ts, str) else ""
2296
+ steps.append((ts_s, entry))
2297
+ steps.sort(key=lambda x: x[0])
2298
+
2299
+ tool_calls: List[Dict[str, Any]] = []
2300
+ tool_results: List[Dict[str, Any]] = []
2301
+ for _ts, entry in steps:
2302
+ effect = entry.get("effect")
2303
+ if not isinstance(effect, dict):
2304
+ continue
2305
+ if str(effect.get("type") or "") != "tool_calls":
2306
+ continue
2307
+ payload = effect.get("payload")
2308
+ payload_d = payload if isinstance(payload, dict) else {}
2309
+ tool_calls.extend(_as_dict_list(payload_d.get("tool_calls")))
2310
+
2311
+ result = entry.get("result")
2312
+ if not isinstance(result, dict):
2313
+ continue
2314
+ results = result.get("results")
2315
+ tool_results.extend(_as_dict_list(results))
2316
+
2317
+ return tool_calls, tool_results
2318
+
2319
+ for node_id, flow_node in flow.nodes.items():
2320
+ effect_type = flow_node.effect_type
2321
+ if not effect_type:
2322
+ continue
2323
+
2324
+ raw = effects.get(node_id)
2325
+ if raw is None:
2326
+ # Backward-compat for older runs/tests that stored by effect type.
2327
+ legacy_key = f"{effect_type}_response"
2328
+ raw = temp_data.get(legacy_key)
2329
+ if raw is None:
2330
+ continue
2331
+
2332
+ current = node_outputs.get(node_id)
2333
+ if not isinstance(current, dict):
2334
+ current = {}
2335
+ node_outputs[node_id] = current
2336
+ else:
2337
+ # If this node previously produced a pre-effect placeholder from the
2338
+ # visual executor (e.g. `_pending_effect`), remove it now that we have
2339
+ # the durable effect outcome in `run.vars["_temp"]["effects"]`.
2340
+ current.pop("_pending_effect", None)
2341
+
2342
+ mapped_value: Any = None
2343
+
2344
+ if effect_type == "ask_user":
2345
+ if isinstance(raw, dict):
2346
+ # raw is usually {"response": "..."} (resume payload)
2347
+ current.update(raw)
2348
+ mapped_value = raw.get("response")
2349
+ elif effect_type == "answer_user":
2350
+ if isinstance(raw, dict):
2351
+ current.update(raw)
2352
+ mapped_value = raw.get("message")
2353
+ else:
2354
+ current["message"] = raw
2355
+ mapped_value = raw
2356
+ elif effect_type == "llm_call":
2357
+ if isinstance(raw, dict):
2358
+ tool_calls = _as_dict_list(raw.get("tool_calls"))
2359
+ data = raw.get("data")
2360
+ response_text = ""
2361
+ if isinstance(data, (dict, list)):
2362
+ try:
2363
+ import json as _json
2364
+
2365
+ response_text = _json.dumps(data, ensure_ascii=False, separators=(",", ":"))
2366
+ except Exception:
2367
+ response_text = ""
2368
+ if not response_text:
2369
+ content = raw.get("content")
2370
+ response_text = str(content) if content is not None else ""
2371
+ current["response"] = response_text
2372
+ # Convenience pin: expose tool_calls directly, instead of forcing consumers
2373
+ # to drill into `result.tool_calls` via a Break Object node.
2374
+ current["tool_calls"] = tool_calls
2375
+ current["success"] = True
2376
+
2377
+ meta: Dict[str, Any] = {
2378
+ "schema": "abstractflow.llm_call.v1.meta",
2379
+ "version": 1,
2380
+ "output_mode": "structured" if isinstance(data, (dict, list)) else "unstructured",
2381
+ "tool_calls": len(tool_calls),
2382
+ }
2383
+ provider = raw.get("provider")
2384
+ if isinstance(provider, str) and provider.strip():
2385
+ meta["provider"] = provider.strip()
2386
+ model = raw.get("model")
2387
+ if isinstance(model, str) and model.strip():
2388
+ meta["model"] = model.strip()
2389
+ finish_reason = raw.get("finish_reason")
2390
+ if isinstance(finish_reason, str) and finish_reason.strip():
2391
+ meta["finish_reason"] = finish_reason.strip()
2392
+ usage = raw.get("usage")
2393
+ if isinstance(usage, dict):
2394
+ meta["usage"] = dict(usage)
2395
+ trace_id = raw.get("trace_id")
2396
+ if not (isinstance(trace_id, str) and trace_id.strip()):
2397
+ md = raw.get("metadata")
2398
+ if isinstance(md, dict):
2399
+ trace_id = md.get("trace_id")
2400
+ if isinstance(trace_id, str) and trace_id.strip():
2401
+ meta["trace"] = {"trace_id": trace_id.strip()}
2402
+ gen_time = raw.get("gen_time")
2403
+ if gen_time is not None and not isinstance(gen_time, bool):
2404
+ meta["gen_time"] = gen_time
2405
+ ttft_ms = raw.get("ttft_ms")
2406
+ if ttft_ms is not None and not isinstance(ttft_ms, bool):
2407
+ meta["ttft_ms"] = ttft_ms
2408
+
2409
+ current["meta"] = meta
2410
+ # Legacy pins for older saved flows.
2411
+ # Only populate if the flow actually references these handles.
2412
+ if (node_id, "result") in referenced_source_pins:
2413
+ current["result"] = raw
2414
+ if (node_id, "raw") in referenced_source_pins:
2415
+ current["raw"] = raw
2416
+ if (node_id, "gen_time") in referenced_source_pins:
2417
+ current["gen_time"] = gen_time
2418
+ if (node_id, "ttft_ms") in referenced_source_pins:
2419
+ current["ttft_ms"] = ttft_ms
2420
+ mapped_value = response_text
2421
+ elif effect_type == "tool_calls":
2422
+ # Effect outcome is produced by AbstractRuntime TOOL_CALLS handler:
2423
+ # - executed: {"mode":"executed","results":[{call_id,name,success,output,error}, ...]}
2424
+ # - passthrough/untrusted: {"mode": "...", "tool_calls": [...]}
2425
+ if isinstance(raw, dict):
2426
+ mode = raw.get("mode")
2427
+ results = raw.get("results")
2428
+ if not isinstance(results, list):
2429
+ results = []
2430
+ current["results"] = results
2431
+ # Only treat non-executed modes as failure (results are unavailable).
2432
+ if isinstance(mode, str) and mode.strip() and mode != "executed":
2433
+ current["success"] = False
2434
+ else:
2435
+ current["success"] = all(isinstance(r, dict) and r.get("success") is True for r in results)
2436
+ current["raw"] = raw
2437
+ mapped_value = current["results"]
2438
+ elif effect_type == "call_tool":
2439
+ # Convenience wrapper over TOOL_CALLS: expose a single tool's outcome as
2440
+ # (result, success) instead of an array of per-call results.
2441
+ if isinstance(raw, dict):
2442
+ mode = raw.get("mode")
2443
+ results = raw.get("results")
2444
+ if not isinstance(results, list):
2445
+ results = []
2446
+ first = results[0] if results else None
2447
+
2448
+ if isinstance(mode, str) and mode.strip() and mode != "executed":
2449
+ current["success"] = False
2450
+ current["result"] = f"Tool execution not completed (mode={mode})"
2451
+ elif isinstance(first, dict):
2452
+ ok = first.get("success") is True
2453
+ current["success"] = ok
2454
+ current["result"] = first.get("output") if ok else (first.get("error") or "Tool execution failed")
2455
+ else:
2456
+ current["success"] = False
2457
+ current["result"] = "Missing tool result"
2458
+
2459
+ current["raw"] = raw
2460
+ mapped_value = current["result"]
2461
+ elif effect_type == "agent":
2462
+ scratchpad = None
2463
+ answer: Optional[str] = None
2464
+ messages: Any = None
2465
+ bucket_success: Optional[bool] = None
2466
+ bucket_provider: Optional[str] = None
2467
+ bucket_model: Optional[str] = None
2468
+ bucket_iterations: Optional[int] = None
2469
+ bucket_sub_run_id: Optional[str] = None
2470
+ bucket_output_mode: Optional[str] = None
2471
+ agent_ns = temp_data.get("agent")
2472
+ if isinstance(agent_ns, dict):
2473
+ bucket = agent_ns.get(node_id)
2474
+ if isinstance(bucket, dict):
2475
+ scratchpad = bucket.get("scratchpad")
2476
+ ans = bucket.get("answer")
2477
+ if isinstance(ans, str):
2478
+ answer = ans
2479
+ messages = bucket.get("messages")
2480
+ if "success" in bucket:
2481
+ bucket_success = bool(bucket.get("success"))
2482
+ bp = bucket.get("provider")
2483
+ bm = bucket.get("model")
2484
+ if isinstance(bp, str) and bp.strip():
2485
+ bucket_provider = bp.strip()
2486
+ if isinstance(bm, str) and bm.strip():
2487
+ bucket_model = bm.strip()
2488
+ bi = bucket.get("iterations")
2489
+ try:
2490
+ if isinstance(bi, bool):
2491
+ bucket_iterations = None
2492
+ elif isinstance(bi, (int, float)):
2493
+ bucket_iterations = int(bi)
2494
+ elif isinstance(bi, str) and bi.strip():
2495
+ bucket_iterations = int(float(bi.strip()))
2496
+ except Exception:
2497
+ bucket_iterations = None
2498
+ bsr = bucket.get("sub_run_id")
2499
+ if isinstance(bsr, str) and bsr.strip():
2500
+ bucket_sub_run_id = bsr.strip()
2501
+ bom = bucket.get("output_mode")
2502
+ if isinstance(bom, str) and bom.strip():
2503
+ bucket_output_mode = bom.strip()
2504
+
2505
+ if scratchpad is None:
2506
+ # Fallback: use this node's own trace if present.
2507
+ try:
2508
+ from abstractruntime.core.vars import get_node_trace as _get_node_trace
2509
+ except Exception: # pragma: no cover
2510
+ _get_node_trace = None # type: ignore[assignment]
2511
+ if callable(_get_node_trace):
2512
+ scratchpad = _get_node_trace(run.vars, node_id)
2513
+
2514
+ scratchpad_obj = scratchpad if isinstance(scratchpad, dict) else None
2515
+ if scratchpad_obj is None:
2516
+ scratchpad_obj = {"node_id": node_id, "steps": []}
2517
+
2518
+ # Convenience pins: expose tool activity extracted from the scratchpad trace.
2519
+ # This is intentionally best-effort and does not change agent execution behavior.
2520
+ tc, tr = _extract_agent_tool_activity(scratchpad_obj)
2521
+
2522
+ # Embed tool activity inside the scratchpad for easy Break Object access.
2523
+ scratchpad_out = dict(scratchpad_obj)
2524
+ scratchpad_out["tool_calls"] = tc
2525
+ scratchpad_out["tool_results"] = tr
2526
+
2527
+ # Best-effort response string:
2528
+ # - prefer preserved unstructured answer (before structured-output post-pass)
2529
+ # - fall back to common keys inside the raw result
2530
+ response = ""
2531
+ if bucket_output_mode == "structured" and isinstance(raw, dict):
2532
+ try:
2533
+ import json as _json
2534
+
2535
+ response = _json.dumps(raw, ensure_ascii=False, separators=(",", ":"))
2536
+ except Exception:
2537
+ response = ""
2538
+ if not response:
2539
+ if isinstance(answer, str) and answer.strip():
2540
+ response = answer.strip()
2541
+ elif isinstance(raw, dict):
2542
+ r1 = raw.get("result")
2543
+ r2 = raw.get("response")
2544
+ if isinstance(r1, str) and r1.strip():
2545
+ response = r1.strip()
2546
+ elif isinstance(r2, str) and r2.strip():
2547
+ response = r2.strip()
2548
+ elif isinstance(raw, str) and raw.strip():
2549
+ response = raw.strip()
2550
+
2551
+ def _normalize_message_list(raw_msgs: Any) -> list[Dict[str, Any]]:
2552
+ if not isinstance(raw_msgs, list):
2553
+ return []
2554
+ out: list[Dict[str, Any]] = []
2555
+ for m in raw_msgs:
2556
+ if isinstance(m, dict):
2557
+ out.append(dict(m))
2558
+ else:
2559
+ out.append({"role": "assistant", "content": str(m)})
2560
+ return out
2561
+
2562
+ def _minimal_transcript() -> list[Dict[str, Any]]:
2563
+ task_s = ""
2564
+ if isinstance(raw, dict):
2565
+ t = raw.get("task")
2566
+ if isinstance(t, str) and t.strip():
2567
+ task_s = t.strip()
2568
+ msgs: list[Dict[str, Any]] = []
2569
+ if task_s:
2570
+ msgs.append({"role": "user", "content": task_s})
2571
+ if response:
2572
+ msgs.append({"role": "assistant", "content": response})
2573
+ return msgs
2574
+
2575
+ if not isinstance(scratchpad_out.get("messages"), list):
2576
+ msgs_out = _normalize_message_list(messages)
2577
+ if not msgs_out and isinstance(raw, dict):
2578
+ ctx = raw.get("context")
2579
+ ctx_d = ctx if isinstance(ctx, dict) else {}
2580
+ msgs_out = _normalize_message_list(ctx_d.get("messages"))
2581
+ if not msgs_out:
2582
+ msgs_out = _minimal_transcript()
2583
+ scratchpad_out["messages"] = msgs_out
2584
+
2585
+ meta: Dict[str, Any] = {"schema": "abstractcode.agent.v1.meta", "version": 1}
2586
+ provider: Optional[str] = None
2587
+ model: Optional[str] = None
2588
+ iterations: Optional[int] = None
2589
+ sub_run_id: Optional[str] = None
2590
+ success: Optional[bool] = bucket_success
2591
+
2592
+ if isinstance(raw, dict):
2593
+ p = raw.get("provider")
2594
+ m = raw.get("model")
2595
+ if isinstance(p, str) and p.strip():
2596
+ provider = p.strip()
2597
+ if isinstance(m, str) and m.strip():
2598
+ model = m.strip()
2599
+
2600
+ it = raw.get("iterations")
2601
+ try:
2602
+ if isinstance(it, bool):
2603
+ iterations = None
2604
+ elif isinstance(it, (int, float)):
2605
+ iterations = int(it)
2606
+ elif isinstance(it, str) and it.strip():
2607
+ iterations = int(float(it.strip()))
2608
+ except Exception:
2609
+ iterations = None
2610
+
2611
+ sr = raw.get("sub_run_id")
2612
+ if isinstance(sr, str) and sr.strip():
2613
+ sub_run_id = sr.strip()
2614
+ if "success" in raw:
2615
+ success = bool(raw.get("success"))
2616
+
2617
+ if sub_run_id is None:
2618
+ sr = bucket_sub_run_id or scratchpad_out.get("sub_run_id")
2619
+ if isinstance(sr, str) and sr.strip():
2620
+ sub_run_id = sr.strip()
2621
+
2622
+ if provider is None:
2623
+ provider = bucket_provider
2624
+ if model is None:
2625
+ model = bucket_model
2626
+ if iterations is None:
2627
+ iterations = bucket_iterations
2628
+
2629
+ # Structured-output mode stores the final output object under `raw` without
2630
+ # provider/model metadata. When resuming from persisted state, infer these
2631
+ # fields from the last LLM_CALL step in the scratchpad trace (best-effort).
2632
+ if (provider is None or model is None) and isinstance(scratchpad_out.get("steps"), list):
2633
+ steps_any = scratchpad_out.get("steps")
2634
+ steps = steps_any if isinstance(steps_any, list) else []
2635
+ for entry_any in reversed(steps):
2636
+ entry = entry_any if isinstance(entry_any, dict) else None
2637
+ if entry is None:
2638
+ continue
2639
+ eff = entry.get("effect")
2640
+ if not isinstance(eff, dict) or str(eff.get("type") or "") != "llm_call":
2641
+ continue
2642
+ payload = eff.get("payload")
2643
+ if isinstance(payload, dict):
2644
+ if provider is None:
2645
+ p = payload.get("provider")
2646
+ if isinstance(p, str) and p.strip():
2647
+ provider = p.strip()
2648
+ if model is None:
2649
+ m = payload.get("model")
2650
+ if isinstance(m, str) and m.strip():
2651
+ model = m.strip()
2652
+
2653
+ res = entry.get("result")
2654
+ if isinstance(res, dict):
2655
+ if provider is None:
2656
+ p = res.get("provider")
2657
+ if isinstance(p, str) and p.strip():
2658
+ provider = p.strip()
2659
+ if model is None:
2660
+ m = res.get("model")
2661
+ if isinstance(m, str) and m.strip():
2662
+ model = m.strip()
2663
+
2664
+ if provider is not None or model is not None:
2665
+ break
2666
+
2667
+ if provider:
2668
+ meta["provider"] = provider
2669
+ if model:
2670
+ meta["model"] = model
2671
+ if sub_run_id:
2672
+ meta["sub_run_id"] = sub_run_id
2673
+ if iterations is not None:
2674
+ meta["iterations"] = iterations
2675
+ meta["tool_calls"] = len(tc)
2676
+ meta["tool_results"] = len(tr)
2677
+ if bucket_output_mode:
2678
+ meta["output_mode"] = bucket_output_mode
2679
+
2680
+ current["response"] = response
2681
+ current["success"] = bool(success) if success is not None else True
2682
+ current["meta"] = meta
2683
+ current["scratchpad"] = scratchpad_out
2684
+ # Legacy pin: older flows may still wire Agent.result into Break Object.
2685
+ if (node_id, "result") in referenced_source_pins:
2686
+ if isinstance(raw, dict) and (set(raw.keys()) - {"success"}):
2687
+ current["result"] = raw
2688
+ else:
2689
+ current["result"] = {
2690
+ "response": response,
2691
+ "success": current["success"],
2692
+ "meta": meta,
2693
+ "scratchpad": scratchpad_out,
2694
+ }
2695
+
2696
+ # Backward-compat / convenience: keep top-level tool activity too.
2697
+ current["tool_calls"] = tc
2698
+ current["tool_results"] = tr
2699
+ mapped_value = raw
2700
+ elif effect_type == "wait_event":
2701
+ current["event_data"] = raw
2702
+ mapped_value = raw
2703
+ elif effect_type == "on_event":
2704
+ # Custom event listener: the resume payload is a structured envelope.
2705
+ if isinstance(raw, dict):
2706
+ current["event"] = raw
2707
+ current["payload"] = raw.get("payload")
2708
+ current["event_id"] = raw.get("event_id")
2709
+ current["name"] = raw.get("name")
2710
+ mapped_value = raw
2711
+ else:
2712
+ current["event"] = raw
2713
+ mapped_value = raw
2714
+ elif effect_type == "on_schedule":
2715
+ cfg = flow_node.effect_config if isinstance(flow_node.effect_config, dict) else {}
2716
+ schedule_cfg = cfg.get("schedule")
2717
+ schedule_str = str(schedule_cfg or "").strip() if schedule_cfg is not None else ""
2718
+ recurrent_cfg = cfg.get("recurrent")
2719
+ recurrent_flag = True if recurrent_cfg is None else bool(recurrent_cfg)
2720
+ # ISO timestamps are treated as one-shot; recurrence is disabled.
2721
+ if schedule_str and not isinstance(schedule_cfg, (int, float)) and schedule_str:
2722
+ import re
2723
+
2724
+ if not re.match(r"^\\s*\\d+(?:\\.\\d+)?\\s*(ms|s|m|h|d)\\s*$", schedule_str, re.IGNORECASE):
2725
+ recurrent_flag = False
2726
+
2727
+ if isinstance(raw, dict):
2728
+ current.update(raw)
2729
+ ts = raw.get("timestamp")
2730
+ if ts is None:
2731
+ ts = raw.get("scheduled_for")
2732
+ current["timestamp"] = ts
2733
+ current["recurrent"] = recurrent_flag
2734
+ mapped_value = ts if ts is not None else raw
2735
+ else:
2736
+ current["timestamp"] = raw
2737
+ current["recurrent"] = recurrent_flag
2738
+ mapped_value = raw
2739
+ elif effect_type == "wait_until":
2740
+ if isinstance(raw, dict):
2741
+ current.update(raw)
2742
+ else:
2743
+ current["result"] = raw
2744
+ mapped_value = raw
2745
+ elif effect_type == "emit_event":
2746
+ # Custom event emission result (dispatch summary).
2747
+ if isinstance(raw, dict):
2748
+ current.update(raw)
2749
+ mapped_value = raw
2750
+ else:
2751
+ current["result"] = raw
2752
+ mapped_value = raw
2753
+ elif effect_type == "memory_note":
2754
+ span_id = _get_span_id(raw)
2755
+ current["note_id"] = span_id
2756
+ current["raw"] = raw
2757
+ mapped_value = span_id
2758
+ elif effect_type == "memory_compact":
2759
+ span_id = _get_span_id(raw)
2760
+ current["span_id"] = span_id
2761
+ current["raw"] = raw
2762
+ mapped_value = span_id
2763
+ elif effect_type == "memory_query":
2764
+ # Runtime returns a tool-results envelope:
2765
+ # {"mode":"executed","results":[{call_id,name,success,output,error,meta?}, ...]}
2766
+ rendered = ""
2767
+ matches: list[Any] = []
2768
+ span_ids: list[Any] = []
2769
+ if isinstance(raw, dict):
2770
+ results_list = raw.get("results")
2771
+ if isinstance(results_list, list) and results_list:
2772
+ first = results_list[0]
2773
+ if isinstance(first, dict):
2774
+ out = first.get("output")
2775
+ if isinstance(out, str):
2776
+ rendered = out
2777
+ meta = first.get("meta")
2778
+ if isinstance(meta, dict):
2779
+ m = meta.get("matches")
2780
+ if isinstance(m, list):
2781
+ matches = m
2782
+ sids = meta.get("span_ids")
2783
+ if isinstance(sids, list):
2784
+ span_ids = sids
2785
+
2786
+ current["rendered"] = rendered
2787
+ current["results"] = matches
2788
+ current["span_ids"] = span_ids
2789
+ current["raw"] = raw
2790
+ mapped_value = current["results"]
2791
+ elif effect_type == "memory_tag":
2792
+ rendered = ""
2793
+ success = False
2794
+ results_list: list[Any] = []
2795
+ if isinstance(raw, dict):
2796
+ rl = raw.get("results")
2797
+ if isinstance(rl, list):
2798
+ results_list = rl
2799
+ if results_list:
2800
+ first = results_list[0]
2801
+ if isinstance(first, dict):
2802
+ out = first.get("output")
2803
+ if isinstance(out, str):
2804
+ rendered = out
2805
+ success = first.get("success") is True
2806
+
2807
+ current["rendered"] = rendered
2808
+ current["success"] = bool(success)
2809
+ current["results"] = results_list
2810
+ current["raw"] = raw
2811
+ mapped_value = rendered
2812
+ elif effect_type == "memory_rehydrate":
2813
+ if isinstance(raw, dict):
2814
+ current["inserted"] = raw.get("inserted")
2815
+ current["skipped"] = raw.get("skipped")
2816
+ current["artifacts"] = raw.get("artifacts")
2817
+ else:
2818
+ current["inserted"] = 0
2819
+ current["skipped"] = 0
2820
+ current["artifacts"] = []
2821
+ current["raw"] = raw
2822
+ mapped_value = raw
2823
+ elif effect_type == "memory_kg_assert":
2824
+ if isinstance(raw, dict):
2825
+ ids = raw.get("assertion_ids")
2826
+ current["assertion_ids"] = ids if isinstance(ids, list) else []
2827
+ current["count"] = raw.get("count")
2828
+ current["ok"] = raw.get("ok")
2829
+ else:
2830
+ current["assertion_ids"] = []
2831
+ current["count"] = 0
2832
+ current["ok"] = False
2833
+ current["raw"] = raw
2834
+ mapped_value = current.get("assertion_ids")
2835
+ elif effect_type == "memory_kg_query":
2836
+ if isinstance(raw, dict):
2837
+ items = raw.get("items")
2838
+ current["items"] = items if isinstance(items, list) else []
2839
+ current["count"] = raw.get("count")
2840
+ current["ok"] = raw.get("ok")
2841
+ # Active Memory packing (optional; only present when max_input_tokens is set).
2842
+ if "packets_version" in raw:
2843
+ current["packets_version"] = raw.get("packets_version")
2844
+ if "packets" in raw:
2845
+ current["packets"] = raw.get("packets")
2846
+ if "packed_count" in raw:
2847
+ current["packed_count"] = raw.get("packed_count")
2848
+ if "active_memory_text" in raw:
2849
+ current["active_memory_text"] = raw.get("active_memory_text")
2850
+ if "estimated_tokens" in raw:
2851
+ current["estimated_tokens"] = raw.get("estimated_tokens")
2852
+ if "dropped" in raw:
2853
+ current["dropped"] = raw.get("dropped")
2854
+ if "warnings" in raw:
2855
+ current["warnings"] = raw.get("warnings")
2856
+ else:
2857
+ current["items"] = []
2858
+ current["count"] = 0
2859
+ current["ok"] = False
2860
+ current["packets"] = []
2861
+ current["packed_count"] = 0
2862
+ current["active_memory_text"] = ""
2863
+ current["estimated_tokens"] = 0
2864
+ current["dropped"] = 0
2865
+ current["raw"] = raw
2866
+ mapped_value = current.get("items")
2867
+ elif effect_type == "memory_kg_resolve":
2868
+ if isinstance(raw, dict):
2869
+ candidates = raw.get("candidates")
2870
+ current["candidates"] = candidates if isinstance(candidates, list) else []
2871
+ current["count"] = raw.get("count")
2872
+ current["ok"] = raw.get("ok")
2873
+ if "warnings" in raw:
2874
+ current["warnings"] = raw.get("warnings")
2875
+ else:
2876
+ current["candidates"] = []
2877
+ current["count"] = 0
2878
+ current["ok"] = False
2879
+ current["raw"] = raw
2880
+ mapped_value = current.get("candidates")
2881
+ elif effect_type == "start_subworkflow":
2882
+ if isinstance(raw, dict):
2883
+ current["sub_run_id"] = raw.get("sub_run_id")
2884
+ out = raw.get("output")
2885
+ if isinstance(out, dict) and "result" in out:
2886
+ result_value = out.get("result")
2887
+ current["output"] = result_value
2888
+ current["child_output"] = out
2889
+ else:
2890
+ result_value = out
2891
+ current["output"] = result_value
2892
+ if isinstance(out, dict):
2893
+ current["child_output"] = out
2894
+ mapped_value = current.get("output")
2895
+
2896
+ cfg = flow_node.effect_config or {}
2897
+ out_pins = cfg.get("output_pins")
2898
+ if isinstance(out_pins, list) and out_pins:
2899
+ if isinstance(result_value, dict):
2900
+ for pid in out_pins:
2901
+ if isinstance(pid, str) and pid:
2902
+ if pid == "output":
2903
+ continue
2904
+ current[pid] = result_value.get(pid)
2905
+ elif len(out_pins) == 1 and isinstance(out_pins[0], str) and out_pins[0]:
2906
+ current[out_pins[0]] = result_value
2907
+ else:
2908
+ current["output"] = raw
2909
+ mapped_value = raw
2910
+
2911
+ # Optional: also write the mapped output to run.vars if configured.
2912
+ if flow_node.output_key and mapped_value is not None:
2913
+ _set_nested(run.vars, flow_node.output_key, mapped_value)
2914
+
2915
+
2916
+ def _set_nested(target: Dict[str, Any], dotted_key: str, value: Any) -> None:
2917
+ """Set nested dict value using dot notation."""
2918
+ parts = dotted_key.split(".")
2919
+ cur = target
2920
+ for p in parts[:-1]:
2921
+ nxt = cur.get(p)
2922
+ if not isinstance(nxt, dict):
2923
+ nxt = {}
2924
+ cur[p] = nxt
2925
+ cur = nxt
2926
+ cur[parts[-1]] = value
2927
+
2928
+
2929
+ def compile_flow(flow: Flow) -> "WorkflowSpec":
2930
+ """Compile a Flow definition into an AbstractRuntime WorkflowSpec.
2931
+
2932
+ This function transforms a declarative Flow definition into an executable
2933
+ WorkflowSpec that can be run by AbstractRuntime. Each flow node is converted
2934
+ to a workflow node handler based on its type:
2935
+
2936
+ - Functions: Executed directly within the workflow
2937
+ - Agents: Run as subworkflows using START_SUBWORKFLOW effect
2938
+ - Nested Flows: Compiled recursively and run as subworkflows
2939
+
2940
+ Args:
2941
+ flow: The Flow definition to compile
2942
+
2943
+ Returns:
2944
+ A WorkflowSpec that can be executed by AbstractRuntime
2945
+
2946
+ Raises:
2947
+ ValueError: If the flow is invalid (no entry node, missing nodes, etc.)
2948
+ TypeError: If a node handler is of unknown type
2949
+
2950
+ Example:
2951
+ >>> flow = Flow("my_flow")
2952
+ >>> flow.add_node("start", my_func)
2953
+ >>> flow.set_entry("start")
2954
+ >>> spec = compile_flow(flow)
2955
+ >>> runtime.start(workflow=spec)
2956
+ """
2957
+ from abstractruntime.core.spec import WorkflowSpec
2958
+
2959
+ # Validate flow
2960
+ errors = flow.validate()
2961
+ if errors:
2962
+ raise ValueError(f"Invalid flow: {'; '.join(errors)}")
2963
+
2964
+ outgoing: Dict[str, list] = {}
2965
+ for edge in flow.edges:
2966
+ outgoing.setdefault(edge.source, []).append(edge)
2967
+
2968
+ # Build next-node map (linear) and branch maps (If/Else).
2969
+ next_node_map: Dict[str, Optional[str]] = {}
2970
+ branch_maps: Dict[str, Dict[str, str]] = {}
2971
+ control_specs: Dict[str, Dict[str, Any]] = {}
2972
+
2973
+ def _is_supported_branch_handle(handle: str) -> bool:
2974
+ return handle in {"true", "false", "default"} or handle.startswith("case:")
2975
+
2976
+ for node_id in flow.nodes:
2977
+ outs = outgoing.get(node_id, [])
2978
+ if not outs:
2979
+ next_node_map[node_id] = None
2980
+ continue
2981
+
2982
+ flow_node = flow.nodes.get(node_id)
2983
+ node_effect_type = getattr(flow_node, "effect_type", None) if flow_node else None
2984
+
2985
+ # Sequence / Parallel: deterministic fan-out scheduling (Blueprint-style).
2986
+ #
2987
+ # Important: these nodes may have 0..N connected outputs; even a single
2988
+ # `then:0` edge should compile (it is not "branching" based on data).
2989
+ if node_effect_type in {"sequence", "parallel"}:
2990
+ # Validate all execution edges have a handle
2991
+ handles: list[str] = []
2992
+ targets_by_handle: Dict[str, str] = {}
2993
+ for e in outs:
2994
+ h = getattr(e, "source_handle", None)
2995
+ if not isinstance(h, str) or not h:
2996
+ raise ValueError(
2997
+ f"Control node '{node_id}' has an execution edge with no source_handle."
2998
+ )
2999
+ handles.append(h)
3000
+ targets_by_handle[h] = e.target
3001
+
3002
+ cfg = getattr(flow_node, "effect_config", None) if flow_node else None
3003
+ cfg_dict = cfg if isinstance(cfg, dict) else {}
3004
+ then_handles = cfg_dict.get("then_handles")
3005
+ if not isinstance(then_handles, list):
3006
+ then_handles = [h for h in handles if h.startswith("then:")]
3007
+
3008
+ def _then_key(h: str) -> int:
3009
+ try:
3010
+ if h.startswith("then:"):
3011
+ return int(h.split(":", 1)[1])
3012
+ except Exception:
3013
+ pass
3014
+ return 10**9
3015
+
3016
+ then_handles = sorted(then_handles, key=_then_key)
3017
+ else:
3018
+ then_handles = [str(h) for h in then_handles if isinstance(h, str) and h]
3019
+
3020
+ allowed = set(then_handles)
3021
+ completed_target: Optional[str] = None
3022
+ if node_effect_type == "parallel":
3023
+ allowed.add("completed")
3024
+ completed_target = targets_by_handle.get("completed")
3025
+
3026
+ unknown = [h for h in handles if h not in allowed]
3027
+ if unknown:
3028
+ raise ValueError(
3029
+ f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
3030
+ )
3031
+
3032
+ control_specs[node_id] = {
3033
+ "kind": node_effect_type,
3034
+ "then_handles": then_handles,
3035
+ "targets_by_handle": targets_by_handle,
3036
+ "completed_target": completed_target,
3037
+ }
3038
+ next_node_map[node_id] = None
3039
+ continue
3040
+
3041
+ # Loop (Foreach): structured scheduling via `loop` (body) and `done` (completed).
3042
+ #
3043
+ # This is a scheduler node (like Sequence/Parallel), not data-driven branching.
3044
+ if node_effect_type == "loop":
3045
+ handles: list[str] = []
3046
+ targets_by_handle: Dict[str, str] = {}
3047
+ for e in outs:
3048
+ h = getattr(e, "source_handle", None)
3049
+ if not isinstance(h, str) or not h:
3050
+ raise ValueError(
3051
+ f"Control node '{node_id}' has an execution edge with no source_handle."
3052
+ )
3053
+ handles.append(h)
3054
+ targets_by_handle[h] = e.target
3055
+
3056
+ allowed = {"loop", "done"}
3057
+ unknown = [h for h in handles if h not in allowed]
3058
+ if unknown:
3059
+ raise ValueError(
3060
+ f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
3061
+ )
3062
+
3063
+ control_specs[node_id] = {
3064
+ "kind": "loop",
3065
+ "loop_target": targets_by_handle.get("loop"),
3066
+ "done_target": targets_by_handle.get("done"),
3067
+ }
3068
+ next_node_map[node_id] = None
3069
+ continue
3070
+
3071
+ # While: structured scheduling via `loop` (body) and `done` (completed),
3072
+ # gated by a boolean condition pin resolved via data edges.
3073
+ if node_effect_type == "while":
3074
+ handles = []
3075
+ targets_by_handle = {}
3076
+ for e in outs:
3077
+ h = getattr(e, "source_handle", None)
3078
+ if not isinstance(h, str) or not h:
3079
+ raise ValueError(
3080
+ f"Control node '{node_id}' has an execution edge with no source_handle."
3081
+ )
3082
+ handles.append(h)
3083
+ targets_by_handle[h] = e.target
3084
+
3085
+ allowed = {"loop", "done"}
3086
+ unknown = [h for h in handles if h not in allowed]
3087
+ if unknown:
3088
+ raise ValueError(
3089
+ f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
3090
+ )
3091
+
3092
+ control_specs[node_id] = {
3093
+ "kind": "while",
3094
+ "loop_target": targets_by_handle.get("loop"),
3095
+ "done_target": targets_by_handle.get("done"),
3096
+ }
3097
+ next_node_map[node_id] = None
3098
+ continue
3099
+
3100
+ # For: structured scheduling via `loop` (body) and `done` (completed),
3101
+ # over a numeric range resolved via data edges (start/end/step).
3102
+ if node_effect_type == "for":
3103
+ handles = []
3104
+ targets_by_handle = {}
3105
+ for e in outs:
3106
+ h = getattr(e, "source_handle", None)
3107
+ if not isinstance(h, str) or not h:
3108
+ raise ValueError(
3109
+ f"Control node '{node_id}' has an execution edge with no source_handle."
3110
+ )
3111
+ handles.append(h)
3112
+ targets_by_handle[h] = e.target
3113
+
3114
+ allowed = {"loop", "done"}
3115
+ unknown = [h for h in handles if h not in allowed]
3116
+ if unknown:
3117
+ raise ValueError(
3118
+ f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
3119
+ )
3120
+
3121
+ control_specs[node_id] = {
3122
+ "kind": "for",
3123
+ "loop_target": targets_by_handle.get("loop"),
3124
+ "done_target": targets_by_handle.get("done"),
3125
+ }
3126
+ next_node_map[node_id] = None
3127
+ continue
3128
+
3129
+ if len(outs) == 1:
3130
+ h = getattr(outs[0], "source_handle", None)
3131
+ if isinstance(h, str) and h and h != "exec-out":
3132
+ if not _is_supported_branch_handle(h):
3133
+ raise ValueError(
3134
+ f"Node '{node_id}' has unsupported branching output '{h}'. "
3135
+ "Branching is not yet supported."
3136
+ )
3137
+ branch_maps[node_id] = {h: outs[0].target} # type: ignore[arg-type]
3138
+ next_node_map[node_id] = None
3139
+ else:
3140
+ next_node_map[node_id] = outs[0].target
3141
+ continue
3142
+
3143
+ handles: list[str] = []
3144
+ for e in outs:
3145
+ h = getattr(e, "source_handle", None)
3146
+ if not isinstance(h, str) or not h:
3147
+ handles = []
3148
+ break
3149
+ handles.append(h)
3150
+
3151
+ if len(handles) != len(outs) or len(set(handles)) != len(handles):
3152
+ raise ValueError(
3153
+ f"Node '{node_id}' has multiple outgoing edges. "
3154
+ "Branching is not yet supported."
3155
+ )
3156
+
3157
+ # Minimal branching support: If/Else uses `true` / `false` execution outputs.
3158
+ if set(handles) <= {"true", "false"}:
3159
+ branch_maps[node_id] = {e.source_handle: e.target for e in outs} # type: ignore[arg-type]
3160
+ next_node_map[node_id] = None
3161
+ continue
3162
+
3163
+ # Switch branching: stable case handles + optional default.
3164
+ if all(h == "default" or h.startswith("case:") for h in handles):
3165
+ branch_maps[node_id] = {e.source_handle: e.target for e in outs} # type: ignore[arg-type]
3166
+ next_node_map[node_id] = None
3167
+ continue
3168
+
3169
+ raise ValueError(
3170
+ f"Node '{node_id}' has multiple outgoing edges. "
3171
+ "Branching is not yet supported."
3172
+ )
3173
+
3174
+ # Determine exit node if not set
3175
+ exit_node = flow.exit_node
3176
+ if not exit_node:
3177
+ terminal_nodes = flow.get_terminal_nodes()
3178
+ if len(terminal_nodes) == 1:
3179
+ exit_node = terminal_nodes[0]
3180
+ elif len(terminal_nodes) > 1:
3181
+ # Multiple terminals - each will complete the flow when reached
3182
+ pass
3183
+
3184
+ # Create node handlers
3185
+ handlers: Dict[str, Callable] = {}
3186
+
3187
+ def _wrap_return_to_active_control(
3188
+ handler: Callable,
3189
+ *,
3190
+ node_id: str,
3191
+ visual_type: Optional[str],
3192
+ ) -> Callable:
3193
+ """If a node tries to complete the run inside an active control block, return to the scheduler.
3194
+
3195
+ This is crucial for Blueprint-style nodes:
3196
+ - branch chains can end (no outgoing exec edges) without ending the whole flow
3197
+ - Sequence/Parallel can then continue scheduling other branches
3198
+ """
3199
+ from abstractruntime.core.models import StepPlan
3200
+
3201
+ try:
3202
+ from .adapters.control_adapter import get_active_control_node_id
3203
+ except Exception: # pragma: no cover
3204
+ get_active_control_node_id = None # type: ignore[assignment]
3205
+
3206
+ def wrapped(run: Any, ctx: Any) -> "StepPlan":
3207
+ plan: StepPlan = handler(run, ctx)
3208
+ if not callable(get_active_control_node_id):
3209
+ return plan
3210
+
3211
+ active = get_active_control_node_id(run.vars)
3212
+ if not isinstance(active, str) or not active:
3213
+ return plan
3214
+ if active == node_id:
3215
+ return plan
3216
+
3217
+ # Explicit end node should always terminate the run, even inside a control block.
3218
+ if visual_type == "on_flow_end":
3219
+ return plan
3220
+
3221
+ # If the node is about to complete the run, treat it as "branch complete" instead.
3222
+ if plan.complete_output is not None:
3223
+ return StepPlan(node_id=plan.node_id, next_node=active)
3224
+
3225
+ # Terminal effect node: runtime would auto-complete if next_node is missing.
3226
+ if plan.effect is not None and not plan.next_node:
3227
+ return StepPlan(node_id=plan.node_id, effect=plan.effect, next_node=active)
3228
+
3229
+ # Defensive fallback.
3230
+ if plan.effect is None and not plan.next_node and plan.complete_output is None:
3231
+ return StepPlan(node_id=plan.node_id, next_node=active)
3232
+
3233
+ return plan
3234
+
3235
+ return wrapped
3236
+
3237
+ for node_id, flow_node in flow.nodes.items():
3238
+ next_node = next_node_map.get(node_id)
3239
+ branch_map = branch_maps.get(node_id)
3240
+ handler_obj = getattr(flow_node, "handler", None)
3241
+ effect_type = getattr(flow_node, "effect_type", None)
3242
+ effect_config = getattr(flow_node, "effect_config", None) or {}
3243
+ visual_type = effect_config.get("_visual_type") if isinstance(effect_config, dict) else None
3244
+
3245
+ # Check for effect/control nodes first
3246
+ if effect_type == "sequence":
3247
+ from .adapters.control_adapter import create_sequence_node_handler
3248
+
3249
+ spec = control_specs.get(node_id) or {}
3250
+ handlers[node_id] = create_sequence_node_handler(
3251
+ node_id=node_id,
3252
+ ordered_then_handles=list(spec.get("then_handles") or []),
3253
+ targets_by_handle=dict(spec.get("targets_by_handle") or {}),
3254
+ )
3255
+ elif effect_type == "parallel":
3256
+ from .adapters.control_adapter import create_parallel_node_handler
3257
+
3258
+ spec = control_specs.get(node_id) or {}
3259
+ handlers[node_id] = create_parallel_node_handler(
3260
+ node_id=node_id,
3261
+ ordered_then_handles=list(spec.get("then_handles") or []),
3262
+ targets_by_handle=dict(spec.get("targets_by_handle") or {}),
3263
+ completed_target=spec.get("completed_target"),
3264
+ )
3265
+ elif effect_type == "loop":
3266
+ from .adapters.control_adapter import create_loop_node_handler
3267
+
3268
+ spec = control_specs.get(node_id) or {}
3269
+ loop_data_handler = handler_obj if callable(handler_obj) else None
3270
+
3271
+ # Precompute upstream pure-node ids for cache invalidation (best-effort).
3272
+ #
3273
+ # Pure nodes (e.g. concat/split/break_object) are cached in `flow._node_outputs`.
3274
+ # Inside a Loop, the inputs to those pure nodes often change per-iteration
3275
+ # (index/item, evolving scratchpad vars, etc.). If we don't invalidate, the
3276
+ # loop body may reuse stale values from iteration 0.
3277
+ pure_ids = getattr(flow, "_pure_node_ids", None) if flow is not None else None
3278
+ pure_ids = set(pure_ids) if isinstance(pure_ids, (set, list, tuple)) else set()
3279
+
3280
+ def _resolve_items(
3281
+ run: Any,
3282
+ _handler: Any = loop_data_handler,
3283
+ _node_id: str = node_id,
3284
+ ) -> list[Any]:
3285
+ if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
3286
+ _sync_effect_results_to_node_outputs(run, flow)
3287
+ if not callable(_handler):
3288
+ return []
3289
+ last_output = run.vars.get("_last_output", {})
3290
+ try:
3291
+ resolved = _handler(last_output)
3292
+ except Exception as e:
3293
+ # Surface this as a workflow error (don't silently treat as empty).
3294
+ try:
3295
+ run.vars["_flow_error"] = f"Loop items resolution failed: {e}"
3296
+ run.vars["_flow_error_node"] = _node_id
3297
+ except Exception:
3298
+ pass
3299
+ raise
3300
+ if not isinstance(resolved, dict):
3301
+ return []
3302
+ raw = resolved.get("items")
3303
+ if isinstance(raw, list):
3304
+ return raw
3305
+ if isinstance(raw, tuple):
3306
+ return list(raw)
3307
+ if raw is None:
3308
+ return []
3309
+ return [raw]
3310
+
3311
+ base_loop = create_loop_node_handler(
3312
+ node_id=node_id,
3313
+ loop_target=spec.get("loop_target"),
3314
+ done_target=spec.get("done_target"),
3315
+ resolve_items=_resolve_items,
3316
+ )
3317
+
3318
+ def _wrapped_loop(
3319
+ run: Any,
3320
+ ctx: Any,
3321
+ *,
3322
+ _base: Any = base_loop,
3323
+ _pure_ids: set[str] = pure_ids,
3324
+ ) -> StepPlan:
3325
+ # Ensure pure nodes feeding the loop body are re-evaluated per iteration.
3326
+ if flow is not None and _pure_ids and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
3327
+ _sync_effect_results_to_node_outputs(run, flow)
3328
+ node_outputs = getattr(flow, "_node_outputs", None)
3329
+ if isinstance(node_outputs, dict):
3330
+ for nid in _pure_ids:
3331
+ node_outputs.pop(nid, None)
3332
+ plan = _base(run, ctx)
3333
+ # The loop scheduler persists `{item,index,total}` into run.vars, but
3334
+ # UI node_complete events read from `flow._node_outputs`. Sync after
3335
+ # scheduling so observability reflects the current iteration.
3336
+ if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
3337
+ _sync_effect_results_to_node_outputs(run, flow)
3338
+ return plan
3339
+
3340
+ handlers[node_id] = _wrapped_loop
3341
+ elif effect_type == "agent":
3342
+ data_aware_handler = handler_obj if callable(handler_obj) else None
3343
+ handlers[node_id] = _create_visual_agent_effect_handler(
3344
+ node_id=node_id,
3345
+ next_node=next_node,
3346
+ agent_config=effect_config if isinstance(effect_config, dict) else {},
3347
+ data_aware_handler=data_aware_handler,
3348
+ flow=flow,
3349
+ )
3350
+ elif effect_type == "while":
3351
+ from .adapters.control_adapter import create_while_node_handler
3352
+
3353
+ spec = control_specs.get(node_id) or {}
3354
+ while_data_handler = handler_obj if callable(handler_obj) else None
3355
+
3356
+ # Precompute upstream pure-node ids for cache invalidation (best-effort).
3357
+ pure_ids = getattr(flow, "_pure_node_ids", None) if flow is not None else None
3358
+ pure_ids = set(pure_ids) if isinstance(pure_ids, (set, list, tuple)) else set()
3359
+
3360
+ data_edge_map = getattr(flow, "_data_edge_map", None) if flow is not None else None
3361
+ data_edge_map = data_edge_map if isinstance(data_edge_map, dict) else {}
3362
+
3363
+ upstream_pure: set[str] = set()
3364
+ if pure_ids:
3365
+ stack2 = [node_id]
3366
+ seen2: set[str] = set()
3367
+ while stack2:
3368
+ cur = stack2.pop()
3369
+ if cur in seen2:
3370
+ continue
3371
+ seen2.add(cur)
3372
+ deps = data_edge_map.get(cur)
3373
+ if not isinstance(deps, dict):
3374
+ continue
3375
+ for _pin, src in deps.items():
3376
+ if not isinstance(src, tuple) or len(src) != 2:
3377
+ continue
3378
+ src_node = src[0]
3379
+ if not isinstance(src_node, str) or not src_node:
3380
+ continue
3381
+ stack2.append(src_node)
3382
+ if src_node in pure_ids:
3383
+ upstream_pure.add(src_node)
3384
+
3385
+ def _resolve_condition(
3386
+ run: Any,
3387
+ _handler: Any = while_data_handler,
3388
+ _node_id: str = node_id,
3389
+ _upstream_pure: set[str] = upstream_pure,
3390
+ ) -> bool:
3391
+ if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
3392
+ _sync_effect_results_to_node_outputs(run, flow)
3393
+ # Ensure pure nodes feeding the condition are re-evaluated per iteration.
3394
+ if _upstream_pure and hasattr(flow, "_node_outputs"):
3395
+ node_outputs = getattr(flow, "_node_outputs", None)
3396
+ if isinstance(node_outputs, dict):
3397
+ for nid in _upstream_pure:
3398
+ node_outputs.pop(nid, None)
3399
+
3400
+ if not callable(_handler):
3401
+ return False
3402
+
3403
+ last_output = run.vars.get("_last_output", {})
3404
+ try:
3405
+ resolved = _handler(last_output)
3406
+ except Exception as e:
3407
+ try:
3408
+ run.vars["_flow_error"] = f"While condition resolution failed: {e}"
3409
+ run.vars["_flow_error_node"] = _node_id
3410
+ except Exception:
3411
+ pass
3412
+ raise
3413
+
3414
+ if isinstance(resolved, dict) and "condition" in resolved:
3415
+ return bool(resolved.get("condition"))
3416
+ return bool(resolved)
3417
+
3418
+ base_while = create_while_node_handler(
3419
+ node_id=node_id,
3420
+ loop_target=spec.get("loop_target"),
3421
+ done_target=spec.get("done_target"),
3422
+ resolve_condition=_resolve_condition,
3423
+ )
3424
+
3425
+ def _wrapped_while(
3426
+ run: Any,
3427
+ ctx: Any,
3428
+ *,
3429
+ _base: Any = base_while,
3430
+ _pure_ids: set[str] = pure_ids,
3431
+ ) -> StepPlan:
3432
+ # Ensure pure nodes feeding the loop body are re-evaluated per iteration.
3433
+ if flow is not None and _pure_ids and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
3434
+ _sync_effect_results_to_node_outputs(run, flow)
3435
+ node_outputs = getattr(flow, "_node_outputs", None)
3436
+ if isinstance(node_outputs, dict):
3437
+ for nid in _pure_ids:
3438
+ node_outputs.pop(nid, None)
3439
+ plan = _base(run, ctx)
3440
+ # While scheduler persists `index` into run.vars; sync so WS/UI
3441
+ # node_complete events show the latest iteration count.
3442
+ if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
3443
+ _sync_effect_results_to_node_outputs(run, flow)
3444
+ return plan
3445
+
3446
+ handlers[node_id] = _wrapped_while
3447
+ elif effect_type == "for":
3448
+ from .adapters.control_adapter import create_for_node_handler
3449
+
3450
+ spec = control_specs.get(node_id) or {}
3451
+ for_data_handler = handler_obj if callable(handler_obj) else None
3452
+
3453
+ # Precompute upstream pure-node ids for cache invalidation (best-effort).
3454
+ pure_ids = getattr(flow, "_pure_node_ids", None) if flow is not None else None
3455
+ pure_ids = set(pure_ids) if isinstance(pure_ids, (set, list, tuple)) else set()
3456
+
3457
+ def _resolve_range(
3458
+ run: Any,
3459
+ _handler: Any = for_data_handler,
3460
+ _node_id: str = node_id,
3461
+ ) -> Dict[str, Any]:
3462
+ if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
3463
+ _sync_effect_results_to_node_outputs(run, flow)
3464
+ if not callable(_handler):
3465
+ return {}
3466
+ last_output = run.vars.get("_last_output", {})
3467
+ try:
3468
+ resolved = _handler(last_output)
3469
+ except Exception as e:
3470
+ try:
3471
+ run.vars["_flow_error"] = f"For range resolution failed: {e}"
3472
+ run.vars["_flow_error_node"] = _node_id
3473
+ except Exception:
3474
+ pass
3475
+ raise
3476
+ return resolved if isinstance(resolved, dict) else {}
3477
+
3478
+ base_for = create_for_node_handler(
3479
+ node_id=node_id,
3480
+ loop_target=spec.get("loop_target"),
3481
+ done_target=spec.get("done_target"),
3482
+ resolve_range=_resolve_range,
3483
+ )
3484
+
3485
+ def _wrapped_for(
3486
+ run: Any,
3487
+ ctx: Any,
3488
+ *,
3489
+ _base: Any = base_for,
3490
+ _pure_ids: set[str] = pure_ids,
3491
+ ) -> StepPlan:
3492
+ # Ensure pure nodes feeding the loop body are re-evaluated per iteration.
3493
+ if flow is not None and _pure_ids and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
3494
+ _sync_effect_results_to_node_outputs(run, flow)
3495
+ node_outputs = getattr(flow, "_node_outputs", None)
3496
+ if isinstance(node_outputs, dict):
3497
+ for nid in _pure_ids:
3498
+ node_outputs.pop(nid, None)
3499
+ plan = _base(run, ctx)
3500
+ # For scheduler persists `{i,index,total}` into run.vars; sync so
3501
+ # WS/UI node_complete events show the current iteration.
3502
+ if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
3503
+ _sync_effect_results_to_node_outputs(run, flow)
3504
+ return plan
3505
+
3506
+ handlers[node_id] = _wrapped_for
3507
+ elif effect_type == "on_event":
3508
+ from .adapters.event_adapter import create_on_event_node_handler
3509
+
3510
+ on_event_data_handler = handler_obj if callable(handler_obj) else None
3511
+
3512
+ def _resolve_inputs(
3513
+ run: Any,
3514
+ _handler: Any = on_event_data_handler,
3515
+ ) -> Dict[str, Any]:
3516
+ if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
3517
+ _sync_effect_results_to_node_outputs(run, flow)
3518
+ if not callable(_handler):
3519
+ return {}
3520
+ last_output = run.vars.get("_last_output", {})
3521
+ try:
3522
+ resolved = _handler(last_output)
3523
+ except Exception:
3524
+ resolved = {}
3525
+ return resolved if isinstance(resolved, dict) else {}
3526
+
3527
+ # Blank/unspecified name is treated as "listen to any event" (wildcard).
3528
+ default_name = ""
3529
+ scope = "session"
3530
+ if isinstance(effect_config, dict):
3531
+ raw_name = effect_config.get("name") or effect_config.get("event_name")
3532
+ if isinstance(raw_name, str) and raw_name.strip():
3533
+ default_name = raw_name
3534
+ raw_scope = effect_config.get("scope")
3535
+ if isinstance(raw_scope, str) and raw_scope.strip():
3536
+ scope = raw_scope
3537
+
3538
+ handlers[node_id] = create_on_event_node_handler(
3539
+ node_id=node_id,
3540
+ next_node=next_node,
3541
+ resolve_inputs=_resolve_inputs if callable(on_event_data_handler) else None,
3542
+ default_name=default_name,
3543
+ scope=scope,
3544
+ flow=flow,
3545
+ )
3546
+ elif effect_type == "on_schedule":
3547
+ from .adapters.event_adapter import create_on_schedule_node_handler
3548
+
3549
+ on_schedule_data_handler = handler_obj if callable(handler_obj) else None
3550
+
3551
+ def _resolve_inputs(
3552
+ run: Any,
3553
+ _handler: Any = on_schedule_data_handler,
3554
+ ) -> Dict[str, Any]:
3555
+ if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
3556
+ _sync_effect_results_to_node_outputs(run, flow)
3557
+ if not callable(_handler):
3558
+ return {}
3559
+ last_output = run.vars.get("_last_output", {})
3560
+ try:
3561
+ resolved = _handler(last_output)
3562
+ except Exception:
3563
+ resolved = {}
3564
+ return resolved if isinstance(resolved, dict) else {}
3565
+
3566
+ schedule = "15s"
3567
+ recurrent = True
3568
+ if isinstance(effect_config, dict):
3569
+ raw_schedule = effect_config.get("schedule")
3570
+ if isinstance(raw_schedule, str) and raw_schedule.strip():
3571
+ schedule = raw_schedule.strip()
3572
+ raw_recurrent = effect_config.get("recurrent")
3573
+ if isinstance(raw_recurrent, bool):
3574
+ recurrent = raw_recurrent
3575
+
3576
+ handlers[node_id] = create_on_schedule_node_handler(
3577
+ node_id=node_id,
3578
+ next_node=next_node,
3579
+ resolve_inputs=_resolve_inputs if callable(on_schedule_data_handler) else None,
3580
+ schedule=schedule,
3581
+ recurrent=recurrent,
3582
+ flow=flow,
3583
+ )
3584
+ elif effect_type == "emit_event":
3585
+ from .adapters.event_adapter import create_emit_event_node_handler
3586
+
3587
+ emit_data_handler = handler_obj if callable(handler_obj) else None
3588
+
3589
+ def _resolve_inputs(
3590
+ run: Any,
3591
+ _handler: Any = emit_data_handler,
3592
+ ) -> Dict[str, Any]:
3593
+ if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
3594
+ _sync_effect_results_to_node_outputs(run, flow)
3595
+ if not callable(_handler):
3596
+ return {}
3597
+ last_output = run.vars.get("_last_output", {})
3598
+ try:
3599
+ resolved = _handler(last_output)
3600
+ except Exception:
3601
+ resolved = {}
3602
+ return resolved if isinstance(resolved, dict) else {}
3603
+
3604
+ default_name = ""
3605
+ default_session_id: Optional[str] = None
3606
+ scope = "session"
3607
+ if isinstance(effect_config, dict):
3608
+ raw_name = effect_config.get("name") or effect_config.get("event_name")
3609
+ if isinstance(raw_name, str) and raw_name.strip():
3610
+ default_name = raw_name
3611
+ raw_session = effect_config.get("session_id")
3612
+ if raw_session is None:
3613
+ raw_session = effect_config.get("sessionId")
3614
+ if isinstance(raw_session, str) and raw_session.strip():
3615
+ default_session_id = raw_session.strip()
3616
+ raw_scope = effect_config.get("scope")
3617
+ if isinstance(raw_scope, str) and raw_scope.strip():
3618
+ scope = raw_scope
3619
+
3620
+ handlers[node_id] = create_emit_event_node_handler(
3621
+ node_id=node_id,
3622
+ next_node=next_node,
3623
+ resolve_inputs=_resolve_inputs,
3624
+ default_name=default_name,
3625
+ default_session_id=default_session_id,
3626
+ scope=scope,
3627
+ )
3628
+ elif effect_type:
3629
+ # Pass the handler_obj as data_aware_handler if it's callable
3630
+ # This allows visual flows to resolve data edges before creating effects
3631
+ data_aware_handler = handler_obj if callable(handler_obj) else None
3632
+ handlers[node_id] = _create_effect_node_handler(
3633
+ node_id=node_id,
3634
+ effect_type=effect_type,
3635
+ effect_config=effect_config,
3636
+ next_node=next_node,
3637
+ input_key=getattr(flow_node, "input_key", None),
3638
+ output_key=getattr(flow_node, "output_key", None),
3639
+ data_aware_handler=data_aware_handler,
3640
+ flow=flow,
3641
+ )
3642
+ elif _is_agent(handler_obj):
3643
+ handlers[node_id] = create_agent_node_handler(
3644
+ node_id=node_id,
3645
+ agent=handler_obj,
3646
+ next_node=next_node,
3647
+ input_key=getattr(flow_node, "input_key", None),
3648
+ output_key=getattr(flow_node, "output_key", None),
3649
+ )
3650
+ elif _is_flow(handler_obj):
3651
+ # Nested flow - compile recursively
3652
+ nested_spec = compile_flow(handler_obj)
3653
+ handlers[node_id] = create_subflow_node_handler(
3654
+ node_id=node_id,
3655
+ nested_workflow=nested_spec,
3656
+ next_node=next_node,
3657
+ input_key=getattr(flow_node, "input_key", None),
3658
+ output_key=getattr(flow_node, "output_key", None),
3659
+ )
3660
+ elif visual_type == "set_var":
3661
+ from .adapters.variable_adapter import create_set_var_node_handler
3662
+
3663
+ data_aware_handler = handler_obj if callable(handler_obj) else None
3664
+ handlers[node_id] = create_set_var_node_handler(
3665
+ node_id=node_id,
3666
+ next_node=next_node,
3667
+ data_aware_handler=data_aware_handler,
3668
+ flow=flow,
3669
+ )
3670
+ elif visual_type == "add_message":
3671
+ from .adapters.context_adapter import create_add_message_node_handler
3672
+
3673
+ data_aware_handler = handler_obj if callable(handler_obj) else None
3674
+ handlers[node_id] = create_add_message_node_handler(
3675
+ node_id=node_id,
3676
+ next_node=next_node,
3677
+ data_aware_handler=data_aware_handler,
3678
+ flow=flow,
3679
+ )
3680
+ elif visual_type == "memact_compose":
3681
+ from .adapters.memact_adapter import create_memact_compose_node_handler
3682
+
3683
+ data_aware_handler = handler_obj if callable(handler_obj) else None
3684
+ handlers[node_id] = create_memact_compose_node_handler(
3685
+ node_id=node_id,
3686
+ next_node=next_node,
3687
+ data_aware_handler=data_aware_handler,
3688
+ flow=flow,
3689
+ )
3690
+ elif visual_type == "set_var_property":
3691
+ from .adapters.variable_adapter import create_set_var_property_node_handler
3692
+
3693
+ data_aware_handler = handler_obj if callable(handler_obj) else None
3694
+ handlers[node_id] = create_set_var_property_node_handler(
3695
+ node_id=node_id,
3696
+ next_node=next_node,
3697
+ data_aware_handler=data_aware_handler,
3698
+ flow=flow,
3699
+ )
3700
+ elif visual_type == "set_vars":
3701
+ from .adapters.variable_adapter import create_set_vars_node_handler
3702
+
3703
+ data_aware_handler = handler_obj if callable(handler_obj) else None
3704
+ handlers[node_id] = create_set_vars_node_handler(
3705
+ node_id=node_id,
3706
+ next_node=next_node,
3707
+ data_aware_handler=data_aware_handler,
3708
+ flow=flow,
3709
+ )
3710
+ elif callable(handler_obj):
3711
+ # Plain Flow callables should behave like standard function nodes (input defaults to run.vars).
3712
+ #
3713
+ # VisualFlow callables (builtins/code/function/etc.) need data-edge awareness, `_last_output`
3714
+ # pipelining, and persisted node outputs for pause/resume, so they use the visual handler.
3715
+ is_visual_flow = bool(
3716
+ hasattr(flow, "_data_edge_map") and hasattr(flow, "_node_outputs")
3717
+ ) or isinstance(visual_type, str)
3718
+
3719
+ if not is_visual_flow:
3720
+ handlers[node_id] = create_function_node_handler(
3721
+ node_id=node_id,
3722
+ func=handler_obj,
3723
+ next_node=next_node,
3724
+ input_key=getattr(flow_node, "input_key", None),
3725
+ output_key=getattr(flow_node, "output_key", None),
3726
+ )
3727
+ else:
3728
+ handlers[node_id] = _create_visual_function_handler(
3729
+ node_id=node_id,
3730
+ func=handler_obj,
3731
+ next_node=next_node,
3732
+ input_key=getattr(flow_node, "input_key", None),
3733
+ output_key=getattr(flow_node, "output_key", None),
3734
+ branch_map=branch_map,
3735
+ flow=flow,
3736
+ )
3737
+ else:
3738
+ raise TypeError(
3739
+ f"Unknown handler type for node '{node_id}': {type(handler_obj)}. "
3740
+ "Expected agent, function, or Flow."
3741
+ )
3742
+
3743
+ # Blueprint-style control flow: terminal nodes inside Sequence/Parallel should
3744
+ # return to the active scheduler instead of completing the whole run.
3745
+ handlers[node_id] = _wrap_return_to_active_control(
3746
+ handlers[node_id],
3747
+ node_id=node_id,
3748
+ visual_type=visual_type if isinstance(visual_type, str) else None,
3749
+ )
3750
+
3751
+ return WorkflowSpec(
3752
+ workflow_id=flow.flow_id,
3753
+ entry_node=flow.entry_node,
3754
+ nodes=handlers,
3755
+ )
3756
+
3757
+
3758
+ def compile_visualflow(raw_or_model: Any) -> "WorkflowSpec":
3759
+ """Compile a VisualFlow JSON object (or Pydantic-like model) into a WorkflowSpec."""
3760
+ from .visual.models import load_visualflow_json
3761
+ from .visual.executor import visual_to_flow
3762
+
3763
+ vf = load_visualflow_json(raw_or_model)
3764
+ flow = visual_to_flow(vf)
3765
+ return compile_flow(flow)
3766
+
3767
+
3768
+ def compile_visualflow_tree(
3769
+ *,
3770
+ root_id: Optional[str],
3771
+ flows_by_id: Dict[str, Any],
3772
+ ) -> Dict[str, "WorkflowSpec"]:
3773
+ """Compile a flow tree (root + referenced subflows) to WorkflowSpecs.
3774
+
3775
+ Notes:
3776
+ - This compiles VisualFlow JSON into callable WorkflowSpecs (not portable).
3777
+ - Subflow references are detected from `subflow` nodes (`subflowId`/`flowId`).
3778
+ - Cycles are allowed; each flow id is compiled once.
3779
+ """
3780
+ from .visual.models import load_visualflow_json
3781
+ from .visual.executor import visual_to_flow
3782
+
3783
+ parsed: Dict[str, Any] = {}
3784
+ for fid, raw in dict(flows_by_id or {}).items():
3785
+ fid2 = str(fid or "").strip()
3786
+ if not fid2:
3787
+ continue
3788
+ parsed[fid2] = load_visualflow_json(raw)
3789
+
3790
+ if not parsed:
3791
+ return {}
3792
+
3793
+ start = str(root_id or "").strip() or next(iter(parsed.keys()))
3794
+
3795
+ def _subflow_ref(node: Any) -> Optional[str]:
3796
+ data = getattr(node, "data", None)
3797
+ if not isinstance(data, dict):
3798
+ return None
3799
+ sid = data.get("subflowId") or data.get("flowId") or data.get("workflowId") or data.get("workflow_id")
3800
+ if isinstance(sid, str) and sid.strip():
3801
+ return sid.strip()
3802
+ return None
3803
+
3804
+ visited: set[str] = set()
3805
+ queue: list[str] = [start]
3806
+ while queue:
3807
+ fid = queue.pop(0)
3808
+ if not fid or fid in visited:
3809
+ continue
3810
+ vf = parsed.get(fid)
3811
+ if vf is None:
3812
+ # Missing subflow: skip (hosts may choose to error earlier).
3813
+ continue
3814
+ visited.add(fid)
3815
+ for n in getattr(vf, "nodes", []) or []:
3816
+ t = getattr(n, "type", None)
3817
+ type_str = t.value if hasattr(t, "value") else str(t or "")
3818
+ if type_str != "subflow":
3819
+ continue
3820
+ sid = _subflow_ref(n)
3821
+ if sid:
3822
+ queue.append(sid)
3823
+
3824
+ specs: Dict[str, "WorkflowSpec"] = {}
3825
+ for fid in visited:
3826
+ vf = parsed.get(fid)
3827
+ if vf is None:
3828
+ continue
3829
+ f = visual_to_flow(vf)
3830
+ spec = compile_flow(f)
3831
+ specs[str(spec.workflow_id)] = spec
3832
+ return specs