"""Flow compiler - converts Flow definitions to AbstractRuntime WorkflowSpec."""

from __future__ import annotations

from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING

from .flow import Flow
from .adapters.function_adapter import create_function_node_handler
from .adapters.agent_adapter import create_agent_node_handler
from .adapters.subflow_adapter import create_subflow_node_handler
from .adapters.effect_adapter import (
    create_ask_user_handler,
    create_answer_user_handler,
    create_wait_until_handler,
    create_wait_event_handler,
    create_memory_kg_assert_handler,
    create_memory_kg_query_handler,
    create_memory_kg_resolve_handler,
    create_memory_note_handler,
    create_memory_query_handler,
    create_memory_tag_handler,
    create_memory_compact_handler,
    create_memory_rehydrate_handler,
    create_llm_call_handler,
    create_tool_calls_handler,
    create_call_tool_handler,
    create_start_subworkflow_handler,
)

if TYPE_CHECKING:
    from abstractruntime.core.models import StepPlan
    from abstractruntime.core.spec import WorkflowSpec


def _is_agent(obj: Any) -> bool:
    """Check if object is an agent (has workflow attribute)."""
    return hasattr(obj, "workflow") and hasattr(obj, "start") and hasattr(obj, "step")


def _is_flow(obj: Any) -> bool:
    """Check if object behaves like a Flow.

    Accept duck-typed Flow implementations (e.g. AbstractFlow's Flow class)
    so hosts can compile nested flows without depending on a specific class
    identity.
    """
    if isinstance(obj, Flow):
        return True
    try:
        flow_id = getattr(obj, "flow_id", None)
        nodes = getattr(obj, "nodes", None)
        edges = getattr(obj, "edges", None)
        validate = getattr(obj, "validate", None)
        return (
            isinstance(flow_id, str)
            and isinstance(nodes, dict)
            and isinstance(edges, list)
            and callable(validate)
        )
    except Exception:
        return False


def _create_effect_node_handler(
    node_id: str,
    effect_type: str,
    effect_config: Dict[str, Any],
    next_node: Optional[str],
    input_key: Optional[str],
    output_key: Optional[str],
    data_aware_handler: Optional[Callable] = None,
    *,
    flow: Optional[Flow] = None,
) -> Callable:
    """Create a node handler for effect nodes.

    Effect nodes produce AbstractRuntime Effects that can pause execution
    and wait for external input.

    If data_aware_handler is provided (from visual flow's executor), it will
    be called first to resolve data edge inputs before creating the effect.
    """
    from abstractruntime.core.models import StepPlan, Effect, EffectType

    # Build the base effect handler
    if effect_type == "ask_user":
        base_handler = create_ask_user_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
            allow_free_text=effect_config.get("allowFreeText", True),
        )
    elif effect_type == "answer_user":
        base_handler = create_answer_user_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
        )
    elif effect_type == "wait_until":
        base_handler = create_wait_until_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
            duration_type=effect_config.get("durationType", "seconds"),
        )
    elif effect_type == "wait_event":
        base_handler = create_wait_event_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
        )
    elif effect_type == "memory_note":
        base_handler = create_memory_note_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
        )
    elif effect_type == "memory_query":
        base_handler = create_memory_query_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
        )
    elif effect_type == "memory_tag":
        base_handler = create_memory_tag_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
        )
    elif effect_type == "memory_compact":
        base_handler = create_memory_compact_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
        )
    elif effect_type == "memory_rehydrate":
        base_handler = create_memory_rehydrate_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
        )
    elif effect_type == "memory_kg_assert":
        base_handler = create_memory_kg_assert_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
        )
    elif effect_type == "memory_kg_query":
        base_handler = create_memory_kg_query_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
        )
    elif effect_type == "memory_kg_resolve":
        base_handler = create_memory_kg_resolve_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
        )
    elif effect_type in {"llm_call", "generate_image", "generate_voice", "transcribe_audio"}:
        base_handler = create_llm_call_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
            provider=effect_config.get("provider"),
            model=effect_config.get("model"),
            temperature=effect_config.get("temperature", 0.7),
            seed=effect_config.get("seed", -1),
        )
    elif effect_type == "listen_voice":
        base_handler = create_wait_event_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
        )
    elif effect_type == "tool_calls":
        base_handler = create_tool_calls_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
            allowed_tools=effect_config.get("allowed_tools") if isinstance(effect_config, dict) else None,
        )
    elif effect_type == "call_tool":
        base_handler = create_call_tool_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
            allowed_tools=effect_config.get("allowed_tools") if isinstance(effect_config, dict) else None,
        )
    elif effect_type == "start_subworkflow":
        base_handler = create_start_subworkflow_handler(
            node_id=node_id,
            next_node=next_node,
            input_key=input_key,
            output_key=output_key,
            workflow_id=effect_config.get("workflow_id"),
        )
    else:
        raise ValueError(f"Unknown effect type: {effect_type}")

    # If no data-aware handler, just return the base effect handler
    if data_aware_handler is None:
        return base_handler

    # Wrap to resolve data edges before creating the effect
    def wrapped_effect_handler(run: Any, ctx: Any) -> "StepPlan":
        """Resolve data edges via executor handler, then create the proper Effect."""
        if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
            _sync_effect_results_to_node_outputs(run, flow)

        # Call the data-aware handler to resolve data edge inputs
        # This reads from flow._node_outputs which has literal values
        last_output = run.vars.get("_last_output", {})
        resolved = data_aware_handler(last_output)

        # Check if this returned a _pending_effect marker (from executor's effect handlers)
        if isinstance(resolved, dict) and "_pending_effect" in resolved:
            pending = resolved["_pending_effect"]
            effect_type_str = pending.get("type", "")

            # Get the EffectType enum value by name (avoid building dict with all members)
            eff_type = None
            try:
                eff_type = EffectType(effect_type_str)
            except ValueError:
                pass  # Unknown effect type
            if eff_type:
                # ------------------------------------------------------------
                # 467: Memory-source access pins for Visual LLM Call nodes
                # ------------------------------------------------------------
                #
                # Visual executor passes memory controls through on the pending effect dict.
                # Here we:
                # - schedule runtime-owned MEMORY_* effects before the LLM call
                # - inject KG Active Memory into the call (bounded, deterministic)
                # - map session attachment index pin -> LLM params override
                #
                # IMPORTANT:
                # - Pre-call effects MUST NOT write to `_temp.effects.{node_id}` because that
                #   slot is reserved for the LLM_CALL outcome and is synced to node outputs.
                if eff_type == EffectType.LLM_CALL and isinstance(pending, dict):
                    try:
                        import hashlib
                        import json

                        def _ensure_dict(parent: Any, key: str) -> dict:
                            if not isinstance(parent, dict):
                                return {}
                            cur = parent.get(key)
                            if not isinstance(cur, dict):
                                cur = {}
                                parent[key] = cur
                            return cur

                        def _coerce_boolish(value: Any) -> Optional[bool]:
                            if value is None:
                                return None
                            if isinstance(value, bool):
                                return bool(value)
                            if isinstance(value, (int, float)) and not isinstance(value, bool):
                                try:
                                    return float(value) != 0.0
                                except Exception:
                                    return None
                            if isinstance(value, str):
                                s = value.strip().lower()
                                if not s:
                                    return None
                                if s in {"false", "0", "no", "off"}:
                                    return False
                                if s in {"true", "1", "yes", "on"}:
                                    return True
                            return None

                        def _coerce_int(value: Any) -> Optional[int]:
                            if value is None or isinstance(value, bool):
                                return None
                            try:
                                iv = int(float(value))
                            except Exception:
                                return None
                            return iv

                        def _coerce_float(value: Any) -> Optional[float]:
                            if value is None or isinstance(value, bool):
                                return None
                            try:
                                fv = float(value)
                            except Exception:
                                return None
                            if not (fv == fv):  # NaN
                                return None
                            return fv

                        def _nonempty_str(value: Any) -> str:
                            if not isinstance(value, str):
                                return ""
                            return value.strip()

                        def _hash_fingerprint(obj: dict) -> str:
                            try:
                                raw = json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
                            except Exception:
                                raw = str(obj)
                            return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]

                        temp = _ensure_dict(run.vars, "_temp")
                        memory_sources = _ensure_dict(temp, "memory_sources")
                        bucket = memory_sources.get(node_id)
                        if not isinstance(bucket, dict):
                            bucket = {}
                            memory_sources[node_id] = bucket
                        meta = bucket.get("_meta")
                        if not isinstance(meta, dict):
                            meta = {}
                            bucket["_meta"] = meta

                        # Memory config (tri-state booleans: absent => no override).
                        use_span_memory = _coerce_boolish(pending.get("use_span_memory")) if "use_span_memory" in pending else None
                        use_kg_memory = _coerce_boolish(pending.get("use_kg_memory")) if "use_kg_memory" in pending else None
                        use_semantic_search = (
                            _coerce_boolish(pending.get("use_semantic_search")) if "use_semantic_search" in pending else None
                        )
                        use_session_attachments = (
                            _coerce_boolish(pending.get("use_session_attachments")) if "use_session_attachments" in pending else None
                        )

                        recall_level = _nonempty_str(pending.get("recall_level"))
                        memory_scope = _nonempty_str(pending.get("memory_scope")) or "run"

                        # Derive query text (best-effort) from pins or the call prompt.
                        query_text = _nonempty_str(pending.get("memory_query"))
                        if not query_text:
                            query_text = _nonempty_str(pending.get("prompt"))
                        if not query_text and isinstance(pending.get("messages"), list):
                            # Fallback: last user message in a message-based call.
                            for m in reversed(list(pending.get("messages") or [])):
                                if not isinstance(m, dict):
                                    continue
                                if m.get("role") != "user":
                                    continue
                                c = m.get("content")
                                if isinstance(c, str) and c.strip():
                                    query_text = c.strip()
                                    break

                        # Re-entry / loop safety:
                        # If the node is executed again with different inputs, clear the per-node
                        # memory-source bucket so we don't reuse stale recall results.
                        fp = _hash_fingerprint(
                            {
                                "prompt": _nonempty_str(pending.get("prompt")),
                                "system_prompt": _nonempty_str(pending.get("system_prompt")),
                                "provider": _nonempty_str(pending.get("provider")),
                                "model": _nonempty_str(pending.get("model")),
                                "memory_query": query_text,
                                "memory_scope": memory_scope,
                                "recall_level": recall_level,
                                "use_span_memory": use_span_memory,
                                "use_kg_memory": use_kg_memory,
                                "use_semantic_search": use_semantic_search,
                                "use_session_attachments": use_session_attachments,
                                "max_span_messages": _coerce_int(pending.get("max_span_messages")),
                                "kg_max_input_tokens": _coerce_int(pending.get("kg_max_input_tokens")),
                                "kg_limit": _coerce_int(pending.get("kg_limit")),
                                "kg_min_score": _coerce_float(pending.get("kg_min_score")),
                            }
                        )
                        if meta.get("fingerprint") != fp:
                            try:
                                bucket.clear()
                            except Exception:
                                bucket = {}
                                memory_sources[node_id] = bucket
                            meta = {"fingerprint": fp}
                            bucket["_meta"] = meta

                        base_key = f"_temp.memory_sources.{node_id}"

                        # Reserved: semantic search over artifacts is planned (464). Avoid silently doing nothing.
                        if use_semantic_search is True:
                            warnings = meta.get("warnings")
                            if not isinstance(warnings, list):
                                warnings = []
                                meta["warnings"] = warnings
                            msg = "use_semantic_search is not implemented yet (planned: 464); ignoring."
                            if msg not in warnings:
                                warnings.append(msg)

                        # Phase 1: span-index recall (metadata)
                        if use_span_memory is True and "span_query" not in bucket and query_text:
                            mq_payload: Dict[str, Any] = {"query": query_text, "scope": memory_scope, "return": "meta"}
                            if recall_level:
                                mq_payload["recall_level"] = recall_level
                            return StepPlan(
                                node_id=node_id,
                                effect=Effect(
                                    type=EffectType.MEMORY_QUERY,
                                    payload=mq_payload,
                                    result_key=f"{base_key}.span_query",
                                ),
                                next_node=node_id,
                            )

                        # Phase 2: span rehydration into context.messages
                        if use_span_memory is True and "span_rehydrate" not in bucket:
                            span_ids: list[str] = []
                            span_query = bucket.get("span_query")
                            if isinstance(span_query, dict):
                                results = span_query.get("results")
                                if isinstance(results, list) and results:
                                    first = results[0] if isinstance(results[0], dict) else None
                                    meta2 = first.get("meta") if isinstance(first, dict) else None
                                    if isinstance(meta2, dict):
                                        raw_ids = meta2.get("span_ids")
                                        if isinstance(raw_ids, list):
                                            span_ids = [str(x).strip() for x in raw_ids if str(x).strip()]
                            meta["span_ids"] = span_ids

                            mr_payload: Dict[str, Any] = {"span_ids": span_ids, "placement": "after_summary"}
                            if recall_level:
                                mr_payload["recall_level"] = recall_level
                            max_span_messages = _coerce_int(pending.get("max_span_messages"))
                            if max_span_messages is None and not recall_level:
                                # Safe default (explicit warning): without recall_level, MEMORY_REHYDRATE would
                                # otherwise insert all messages from selected spans.
                                max_span_messages = 80
                                warnings = meta.get("warnings")
                                if not isinstance(warnings, list):
                                    warnings = []
                                    meta["warnings"] = warnings
                                msg = "use_span_memory enabled without recall_level/max_span_messages; defaulting max_span_messages=80."
                                if msg not in warnings:
                                    warnings.append(msg)
                            if isinstance(max_span_messages, int):
                                mr_payload["max_messages"] = max_span_messages

                            return StepPlan(
                                node_id=node_id,
                                effect=Effect(
                                    type=EffectType.MEMORY_REHYDRATE,
                                    payload=mr_payload,
                                    result_key=f"{base_key}.span_rehydrate",
                                ),
                                next_node=node_id,
                            )

                        # Phase 3: KG recall (packetized Active Memory)
                        if use_kg_memory is True and "kg_query" not in bucket and query_text:
                            kg_payload: Dict[str, Any] = {"query_text": query_text, "scope": memory_scope}
                            if recall_level:
                                kg_payload["recall_level"] = recall_level
                            kg_min_score = _coerce_float(pending.get("kg_min_score"))
                            if isinstance(kg_min_score, float):
                                kg_payload["min_score"] = kg_min_score
                            kg_limit = _coerce_int(pending.get("kg_limit"))
                            if isinstance(kg_limit, int):
                                kg_payload["limit"] = kg_limit
                            kg_budget = _coerce_int(pending.get("kg_max_input_tokens"))
                            if kg_budget is None and not recall_level:
                                # Safe default (explicit warning): ensure we actually get `active_memory_text`
                                # even when recall_level is not set.
                                kg_budget = 1200
                                warnings = meta.get("warnings")
                                if not isinstance(warnings, list):
                                    warnings = []
                                    meta["warnings"] = warnings
                                msg = "use_kg_memory enabled without recall_level/kg_max_input_tokens; defaulting kg_max_input_tokens=1200."
                                if msg not in warnings:
                                    warnings.append(msg)
                            if isinstance(kg_budget, int):
                                kg_payload["max_input_tokens"] = kg_budget
                            model_name = pending.get("model")
                            if isinstance(model_name, str) and model_name.strip():
                                kg_payload["model"] = model_name.strip()

                            return StepPlan(
                                node_id=node_id,
                                effect=Effect(
                                    type=EffectType.MEMORY_KG_QUERY,
                                    payload=kg_payload,
                                    result_key=f"{base_key}.kg_query",
                                ),
                                next_node=node_id,
                            )

                        # Map session attachment index pin -> params override (explicit, auditable).
                        if use_session_attachments is not None:
                            params = pending.get("params")
                            if not isinstance(params, dict):
                                params = {}
                                pending["params"] = params
                            params["include_session_attachments_index"] = bool(use_session_attachments)

                        # Inject KG Active Memory.
                        if use_kg_memory is True:
                            active_text = ""
                            kg_query = bucket.get("kg_query")
                            if isinstance(kg_query, dict):
                                am = kg_query.get("active_memory_text")
                                active_text = am.strip() if isinstance(am, str) else ""
                            if active_text:
                                meta["kg_active_memory_text_len"] = len(active_text)

                                # Avoid duplicating the same block when the node loops.
                                def _has_active_memory_block(msgs: list[Any]) -> bool:
                                    for m in msgs:
                                        if not isinstance(m, dict) or m.get("role") != "system":
                                            continue
                                        c = m.get("content")
                                        if isinstance(c, str) and "## KG ACTIVE MEMORY" in c:
                                            return True
                                    return False

                                if isinstance(pending.get("messages"), list):
                                    msgs = [dict(m) for m in pending.get("messages") if isinstance(m, dict)]
                                    if not _has_active_memory_block(msgs):
                                        insert_at = 0
                                        while insert_at < len(msgs):
                                            if msgs[insert_at].get("role") != "system":
                                                break
                                            insert_at += 1
                                        msgs.insert(insert_at, {"role": "system", "content": active_text})
                                        pending["messages"] = msgs
                                else:
                                    sys_raw = pending.get("system_prompt") or pending.get("system")
                                    sys_text = sys_raw.strip() if isinstance(sys_raw, str) else ""
                                    combined = f"{sys_text}\n\n{active_text}".strip() if sys_text else active_text
                                    pending["system_prompt"] = combined

                        # Span rehydrate without conversation history:
                        # If span recall is enabled but include_context is false, inject the rehydrated
                        # messages as the call's message history so the current call can use them.
                        include_ctx = pending.get("include_context") is True or pending.get("use_context") is True
                        if use_span_memory is True and not include_ctx and "messages" not in pending:
                            span_ids = meta.get("span_ids")
                            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()
                            recalled_msgs: list[Dict[str, Any]] = []
                            ctx_ns = run.vars.get("context") if isinstance(run.vars, dict) else None
                            raw_msgs = ctx_ns.get("messages") if isinstance(ctx_ns, dict) else None
                            if isinstance(raw_msgs, list) and span_ids_set:
                                for m_any in raw_msgs:
                                    m = m_any if isinstance(m_any, dict) else None
                                    if m is None:
                                        continue
                                    meta_m = m.get("metadata")
                                    if not isinstance(meta_m, dict) or meta_m.get("rehydrated") is not True:
                                        continue
                                    src = meta_m.get("source_artifact_id")
                                    src_id = str(src).strip() if isinstance(src, str) else ""
                                    if src_id and src_id in span_ids_set:
                                        recalled_msgs.append(dict(m))

                            if recalled_msgs:
                                sys_raw = pending.get("system_prompt") or pending.get("system")
                                sys_text = sys_raw.strip() if isinstance(sys_raw, str) else ""
                                prompt_raw = pending.get("prompt")
                                prompt_text = prompt_raw if isinstance(prompt_raw, str) else str(prompt_raw or "")

                                msgs: list[Dict[str, Any]] = []
                                if sys_text:
                                    msgs.append({"role": "system", "content": sys_text})
                                msgs.extend(recalled_msgs)
                                msgs.append({"role": "user", "content": prompt_text})

                                # Best-effort token trim when an explicit max_input_tokens budget is provided.
                                try:
                                    raw_max_in = pending.get("max_input_tokens")
                                    max_in = int(raw_max_in) if raw_max_in is not None and not isinstance(raw_max_in, bool) else None
                                except Exception:
                                    max_in = None
                                if isinstance(max_in, int) and max_in > 0:
                                    try:
                                        from abstractruntime.memory.token_budget import trim_messages_to_max_input_tokens

                                        model_name = (
                                            pending.get("model") if isinstance(pending.get("model"), str) else None
                                        )
                                        msgs = trim_messages_to_max_input_tokens(
                                            msgs, max_input_tokens=int(max_in), model=model_name
                                        )
                                    except Exception:
                                        pass

                                pending["messages"] = msgs
                                pending.pop("prompt", None)
                                pending.pop("system_prompt", None)
                                pending.pop("system", None)
                    except Exception:
                        # Never fail compilation/execution due to optional memory-source wiring.
                        pass

                # Visual LLM Call UX: include the run's active context messages when possible.
                #
                # Why here (compiler) and not in AbstractRuntime:
                # - LLM_CALL is a generic runtime effect; not all callers want implicit context.
                # - Visual LLM Call nodes expect "Recall into context" to affect subsequent calls.
                if (
                    eff_type == EffectType.LLM_CALL
                    and isinstance(pending, dict)
                    and "messages" not in pending
                    and pending.get("include_context") is True
                ):
                    try:
                        from abstractruntime.memory.active_context import ActiveContextPolicy

                        base = ActiveContextPolicy.select_active_messages_for_llm_from_run(run)
                        messages = [dict(m) for m in base if isinstance(m, dict)]

                        sys_raw = pending.get("system_prompt") or pending.get("system")
                        sys_text = str(sys_raw).strip() if isinstance(sys_raw, str) else ""
                        if sys_text:
                            # Insert after existing system messages (system must precede user/assistant).
                            insert_at = 0
                            while insert_at < len(messages):
                                m = messages[insert_at]
                                if not isinstance(m, dict) or m.get("role") != "system":
                                    break
                                insert_at += 1
                            messages.insert(insert_at, {"role": "system", "content": sys_text})

                        prompt_raw = pending.get("prompt")
                        prompt_text = prompt_raw if isinstance(prompt_raw, str) else str(prompt_raw or "")
                        messages.append({"role": "user", "content": prompt_text})

                        # Token-budget enforcement (ADR-0008):
                        # If `_limits.max_input_tokens` is set, trim the oldest non-system messages
                        # so the final LLM request remains bounded even when contexts grow large.
                        try:
                            limits = run.vars.get("_limits") if isinstance(run.vars, dict) else None
                            limits = limits if isinstance(limits, dict) else {}
                            raw_max_in = (
                                pending.get("max_input_tokens")
                                if isinstance(pending, dict) and "max_input_tokens" in pending
                                else limits.get("max_input_tokens")
                            )
                            if raw_max_in is not None and not isinstance(raw_max_in, bool):
                                max_in = int(raw_max_in)
                            else:
                                max_in = None
                        except Exception:
                            max_in = None
                        if isinstance(max_in, int) and max_in > 0:
                            try:
                                from abstractruntime.memory.token_budget import (
                                    trim_messages_to_max_input_tokens,
                                )

                                model_name = (
                                    pending.get("model") if isinstance(pending.get("model"), str) else None
                                )
                                messages = trim_messages_to_max_input_tokens(
                                    messages, max_input_tokens=int(max_in), model=model_name
                                )
                            except Exception:
                                # Never fail compilation/execution due to token trimming.
                                pass

                        pending["messages"] = messages
                        # Avoid double-including prompt/system_prompt if the LLM client also
                        # builds messages from them.
                        pending.pop("prompt", None)
                        pending.pop("system_prompt", None)
                        pending.pop("system", None)
                    except Exception:
                        pass

                # If the run has context attachments, forward them into the LLM call as `media`
                # (unless the node/pins already provided an explicit `media` override).
                if (
                    eff_type == EffectType.LLM_CALL
                    and isinstance(pending, dict)
                    and pending.get("include_context") is True
                    and "media" not in pending
                ):
                    try:
                        ctx2 = run.vars.get("context") if isinstance(run.vars, dict) else None
                        attachments_raw = ctx2.get("attachments") if isinstance(ctx2, dict) else None
                        if isinstance(attachments_raw, list) and attachments_raw:
                            cleaned: list[Any] = []
                            for a in attachments_raw:
                                if isinstance(a, dict):
                                    cleaned.append(dict(a))
                                elif isinstance(a, str) and a.strip():
                                    cleaned.append(a.strip())
                            if cleaned:
                                pending["media"] = cleaned
                    except Exception:
                        pass

                # Visual Subflow UX: optionally seed the child run's `context.messages` from the
                # parent run's active context view (so LLM/Agent nodes inside the subflow can
                # "Use context" without extra wiring).
                if (
                    eff_type == EffectType.START_SUBWORKFLOW
                    and isinstance(pending, dict)
                    and pending.get("inherit_context") is True
                ):
                    try:
                        from abstractruntime.memory.active_context import ActiveContextPolicy

                        inherited = ActiveContextPolicy.select_active_messages_for_llm_from_run(run)
                        inherited_msgs = [dict(m) for m in inherited if isinstance(m, dict)]
                        if inherited_msgs:
                            sub_vars = pending.get("vars")
                            if not isinstance(sub_vars, dict):
                                sub_vars = {}
                            sub_ctx = sub_vars.get("context")
                            if not isinstance(sub_ctx, dict):
                                sub_ctx = {}
                                sub_vars["context"] = sub_ctx

                            existing = sub_ctx.get("messages")
                            existing_msgs = [dict(m) for m in existing if isinstance(m, dict)] if isinstance(existing, list) else []

                            # Merge semantics:
                            # - preserve any explicit child system messages at the top
                            # - include the parent's active context (history) so recursive subflows
                            #   see the latest turns even when the child pins provide a stale subset
                            # - keep any extra explicit child messages (e.g. an injected "next step")
                            #
                            # Dedup strictly by metadata.message_id when present. (Messages without
                            # ids are treated as distinct to avoid collapsing legitimate repeats.)
                            seen_ids: set[str] = set()

                            def _msg_id(m: Dict[str, Any]) -> str:
                                meta = m.get("metadata")
                                if isinstance(meta, dict):
                                    mid = meta.get("message_id")
                                    if isinstance(mid, str) and mid.strip():
                                        return mid.strip()
                                return ""

                            def _append(dst: list[Dict[str, Any]], m: Dict[str, Any]) -> None:
                                mid = _msg_id(m)
                                if mid:
                                    if mid in seen_ids:
                                        return
                                    seen_ids.add(mid)
                                dst.append(m)

                            merged: list[Dict[str, Any]] = []
                            # 1) Child system messages first (explicit overrides)
                            for m in existing_msgs:
                                if m.get("role") == "system":
                                    _append(merged, m)
                            # 2) Parent system messages
                            for m in inherited_msgs:
                                if m.get("role") == "system":
                                    _append(merged, m)
                            # 3) Parent conversation history
                            for m in inherited_msgs:
                                if m.get("role") != "system":
                                    _append(merged, m)
                            # 4) Remaining child messages (extras)
                            for m in existing_msgs:
                                if m.get("role") != "system":
                                    _append(merged, m)

                            sub_ctx["messages"] = merged

                            # Best-effort: also inherit parent context attachments, unless explicitly set on the child.
                            if "attachments" not in sub_ctx:
                                parent_ctx = run.vars.get("context") if isinstance(run.vars, dict) else None
                                raw_attachments = parent_ctx.get("attachments") if isinstance(parent_ctx, dict) else None
                                if isinstance(raw_attachments, list) and raw_attachments:
                                    cleaned_att: list[Any] = []
                                    for a in raw_attachments:
                                        if isinstance(a, dict):
                                            cleaned_att.append(dict(a))
                                        elif isinstance(a, str) and a.strip():
                                            cleaned_att.append(a.strip())
                                    if cleaned_att:
                                        sub_ctx["attachments"] = cleaned_att

                            pending["vars"] = sub_vars
                    except Exception:
                        pass
                    # Keep payload clean (runtime ignores it, but it clutters traces).
                    pending.pop("inherit_context", None)

                # Build the Effect with resolved values from data edges
                effect = Effect(
                    type=eff_type,
                    payload={
                        **pending,
                        "resume_to_node": next_node,
                    },
                    # Always store effect outcomes per-node; visual syncing can optionally copy to output_key.
                    result_key=f"_temp.effects.{node_id}",
                )

                return StepPlan(
                    node_id=node_id,
                    effect=effect,
                    next_node=next_node,
                )

        # Fallback: run.vars won't have the values, but try anyway
        return base_handler(run, ctx)

    return wrapped_effect_handler


def _create_visual_agent_effect_handler(
    *,
    node_id: str,
    next_node: Optional[str],
    agent_config: Dict[str, Any],
    data_aware_handler: Optional[Callable[[Any], Any]],
    flow: Flow,
) -> Callable:
    """Create a handler for the visual Agent node.

    Visual Agent nodes delegate to AbstractAgent's canonical ReAct workflow
    via `START_SUBWORKFLOW` (runtime-owned execution and persistence).

    This handler:
    - resolves `task` / `context` via data edges
    - starts the configured ReAct subworkflow (sync; may wait)
    - exposes the final agent result and trace ("scratchpad") via output pins
    - optionally performs a final structured-output LLM_CALL (format-only pass)
    """
    import json

    from abstractruntime.core.models import Effect, EffectType, StepPlan

    from .visual.agent_ids import visual_react_workflow_id

    def _ensure_temp_dict(run: Any) -> Dict[str, Any]:
        temp = run.vars.get("_temp")
        if not isinstance(temp, dict):
            temp = {}
            run.vars["_temp"] = temp
        return temp

    def _get_agent_bucket(run: Any) -> Dict[str, Any]:
        temp = _ensure_temp_dict(run)
        agent = temp.get("agent")
        if not isinstance(agent, dict):
            agent = {}
            temp["agent"] = agent
        bucket = agent.get(node_id)
        if not isinstance(bucket, dict):
            bucket = {}
            agent[node_id] = bucket
        return bucket

    def _get_memory_sources_bucket(run: Any) -> Dict[str, Any]:
        temp = _ensure_temp_dict(run)
        mem = temp.get("memory_sources")
        if not isinstance(mem, dict):
            mem = {}
            temp["memory_sources"] = mem
        bucket = mem.get(node_id)
        if not isinstance(bucket, dict):
            bucket = {}
            mem[node_id] = bucket
        return bucket

    def _resolve_inputs(run: Any) -> Dict[str, Any]:
        if hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
            _sync_effect_results_to_node_outputs(run, flow)

        if not callable(data_aware_handler):
            return {}
        last_output = run.vars.get("_last_output", {})
        try:
            resolved = data_aware_handler(last_output)
        except Exception:
            resolved = {}
        return resolved if isinstance(resolved, dict) else {}

    def _flatten_node_traces(node_traces: Any) -> list[Dict[str, Any]]:
        if not isinstance(node_traces, dict):
            return []
        out: list[Dict[str, Any]] = []
        for trace in node_traces.values():
            if not isinstance(trace, dict):
                continue
            steps = trace.get("steps")
            if not isinstance(steps, list):
                continue
            for s in steps:
                if isinstance(s, dict):
                    out.append(dict(s))
        out.sort(key=lambda s: str(s.get("ts") or ""))
        return out

    def _as_dict_list(value: Any) -> list[Dict[str, Any]]:
        if value is None:
            return []
        if isinstance(value, dict):
            return [dict(value)]
        if isinstance(value, list):
            out: list[Dict[str, Any]] = []
            for x in value:
                if isinstance(x, dict):
                    out.append(dict(x))
            return out
        return []

    def _extract_tool_activity_from_steps(steps: Any) -> tuple[list[Dict[str, Any]], list[Dict[str, Any]]]:
        """Best-effort tool call/result extraction from flattened scratchpad steps."""
        if not isinstance(steps, list):
            return [], []
        tool_calls: list[Dict[str, Any]] = []
        tool_results: list[Dict[str, Any]] = []
        for entry_any in steps:
            entry = entry_any if isinstance(entry_any, dict) else None
            if entry is None:
                continue
            effect = entry.get("effect")
            if not isinstance(effect, dict) or str(effect.get("type") or "") != "tool_calls":
                continue
            payload = effect.get("payload")
            payload_d = payload if isinstance(payload, dict) else {}
            tool_calls.extend(_as_dict_list(payload_d.get("tool_calls")))

            result = entry.get("result")
            if not isinstance(result, dict):
                continue
            tool_results.extend(_as_dict_list(result.get("results")))
        return tool_calls, tool_results

    def _build_sub_vars(
        run: Any,
        *,
        task: str,
        context: Dict[str, Any],
        provider: str,
        model: str,
        system_prompt: str,
        allowed_tools: list[str],
        temperature: float = 0.7,
        seed: int = -1,
        include_context: bool = False,
        max_iterations: Optional[int] = None,
        max_input_tokens: Optional[int] = None,
        max_output_tokens: Optional[int] = None,
        extra_messages: Optional[list[Dict[str, Any]]] = None,
        include_session_attachments_index: Optional[bool] = None,
    ) -> Dict[str, Any]:
        parent_limits = run.vars.get("_limits")
        limits = dict(parent_limits) if isinstance(parent_limits, dict) else {}
        limits.setdefault("max_iterations", 50)
        limits.setdefault("current_iteration", 0)
        from abstractruntime.core.vars import DEFAULT_MAX_TOKENS

        limits.setdefault("max_tokens", DEFAULT_MAX_TOKENS)
        limits.setdefault("max_output_tokens", None)
        limits.setdefault("max_input_tokens", None)
        limits.setdefault("max_history_messages", -1)
        limits.setdefault("estimated_tokens_used", 0)
        limits.setdefault("warn_iterations_pct", 80)
        limits.setdefault("warn_tokens_pct", 80)

        if isinstance(max_iterations, int) and max_iterations > 0:
            limits["max_iterations"] = int(max_iterations)

        if isinstance(max_input_tokens, int) and max_input_tokens > 0:
            limits["max_input_tokens"] = int(max_input_tokens)

        if isinstance(max_output_tokens, int) and max_output_tokens > 0:
            limits["max_output_tokens"] = int(max_output_tokens)

        ctx_ns: Dict[str, Any] = {"task": str(task or ""), "messages": []}

        # Optional: inherit the parent's active context as agent history (including Recall into context inserts).
        # This is a visual-editor UX feature; it is disabled by default and can be enabled via
        # agentConfig.include_context or the include_context input pin.
        if bool(include_context):
            try:
                from abstractruntime.memory.active_context import ActiveContextPolicy

                base = ActiveContextPolicy.select_active_messages_for_llm_from_run(run)
                if isinstance(base, list):
                    ctx_ns["messages"] = [dict(m) for m in base if isinstance(m, dict)]
            except Exception:
                pass
            # Best-effort: inherit parent attachments as part of context when "use context" is enabled.
            try:
                parent_ctx = run.vars.get("context") if isinstance(run.vars, dict) else None
                raw_attachments = parent_ctx.get("attachments") if isinstance(parent_ctx, dict) else None
                if isinstance(raw_attachments, list) and raw_attachments:
                    cleaned_att: list[Any] = []
                    for a in raw_attachments:
                        if isinstance(a, dict):
                            cleaned_att.append(dict(a))
                        elif isinstance(a, str) and a.strip():
                            cleaned_att.append(a.strip())
                    if cleaned_att:
                        ctx_ns["attachments"] = cleaned_att
            except Exception:
                pass

        # Explicit context.messages from a pin overrides the inherited run context.
        raw_msgs = context.get("messages") if isinstance(context, dict) else None
        if isinstance(raw_msgs, list):
            msgs = [dict(m) for m in raw_msgs if isinstance(m, dict)]
            if msgs:
                ctx_ns["messages"] = msgs

        # Optional: inject additional memory/system messages (467).
        #
        # This is used by the Visual Agent node to include span/KG recall even when
        # include_context is false (without pulling in the full conversation history).
        if isinstance(extra_messages, list) and extra_messages:
            base_msgs = ctx_ns.get("messages")
            base_msgs = [dict(m) for m in base_msgs if isinstance(m, dict)] if isinstance(base_msgs, list) else []
            extra_msgs = [dict(m) for m in extra_messages if isinstance(m, dict)]

            seen_ids: set[str] = set()

            def _msg_id(m: Dict[str, Any]) -> str:
                meta = m.get("metadata")
                if isinstance(meta, dict):
                    mid = meta.get("message_id")
                    if isinstance(mid, str) and mid.strip():
                        return mid.strip()
                return ""

            def _append(dst: list[Dict[str, Any]], m: Dict[str, Any]) -> None:
                mid = _msg_id(m)
                if mid:
                    if mid in seen_ids:
                        return
                    seen_ids.add(mid)
                dst.append(m)

            merged: list[Dict[str, Any]] = []
            # Preserve base system messages at the very top.
            for m in base_msgs:
                if m.get("role") == "system":
                    _append(merged, m)
            # Then injected system messages.
            for m in extra_msgs:
                if m.get("role") == "system":
                    _append(merged, m)
            # Then base non-system (history/task context).
            for m in base_msgs:
                if m.get("role") != "system":
                    _append(merged, m)
            # Finally injected non-system messages.
            for m in extra_msgs:
                if m.get("role") != "system":
                    _append(merged, m)

            ctx_ns["messages"] = merged

        if isinstance(context, dict) and context:
            for k, v in context.items():
                if k in ("task", "messages"):
                    continue
                ctx_ns[str(k)] = v

        runtime_ns: Dict[str, Any] = {
            "inbox": [],
            "provider": provider,
            "model": model,
            "allowed_tools": list(allowed_tools),
        }

        # Media input policy defaults (run-scoped):
        # If the parent run configured an explicit audio policy (e.g. AbstractCode sets
        # `_runtime.audio_policy="auto"` when audio attachments are present), inherit it
        # into the Agent subworkflow so all nested LLM calls behave consistently.
        try:
            parent_runtime = run.vars.get("_runtime") if isinstance(run.vars, dict) else None
        except Exception:
            parent_runtime = None
        if isinstance(parent_runtime, dict):
            ap = parent_runtime.get("audio_policy")
            if isinstance(ap, str) and ap.strip():
                runtime_ns.setdefault("audio_policy", ap.strip())

            stt_lang = parent_runtime.get("stt_language")
            if stt_lang is None:
                stt_lang = parent_runtime.get("audio_language")
            if isinstance(stt_lang, str) and stt_lang.strip():
                runtime_ns.setdefault("stt_language", stt_lang.strip())

        # Sampling controls:
        # - Do NOT force default values into the child runtime vars.
        # - When unset, step-level agent defaults (e.g. lower temperature for tool-followthrough)
        #   must be able to take effect.
        #
        # Policy:
        # - temperature: only include when it differs from the framework default (0.7)
        # - seed: only include when explicitly set (>= 0)
        if abs(float(temperature) - 0.7) > 1e-9:
            runtime_ns["temperature"] = float(temperature)
        if int(seed) >= 0:
            runtime_ns["seed"] = int(seed)
        if include_session_attachments_index is not None:
            control = runtime_ns.get("control")
            if not isinstance(control, dict):
                control = {}
                runtime_ns["control"] = control
            control["include_session_attachments_index"] = bool(include_session_attachments_index)
        if isinstance(system_prompt, str) and system_prompt.strip():
            # IMPORTANT: Visual Agent `system` pin provides *additional* high-priority instructions.
            # We keep the canonical ReAct system prompt (iteration framing + evidence/tool-use rules)
            # and append this extra guidance so tool-followthrough remains robust.
            runtime_ns["system_prompt_extra"] = system_prompt

        return {
            "context": ctx_ns,
            "scratchpad": {"iteration": 0, "max_iterations": int(limits.get("max_iterations") or 25)},
            # `_runtime` is durable; we store provider/model here so the ReAct subworkflow
            # can inject them into LLM_CALL payloads (and remain resumable).
            "_runtime": runtime_ns,
            "_temp": {},
            "_limits": limits,
        }

    def _coerce_max_iterations(value: Any) -> Optional[int]:
        try:
            if value is None:
                return None
            if isinstance(value, bool):
                return None
            if isinstance(value, (int, float)):
                iv = int(float(value))
                return iv if iv > 0 else None
            if isinstance(value, str) and value.strip():
                iv = int(float(value.strip()))
                return iv if iv > 0 else None
        except Exception:
            return None
        return None

    def _coerce_max_input_tokens(value: Any) -> Optional[int]:
        try:
            if value is None or isinstance(value, bool):
                return None
            if isinstance(value, (int, float)):
                iv = int(float(value))
                return iv if iv > 0 else None
            if isinstance(value, str) and value.strip():
                iv = int(float(value.strip()))
                return iv if iv > 0 else None
        except Exception:
            return None
        return None

    def _coerce_max_output_tokens(value: Any) -> Optional[int]:
        # Same coercion rules as max_input_tokens: accept int/float/string, reject <=0.
        try:
            if value is None or isinstance(value, bool):
                return None
            if isinstance(value, (int, float)):
                iv = int(float(value))
                return iv if iv > 0 else None
            if isinstance(value, str) and value.strip():
                iv = int(float(value.strip()))
                return iv if iv > 0 else None
        except Exception:
            return None
        return None

    def _coerce_temperature(value: Any, default: float = 0.7) -> float:
        try:
            if value is None or isinstance(value, bool):
                return float(default)
            return float(value)
        except Exception:
            return float(default)

    def _coerce_seed(value: Any, default: int = -1) -> int:
        try:
            if value is None or isinstance(value, bool):
                return int(default)
            return int(value)
        except Exception:
            return int(default)

    def handler(run: Any, ctx: Any) -> "StepPlan":
        del ctx

        output_schema_cfg = agent_config.get("outputSchema") if isinstance(agent_config.get("outputSchema"), dict) else {}
        schema_enabled = bool(output_schema_cfg.get("enabled"))
        schema = output_schema_cfg.get("jsonSchema") if isinstance(output_schema_cfg.get("jsonSchema"), dict) else None

        bucket = _get_agent_bucket(run)
        phase = str(bucket.get("phase") or "init")

        # IMPORTANT: This visual Agent node can be executed multiple times within a single run
        # (e.g. inside a Loop/While/Sequence). The per-node bucket is durable and would otherwise
        # keep `phase="done"` and `resolved_inputs` from the first invocation, causing subsequent
        # invocations to skip work and reuse stale inputs/results.
        #
        # When we re-enter the node after it previously completed, reset the bucket to start a
        # fresh subworkflow invocation with the current upstream inputs.
        if phase == "done":
            try:
                bucket.clear()
            except Exception:
                # Best-effort; if clear fails, overwrite key fields below.
                pass
            # Clear any cached per-node memory-source recall outputs (467) so re-entry
            # (e.g. inside loops) re-computes recall for the new inputs.
            try:
                ms_bucket = _get_memory_sources_bucket(run)
                ms_bucket.clear()
            except Exception:
                pass
            phase = "init"
            bucket["phase"] = "init"

        resolved_inputs = bucket.get("resolved_inputs")
        if not isinstance(resolved_inputs, dict) or phase == "init":
            resolved_inputs = _resolve_inputs(run)
            bucket["resolved_inputs"] = resolved_inputs if isinstance(resolved_inputs, dict) else {}

        # Pin-driven structured output:
        # If `response_schema` is provided via an input pin (data edge), it overrides the node config
        # and enables the structured-output post-pass (durable LLM_CALL).
        pin_schema = resolved_inputs.get("response_schema") if isinstance(resolved_inputs, dict) else None
        if isinstance(pin_schema, dict) and pin_schema:
            schema = dict(pin_schema)
            schema_enabled = True

        # Provider/model can come from Agent node config or from data-edge inputs (pins).
        provider_raw = resolved_inputs.get("provider") if isinstance(resolved_inputs, dict) else None
        model_raw = resolved_inputs.get("model") if isinstance(resolved_inputs, dict) else None
        if not isinstance(provider_raw, str) or not provider_raw.strip():
            provider_raw = agent_config.get("provider")
        if not isinstance(model_raw, str) or not model_raw.strip():
            model_raw = agent_config.get("model")

        def _is_default_marker(value: Any) -> bool:
            if not isinstance(value, str):
                return False
            marker = value.strip().lower()
            return marker in {"default", "__default__", "__abstractcore_default__", "abstractcore.default"}

        explicit_default = False
        if _is_default_marker(provider_raw):
            provider_raw = ""
            explicit_default = True
        if _is_default_marker(model_raw):
            model_raw = ""
            explicit_default = True

        provider = str(provider_raw or "").strip().lower() if isinstance(provider_raw, str) else ""
        model = str(model_raw or "").strip() if isinstance(model_raw, str) else ""

        def _append_flow_warning(msg: str) -> None:
            if not isinstance(msg, str) or not msg.strip():
                return
            try:
                warnings = run.vars.get("_flow_warnings")
                if not isinstance(warnings, list):
                    warnings = []
                    run.vars["_flow_warnings"] = warnings
                if msg not in warnings:
                    warnings.append(msg)
            except Exception:
                return

        # Best-effort defaults:
        # - Prefer the run-scoped runtime defaults injected by hosts (e.g. AbstractGateway).
        # - Fall back to AbstractCore global defaults (`abstractcore --config`).
        if not provider or not model:
            try:
                runtime_ns = run.vars.get("_runtime") if isinstance(run.vars, dict) else None
            except Exception:
                runtime_ns = None
            if isinstance(runtime_ns, dict):
                rt_provider = runtime_ns.get("provider")
                rt_model = runtime_ns.get("model")
                rt_provider_s = str(rt_provider or "").strip().lower() if isinstance(rt_provider, str) else ""
                rt_model_s = str(rt_model or "").strip() if isinstance(rt_model, str) else ""
                used_fallback = False
                if not provider and rt_provider_s:
                    provider = rt_provider_s
                    used_fallback = True
                if not model and rt_model_s and (not provider or provider == rt_provider_s):
                    model = rt_model_s
                    used_fallback = True
                if used_fallback and not explicit_default:
                    _append_flow_warning("#FALLBACK: missing provider/model; using runtime defaults")

            try:
                from abstractcore.config import get_config_manager  # type: ignore

                cfg = get_config_manager().config
                cfg_provider = str(getattr(cfg.default_models, "global_provider", "") or "").strip().lower()
                cfg_model = str(getattr(cfg.default_models, "global_model", "") or "").strip()
                used_fallback = False
                if not provider and cfg_provider:
                    provider = cfg_provider
                    used_fallback = True
                if not model and cfg_model and (not provider or provider == cfg_provider):
                    model = cfg_model
                    used_fallback = True
                if used_fallback and not explicit_default:
                    _append_flow_warning("#FALLBACK: missing provider/model; using AbstractCore defaults")
            except Exception:
                pass

        # Backward compatibility:
        # - Visual Agent nodes commonly use the input pin id "prompt" for the task string.
        task = str(resolved_inputs.get("task") or resolved_inputs.get("prompt") or "")
        context_raw = resolved_inputs.get("context")
        context = context_raw if isinstance(context_raw, dict) else {}
        system_raw = resolved_inputs.get("system") if isinstance(resolved_inputs, dict) else None
        system_prompt = system_raw if isinstance(system_raw, str) else str(system_raw or "")

        # Include parent run context (as agent history):
        # - Pin override wins when connected (resolved_inputs contains include_context/use_context)
        # - Otherwise fall back to node config (checkbox)
        # - Default: false
        include_context: bool
        if isinstance(resolved_inputs, dict) and ("include_context" in resolved_inputs or "use_context" in resolved_inputs):
            include_context = bool(
                resolved_inputs.get("include_context")
                if "include_context" in resolved_inputs
                else resolved_inputs.get("use_context")
            )
        else:
            include_context_cfg = agent_config.get("include_context")
            if include_context_cfg is None:
                include_context_cfg = agent_config.get("use_context")
            include_context = bool(include_context_cfg) if include_context_cfg is not None else False

        # Agent loop budget (max_iterations) can come from a data-edge pin or from config.
        max_iterations_raw = resolved_inputs.get("max_iterations") if isinstance(resolved_inputs, dict) else None
        max_iterations_override = _coerce_max_iterations(max_iterations_raw)
        if max_iterations_override is None:
            max_iterations_override = _coerce_max_iterations(agent_config.get("max_iterations"))

        # Token budget (max_input_tokens) can come from a data-edge pin or from config.
        max_input_tokens_raw = resolved_inputs.get("max_input_tokens") if isinstance(resolved_inputs, dict) else None
        max_input_tokens_override = _coerce_max_input_tokens(max_input_tokens_raw)
        if max_input_tokens_override is None:
            max_input_tokens_override = _coerce_max_input_tokens(agent_config.get("max_input_tokens"))

        # Token budget (max_output_tokens) can come from a data-edge pin or from config.
        max_output_tokens_raw: Any = None
        if isinstance(resolved_inputs, dict):
            if "max_output_tokens" in resolved_inputs:
                max_output_tokens_raw = resolved_inputs.get("max_output_tokens")
            elif "max_out_tokens" in resolved_inputs:
                max_output_tokens_raw = resolved_inputs.get("max_out_tokens")
        max_output_tokens_override = _coerce_max_output_tokens(max_output_tokens_raw)
        if max_output_tokens_override is None:
            max_output_tokens_override = _coerce_max_output_tokens(agent_config.get("max_output_tokens"))

        # Sampling controls can come from pins or from config.
        if isinstance(resolved_inputs, dict) and "temperature" in resolved_inputs:
            temperature_raw = resolved_inputs.get("temperature")
        else:
            temperature_raw = agent_config.get("temperature", 0.7)
        temperature = _coerce_temperature(temperature_raw, default=0.7)

        if isinstance(resolved_inputs, dict) and "seed" in resolved_inputs:
            seed_raw = resolved_inputs.get("seed")
        else:
            seed_raw = agent_config.get("seed", -1)
        seed = _coerce_seed(seed_raw, default=-1)

        # Tools selection:
        # - If the resolved inputs explicitly include `tools` (e.g. tools pin connected),
        #   respect it even if it's an empty list (disables tools).
        # - Otherwise fall back to the Agent node's configuration.
        if isinstance(resolved_inputs, dict) and "tools" in resolved_inputs:
            tools_raw = resolved_inputs.get("tools")
        else:
            tools_raw = agent_config.get("tools")
        allowed_tools: list[str] = []
        if isinstance(tools_raw, list):
            for t in tools_raw:
                if isinstance(t, str) and t.strip():
                    allowed_tools.append(t.strip())
        elif isinstance(tools_raw, tuple):
            for t in tools_raw:
                if isinstance(t, str) and t.strip():
                    allowed_tools.append(t.strip())
        elif isinstance(tools_raw, str) and tools_raw.strip():
            allowed_tools.append(tools_raw.strip())

        # De-dup while preserving order.
        seen_tools: set[str] = set()
        allowed_tools = [t for t in allowed_tools if not (t in seen_tools or seen_tools.add(t))]

        workflow_id_raw = agent_config.get("_react_workflow_id")
        react_workflow_id = (
            workflow_id_raw.strip()
            if isinstance(workflow_id_raw, str) and workflow_id_raw.strip()
            else visual_react_workflow_id(flow_id=flow.flow_id, node_id=node_id)
        )

        if phase == "init":
            if not provider or not model:
                run.vars["_flow_error"] = "Agent node missing provider/model configuration"
                run.vars["_flow_error_node"] = node_id
                out = {
                    "response": "Agent configuration error: missing provider/model",
                    "task": task,
                    "context": context,
                    "success": False,
                    "error": "missing provider/model",
                    "provider": provider or "unknown",
                    "model": model or "unknown",
                }
                _set_nested(run.vars, f"_temp.effects.{node_id}", out)
                bucket["phase"] = "done"

                scratchpad = {"node_id": node_id, "steps": [], "tool_calls": [], "tool_results": []}
                meta: Dict[str, Any] = {
                    "schema": "abstractcode.agent.v1.meta",
                    "version": 1,
                    "provider": provider or "unknown",
                    "model": model or "unknown",
                    "tool_calls": 0,
                    "tool_results": 0,
                }
                flow._node_outputs[node_id] = {
                    "response": str(out.get("response") or ""),
                    "success": False,
                    "meta": meta,
                    "scratchpad": scratchpad,
                    # Backward-compat / convenience:
                    "tool_calls": [],
                    "tool_results": [],
                }
                run.vars["_last_output"] = dict(flow._node_outputs.get(node_id) or {})
                if next_node:
                    return StepPlan(node_id=node_id, next_node=next_node)
                return StepPlan(
                    node_id=node_id,
                    complete_output={
                        "response": str(out.get("response") or ""),
                        "success": False,
                        "meta": meta,
                        "scratchpad": scratchpad,
                    },
                )

            # ------------------------------------------------------------
            # 467: Memory-source access pins (pre-agent recall)
            # ------------------------------------------------------------
            #
            # Before we start the durable Agent subworkflow, optionally run runtime-owned
            # MEMORY_* effects to recall from spans and/or the KG. Results are stored under
            # `_temp.memory_sources.{node_id}.*` and injected into the child run context.
            ms_bucket = _get_memory_sources_bucket(run)
            ms_meta = ms_bucket.get("_meta") if isinstance(ms_bucket.get("_meta"), dict) else {}
            if not isinstance(ms_meta, dict):
                ms_meta = {}
            ms_bucket["_meta"] = ms_meta

            def _pin_bool_opt(name: str) -> Optional[bool]:
                if not isinstance(resolved_inputs, dict):
                    return None
                if name not in resolved_inputs:
                    return None
                return bool(resolved_inputs.get(name))

            use_span_memory = _pin_bool_opt("use_span_memory")
            use_kg_memory = _pin_bool_opt("use_kg_memory")
            use_semantic_search = _pin_bool_opt("use_semantic_search")
            use_session_attachments = _pin_bool_opt("use_session_attachments")

            memory_scope = (
                str(resolved_inputs.get("memory_scope") or "run").strip().lower()
                if isinstance(resolved_inputs, dict)
                else "run"
            ) or "run"
            recall_level = (
                str(resolved_inputs.get("recall_level") or "").strip().lower()
                if isinstance(resolved_inputs, dict)
                else ""
            )

            mem_query = (
                str(resolved_inputs.get("memory_query") or "").strip()
                if isinstance(resolved_inputs, dict)
                else ""
            )
            query_text = mem_query or str(task or "").strip()

            base_key = f"_temp.memory_sources.{node_id}"

            def _coerce_int(value: Any) -> Optional[int]:
                if value is None or isinstance(value, bool):
                    return None
                try:
                    return int(float(value))
                except Exception:
                    return None

            def _coerce_float(value: Any) -> Optional[float]:
                if value is None or isinstance(value, bool):
                    return None
                try:
                    v = float(value)
                except Exception:
                    return None
                return v if (v == v) else None

            if use_semantic_search is True:
                warnings = ms_meta.get("warnings")
                if not isinstance(warnings, list):
                    warnings = []
                    ms_meta["warnings"] = warnings
                msg = "use_semantic_search is not implemented yet (planned: 464); ignoring."
                if msg not in warnings:
                    warnings.append(msg)

            # Span recall (metadata)
            if use_span_memory is True and "span_query" not in ms_bucket and query_text:
                mq_payload: Dict[str, Any] = {"query": query_text, "scope": memory_scope, "return": "meta"}
                if recall_level:
                    mq_payload["recall_level"] = recall_level
                return StepPlan(
                    node_id=node_id,
                    effect=Effect(
                        type=EffectType.MEMORY_QUERY,
                        payload=mq_payload,
                        result_key=f"{base_key}.span_query",
                    ),
                    next_node=node_id,
                )

            # Span rehydrate into parent context (so we can forward a subset into the child).
            if use_span_memory is True and "span_rehydrate" not in ms_bucket:
                span_ids: list[str] = []
                span_query = ms_bucket.get("span_query")
                if isinstance(span_query, dict):
                    results = span_query.get("results")
                    if isinstance(results, list) and results:
                        first = results[0] if isinstance(results[0], dict) else None
                        meta2 = first.get("meta") if isinstance(first, dict) else None
                        if isinstance(meta2, dict):
                            raw_ids = meta2.get("span_ids")
                            if isinstance(raw_ids, list):
                                span_ids = [str(x).strip() for x in raw_ids if str(x).strip()]
                ms_meta["span_ids"] = span_ids

                mr_payload: Dict[str, Any] = {"span_ids": span_ids, "placement": "after_summary"}
                if recall_level:
                    mr_payload["recall_level"] = recall_level
                max_span_messages = (
                    _coerce_int(resolved_inputs.get("max_span_messages"))
                    if isinstance(resolved_inputs, dict)
                    else None
                )
                if max_span_messages is None and not recall_level:
                    # Safe default (explicit warning): without recall_level, MEMORY_REHYDRATE would
                    # otherwise insert all messages from selected spans.
                    max_span_messages = 80
                    warnings = ms_meta.get("warnings")
                    if not isinstance(warnings, list):
                        warnings = []
                        ms_meta["warnings"] = warnings
                    msg = "use_span_memory enabled without recall_level/max_span_messages; defaulting max_span_messages=80."
                    if msg not in warnings:
                        warnings.append(msg)
                if isinstance(max_span_messages, int):
                    mr_payload["max_messages"] = max_span_messages

                return StepPlan(
                    node_id=node_id,
                    effect=Effect(
                        type=EffectType.MEMORY_REHYDRATE,
                        payload=mr_payload,
                        result_key=f"{base_key}.span_rehydrate",
                    ),
                    next_node=node_id,
                )

            # KG recall (packetized active memory)
            if use_kg_memory is True and "kg_query" not in ms_bucket and query_text:
                kg_payload: Dict[str, Any] = {"query_text": query_text, "scope": memory_scope}
                if recall_level:
                    kg_payload["recall_level"] = recall_level

                kg_min_score = (
                    _coerce_float(resolved_inputs.get("kg_min_score")) if isinstance(resolved_inputs, dict) else None
                )
                if isinstance(kg_min_score, float):
                    kg_payload["min_score"] = kg_min_score
                kg_limit = _coerce_int(resolved_inputs.get("kg_limit")) if isinstance(resolved_inputs, dict) else None
                if isinstance(kg_limit, int):
                    kg_payload["limit"] = kg_limit
                kg_budget = (
                    _coerce_int(resolved_inputs.get("kg_max_input_tokens"))
                    if isinstance(resolved_inputs, dict)
                    else None
                )
                if kg_budget is None and not recall_level:
                    # Safe default (explicit warning): ensure we actually get `active_memory_text`
                    # even when recall_level is not set.
                    kg_budget = 1200
                    warnings = ms_meta.get("warnings")
                    if not isinstance(warnings, list):
                        warnings = []
                        ms_meta["warnings"] = warnings
                    msg = "use_kg_memory enabled without recall_level/kg_max_input_tokens; defaulting kg_max_input_tokens=1200."
                    if msg not in warnings:
                        warnings.append(msg)
                if isinstance(kg_budget, int):
                    kg_payload["max_input_tokens"] = kg_budget
                if model:
                    kg_payload["model"] = model

                return StepPlan(
                    node_id=node_id,
                    effect=Effect(
                        type=EffectType.MEMORY_KG_QUERY,
                        payload=kg_payload,
                        result_key=f"{base_key}.kg_query",
                    ),
                    next_node=node_id,
                )

            bucket["phase"] = "subworkflow"
            flow._node_outputs[node_id] = {
                "status": "running",
                "task": task,
                "context": context,
                "response": "",
                "success": None,
                "meta": None,
                "scratchpad": None,
            }

            # Memory injection into the Agent subworkflow context (467).
            extra_messages: list[Dict[str, Any]] = []
            if use_kg_memory is True:
                kg_query = ms_bucket.get("kg_query")
                active_text = kg_query.get("active_memory_text") if isinstance(kg_query, dict) else None
                if isinstance(active_text, str) and active_text.strip():
                    extra_messages.append(
                        {
                            "role": "system",
                            "content": active_text.strip(),
                            "metadata": {
                                "kind": "kg_active_memory",
                                "message_id": f"kg_active_memory:{node_id}",
                            },
                        }
                    )

            if use_span_memory is True:
                span_ids_any = ms_meta.get("span_ids")
                span_ids_set = (
                    {str(x).strip() for x in span_ids_any if isinstance(x, str) and str(x).strip()}
                    if isinstance(span_ids_any, list)
                    else set()
                )
                ctx_ns = run.vars.get("context") if isinstance(run.vars, dict) else None
                raw_msgs = ctx_ns.get("messages") if isinstance(ctx_ns, dict) else None
                if isinstance(raw_msgs, list) and span_ids_set:
                    for m_any in raw_msgs:
                        m = m_any if isinstance(m_any, dict) else None
                        if m is None:
                            continue
                        meta_m = m.get("metadata")
                        if not isinstance(meta_m, dict) or meta_m.get("rehydrated") is not True:
                            continue
                        src = meta_m.get("source_artifact_id")
                        src_id = str(src).strip() if isinstance(src, str) else ""
                        if src_id and src_id in span_ids_set:
                            extra_messages.append(dict(m))

            return StepPlan(
                node_id=node_id,
                effect=Effect(
                    type=EffectType.START_SUBWORKFLOW,
                    payload={
                        "workflow_id": react_workflow_id,
                        "vars": _build_sub_vars(
                            run,
                            task=task,
                            context=context,
                            provider=provider,
                            model=model,
                            system_prompt=system_prompt,
                            allowed_tools=allowed_tools,
                            temperature=temperature,
                            seed=seed,
                            include_context=include_context,
                            max_iterations=max_iterations_override,
                            max_input_tokens=max_input_tokens_override,
                            max_output_tokens=max_output_tokens_override,
                            extra_messages=extra_messages or None,
                            include_session_attachments_index=use_session_attachments,
                        ),
                        # Run Agent as a durable async subworkflow so the host can:
                        # - tick the child incrementally (real-time observability of each effect)
                        # - resume the parent once the child completes (async+wait mode)
                        "async": True,
                        "wait": True,
                        "include_traces": True,
                    },
                    result_key=f"_temp.agent.{node_id}.sub",
                ),
                next_node=node_id,
            )

        if phase == "subworkflow":
            sub = bucket.get("sub")
            if sub is None:
                temp = _ensure_temp_dict(run)
                agent_ns = temp.get("agent")
                if isinstance(agent_ns, dict):
                    node_bucket = agent_ns.get(node_id)
                    if isinstance(node_bucket, dict):
                        sub = node_bucket.get("sub")

            if not isinstance(sub, dict):
                return StepPlan(node_id=node_id, next_node=node_id)

            sub_run_id = sub.get("sub_run_id") if isinstance(sub.get("sub_run_id"), str) else None
            output = sub.get("output")
            output_dict = output if isinstance(output, dict) else {}
            answer = str(output_dict.get("answer") or "")
            iterations = output_dict.get("iterations")

            node_traces = sub.get("node_traces")
            raw_messages = output_dict.get("messages")

            def _normalize_messages(raw: Any) -> list[Dict[str, Any]]:
                if isinstance(raw, list):
                    out: list[Dict[str, Any]] = []
                    for m in raw:
                        if isinstance(m, dict):
                            out.append(dict(m))
                        else:
                            out.append({"role": "assistant", "content": str(m)})
                    return out
                msgs: list[Dict[str, Any]] = []
                if str(task or "").strip():
                    msgs.append({"role": "user", "content": str(task)})
                if answer.strip():
                    msgs.append({"role": "assistant", "content": answer})
                return msgs

            messages_out = _normalize_messages(raw_messages)
            context_extra: Dict[str, Any] = {}
            if isinstance(context, dict) and context:
                for k, v in context.items():
                    if k in ("task", "messages"):
                        continue
                    context_extra[str(k)] = v
            scratchpad = {
                "sub_run_id": sub_run_id,
                "workflow_id": react_workflow_id,
                "task": str(task or ""),
                "messages": messages_out,
                "node_traces": node_traces if isinstance(node_traces, dict) else {},
                "steps": _flatten_node_traces(node_traces),
            }
            if context_extra:
                scratchpad["context_extra"] = context_extra
            bucket["scratchpad"] = scratchpad
            bucket["messages"] = messages_out
            tc, tr = _extract_tool_activity_from_steps(scratchpad.get("steps"))
            bucket["answer"] = answer
            bucket["success"] = True
            bucket["provider"] = provider
            bucket["model"] = model
            bucket["output_mode"] = "unstructured"
            if iterations is not None:
                bucket["iterations"] = iterations
            if sub_run_id:
                bucket["sub_run_id"] = sub_run_id

            # Agent `result` is intentionally minimal in unstructured mode.
            # - The user-facing answer string is available on the `response` output pin.
            # - Execution metadata belongs in `meta`.
            # - The agent-internal transcript lives in `scratchpad.messages`.
            result_obj: Dict[str, Any] = {"success": True}

            scratchpad_out = dict(scratchpad)
            scratchpad_out["tool_calls"] = tc
            scratchpad_out["tool_results"] = tr

            meta: Dict[str, Any] = {
                "schema": "abstractcode.agent.v1.meta",
                "version": 1,
                "output_mode": "unstructured",
                "provider": provider,
                "model": model,
                "tool_calls": len(tc),
                "tool_results": len(tr),
            }
            if sub_run_id:
                meta["sub_run_id"] = sub_run_id
            if iterations is not None:
                meta["iterations"] = iterations

            # When "Include/Use context" is enabled on the Agent node, persist the turn
            # into the parent run's active context so subsequent Agent/Subflow/LLM_CALL
            # nodes can see prior interactions (critical for recursive subflows).
            try:
                if include_context:
                    already = bucket.get("context_appended_sub_run_id")
                    if already != sub_run_id:
                        ctx_ns = run.vars.get("context")
                        if not isinstance(ctx_ns, dict):
                            ctx_ns = {}
                            run.vars["context"] = ctx_ns
                        msgs = ctx_ns.get("messages")
                        if not isinstance(msgs, list):
                            msgs = []
                            ctx_ns["messages"] = msgs

                        def _has_message_id(message_id: str) -> bool:
                            for m in msgs:
                                if not isinstance(m, dict):
                                    continue
                                meta = m.get("metadata")
                                if not isinstance(meta, dict):
                                    continue
                                if meta.get("message_id") == message_id:
                                    return True
                            return False

                        def _append(role: str, content: str, *, suffix: str) -> None:
                            text = str(content or "").strip()
                            if not text:
                                return
                            mid = f"agent:{sub_run_id or run.run_id}:{node_id}:{suffix}"
                            if _has_message_id(mid):
                                return
                            meta: Dict[str, Any] = {"kind": "agent_turn", "node_id": node_id, "message_id": mid}
                            if sub_run_id:
                                meta["sub_run_id"] = sub_run_id
                            msgs.append({"role": role, "content": text, "metadata": meta})

                        def _truncate(text: str, *, max_chars: int) -> str:
                            if max_chars <= 0:
                                return text
                            if len(text) <= max_chars:
                                return text
                            suffix = f"\n… (truncated, {len(text):,} chars total)"
                            keep = max_chars - len(suffix)
                            if keep < 200:
                                keep = max_chars
                                suffix = ""
                            return text[:keep].rstrip() + suffix

                        def _append_tool_observations() -> None:
                            # Persist a compact transcript of tool results executed by this Agent sub-run.
                            # This is critical for outer loops (e.g. RALPH) that re-invoke the agent and
                            # need evidence continuity to avoid repeating the same tool calls forever.
                            if not isinstance(tr, list) or not tr:
                                return

                            # Optional: attach arguments when available (from tool_calls).
                            call_by_id: Dict[str, Dict[str, Any]] = {}
                            if isinstance(tc, list):
                                for c in tc:
                                    if not isinstance(c, dict):
                                        continue
                                    cid = str(c.get("call_id") or "").strip()
                                    if cid:
                                        call_by_id[cid] = dict(c)

                            max_results = 20
                            for i, r in enumerate(tr[:max_results]):
                                if not isinstance(r, dict):
                                    continue
                                name = str(r.get("name") or "tool").strip() or "tool"
                                call_id = str(r.get("call_id") or "").strip()
                                success = bool(r.get("success"))

                                args = None
                                if call_id and call_id in call_by_id:
                                    raw_args = call_by_id[call_id].get("arguments")
                                    if isinstance(raw_args, dict) and raw_args:
                                        args = raw_args

                                output = r.get("output")
                                error = r.get("error")

                                try:
                                    rendered_out = json.dumps(output, ensure_ascii=False, indent=2) if isinstance(output, (dict, list)) else str(output or "")
                                except Exception:
                                    rendered_out = "" if output is None else str(output)
                                rendered_out = rendered_out.strip()

                                details = str(error or rendered_out).strip() if not success else rendered_out
                                if args is not None:
                                    try:
                                        args_txt = json.dumps(args, ensure_ascii=False, sort_keys=True)
                                    except Exception:
                                        args_txt = str(args)
                                    details = f"args={args_txt}\n{details}".strip()

                                details = _truncate(details, max_chars=2000)
                                content = f"[{name}]: {details}" if success else f"[{name}]: Error: {details}"

                                # Store as an assistant message for broad provider compatibility.
                                # (Some OpenAI-compatible servers are strict about role='tool' message shape.)
                                suffix = f"tool:{call_id or i}"
                                mid = f"agent:{sub_run_id or run.run_id}:{node_id}:{suffix}"
                                if _has_message_id(mid):
                                    continue
                                meta: Dict[str, Any] = {
                                    "kind": "tool_observation",
                                    "node_id": node_id,
                                    "message_id": mid,
                                    "tool_name": name,
                                    "success": success,
                                }
                                if call_id:
                                    meta["call_id"] = call_id
                                if sub_run_id:
                                    meta["sub_run_id"] = sub_run_id
                                msgs.append({"role": "assistant", "content": content, "metadata": meta})

                            if len(tr) > max_results:
                                omitted = len(tr) - max_results
                                mid = f"agent:{sub_run_id or run.run_id}:{node_id}:tool:omitted"
                                if not _has_message_id(mid):
                                    msgs.append(
                                        {
                                            "role": "assistant",
                                            "content": f"[tools]: (omitted {omitted} additional tool results for brevity; see sub_run_id={sub_run_id})",
                                            "metadata": {
                                                "kind": "tool_observation_summary",
                                                "node_id": node_id,
                                                "message_id": mid,
                                                "sub_run_id": sub_run_id,
                                            },
                                        }
                                    )

                        _append("user", task, suffix="task")
                        _append_tool_observations()
                        _append("assistant", answer, suffix="answer")
                        bucket["context_appended_sub_run_id"] = sub_run_id
            except Exception:
                # Context persistence must never break Agent execution.
                pass

            if schema_enabled and schema:
                bucket["phase"] = "structured"
                messages = [
                    {
                        "role": "user",
                        "content": (
                            "Convert the Agent answer into a JSON object matching the required schema. Return JSON only.\n\n"
                            f"Task:\n{task}\n\n"
                            f"Answer:\n{answer}"
                        ),
                    }
                ]
                return StepPlan(
                    node_id=node_id,
                    effect=Effect(
                        type=EffectType.LLM_CALL,
                        payload={
                            "messages": messages,
                            "system_prompt": system_prompt,
                            "provider": provider,
                            "model": model,
                            "response_schema": schema,
                            "response_schema_name": f"Agent_{node_id}",
                            "params": {"temperature": temperature, "seed": seed} if seed >= 0 else {"temperature": temperature},
                        },
                        result_key=f"_temp.agent.{node_id}.structured",
                    ),
                    next_node=node_id,
                )

            _set_nested(run.vars, f"_temp.effects.{node_id}", result_obj)
            bucket["phase"] = "done"
            flow._node_outputs[node_id] = {
                "response": answer,
                "success": True,
                "meta": meta,
                "scratchpad": scratchpad_out,
                # Backward-compat / convenience:
                "tool_calls": tc,
                "tool_results": tr,
            }
            run.vars["_last_output"] = dict(flow._node_outputs.get(node_id) or {})
            if next_node:
                return StepPlan(node_id=node_id, next_node=next_node)
            return StepPlan(
                node_id=node_id,
                complete_output={
                    "response": answer,
                    "success": True,
                    "meta": meta,
                    "scratchpad": scratchpad_out,
                },
            )

        if phase == "structured":
            structured_resp = bucket.get("structured")
            if structured_resp is None:
                temp = _ensure_temp_dict(run)
                agent_bucket = temp.get("agent", {}).get(node_id, {}) if isinstance(temp.get("agent"), dict) else {}
                structured_resp = agent_bucket.get("structured") if isinstance(agent_bucket, dict) else None

            data = structured_resp.get("data") if isinstance(structured_resp, dict) else None
            if data is None and isinstance(structured_resp, dict):
                content = structured_resp.get("content")
                if isinstance(content, str) and content.strip():
                    try:
                        data = json.loads(content)
                    except Exception:
                        data = None

            if not isinstance(data, dict):
                data = {}

            _set_nested(run.vars, f"_temp.effects.{node_id}", data)
            bucket["phase"] = "done"
            scratchpad = bucket.get("scratchpad")
            if not isinstance(scratchpad, dict):
                scratchpad = {"node_id": node_id, "steps": []}
            tc, tr = _extract_tool_activity_from_steps(scratchpad.get("steps"))
            answer = bucket.get("answer") if isinstance(bucket.get("answer"), str) else ""
            scratchpad_out = dict(scratchpad)
            if "messages" not in scratchpad_out:
                msgs = bucket.get("messages")
                if isinstance(msgs, list):
                    scratchpad_out["messages"] = list(msgs)
            scratchpad_out["tool_calls"] = tc
            scratchpad_out["tool_results"] = tr

            meta: Dict[str, Any] = {
                "schema": "abstractcode.agent.v1.meta",
                "version": 1,
                "output_mode": "structured",
                "provider": provider,
                "model": model,
                "tool_calls": len(tc),
                "tool_results": len(tr),
            }
            sr = scratchpad_out.get("sub_run_id")
            if isinstance(sr, str) and sr.strip():
                meta["sub_run_id"] = sr.strip()

            try:
                response_text = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
            except Exception:
                response_text = str(data)

            flow._node_outputs[node_id] = {
                "response": response_text,
                "success": True,
                "meta": meta,
                "scratchpad": scratchpad_out,
                # Backward-compat / convenience:
                "tool_calls": tc,
                "tool_results": tr,
            }
            bucket["output_mode"] = "structured"
            run.vars["_last_output"] = dict(flow._node_outputs.get(node_id) or {})
            if next_node:
                return StepPlan(node_id=node_id, next_node=next_node)
            return StepPlan(
                node_id=node_id,
                complete_output={
                    "response": response_text,
                    "success": True,
                    "meta": meta,
                    "scratchpad": scratchpad_out,
                },
            )

        if next_node:
            return StepPlan(node_id=node_id, next_node=next_node)
        last = run.vars.get("_last_output")
        if isinstance(last, dict):
            return StepPlan(
                node_id=node_id,
                complete_output={
                    "response": str(last.get("response") or ""),
                    "success": bool(last.get("success")) if "success" in last else True,
                    "meta": last.get("meta") if isinstance(last.get("meta"), dict) else {},
                    "scratchpad": last.get("scratchpad"),
                },
            )
        return StepPlan(
            node_id=node_id,
            complete_output={"response": str(last) if last is not None else "", "success": True, "meta": {}, "scratchpad": None},
        )

    return handler


def _create_visual_function_handler(
    node_id: str,
    func: Callable,
    next_node: Optional[str],
    input_key: Optional[str],
    output_key: Optional[str],
    flow: Flow,
    branch_map: Optional[Dict[str, str]] = None,
) -> Callable:
    """Create a handler for visual flow function nodes.

    Visual flows use data edges for passing values between nodes. This handler:
    1. Syncs effect results from run.vars to flow._node_outputs
    2. Calls the wrapped function with proper input
    3. Updates _last_output for downstream nodes
    """
    from abstractruntime.core.models import StepPlan
    from .adapters.control_adapter import _set_prev_exec_handle

    def handler(run: Any, ctx: Any) -> "StepPlan":
        """Execute the function and transition to next node."""
        def _to_complete_output(value: Any) -> Dict[str, Any]:
            if isinstance(value, dict):
                out = dict(value)
                out.setdefault("success", True)
                return out
            return {"response": value, "success": True}

        # Sync effect results from run.vars to flow._node_outputs
        # This allows data edges from effect nodes to resolve correctly
        if hasattr(flow, '_node_outputs') and hasattr(flow, '_data_edge_map'):
            _sync_effect_results_to_node_outputs(run, flow)

        # Get input from _last_output (visual flow pattern)
        # or from input_key if specified
        if input_key:
            input_data = run.vars.get(input_key)
        else:
            input_data = run.vars.get("_last_output") if "_last_output" in run.vars else run.vars

        # Execute function (which is the data-aware wrapped handler)
        try:
            result = func(input_data)
        except Exception as e:
            run.vars["_flow_error"] = str(e)
            run.vars["_flow_error_node"] = node_id
            return StepPlan(
                node_id=node_id,
                complete_output={"error": str(e), "success": False, "node": node_id},
            )

        # Store result in _last_output for downstream nodes
        run.vars["_last_output"] = result

        # Persist per-node outputs for data-edge rehydration across pause/resume.
        #
        # Visual data edges read from `flow._node_outputs`, which is an in-memory
        # cache. When a run pauses (ASK_USER / TOOL passthrough) and is resumed
        # in a different process, we must be able to reconstruct upstream node
        # outputs from persisted `RunState.vars`.
        temp = run.vars.get("_temp")
        if not isinstance(temp, dict):
            temp = {}
            run.vars["_temp"] = temp
        persisted_outputs = temp.get("node_outputs")
        if not isinstance(persisted_outputs, dict):
            persisted_outputs = {}
            temp["node_outputs"] = persisted_outputs
        persisted_outputs[node_id] = result

        # Also store in output_key if specified
        if output_key:
            _set_nested(run.vars, output_key, result)

        if branch_map is not None:
            branch = result.get("branch") if isinstance(result, dict) else None
            if not isinstance(branch, str) or not branch:
                run.vars["_flow_error"] = "Branching node did not return a string 'branch' value"
                run.vars["_flow_error_node"] = node_id
                return StepPlan(
                    node_id=node_id,
                    complete_output={
                        "error": "Branching node did not return a string 'branch' value",
                        "success": False,
                        "node": node_id,
                    },
                )
            chosen = branch_map.get(branch)
            if not isinstance(chosen, str) or not chosen:
                # Blueprint-style behavior: if the chosen execution pin isn't connected,
                # treat it as a clean completion instead of an error.
                if branch in {"true", "false", "default"} or branch.startswith("case:"):
                    return StepPlan(
                        node_id=node_id,
                        complete_output=_to_complete_output(result),
                    )

                run.vars["_flow_error"] = f"Unknown branch '{branch}'"
                run.vars["_flow_error_node"] = node_id
                return StepPlan(
                    node_id=node_id,
                    complete_output={
                        "error": f"Unknown branch '{branch}'",
                        "success": False,
                        "node": node_id,
                    },
                )
            _set_prev_exec_handle(run.vars, branch)
            return StepPlan(node_id=node_id, next_node=chosen)

        # Continue to next node or complete
        if next_node:
            _set_prev_exec_handle(run.vars, "exec-out")
            return StepPlan(node_id=node_id, next_node=next_node)
        return StepPlan(
            node_id=node_id,
            complete_output=_to_complete_output(result),
        )

    return handler


def _sync_effect_results_to_node_outputs(run: Any, flow: Flow) -> None:
    """Sync effect results from run.vars to flow._node_outputs.

    When an effect (like ask_user) completes, its result is stored in run.vars
    at the result_key. But visual flow data edges read from flow._node_outputs.
    This function syncs those results so data edges resolve correctly.
    """
    # Attach a live reference to the current run vars so pure nodes (e.g. Get Variable)
    # can read the up-to-date workflow state during data-edge resolution.
    try:
        if hasattr(run, "vars") and isinstance(run.vars, dict):
            flow._run_vars = run.vars  # type: ignore[attr-defined]
    except Exception:
        pass

    # IMPORTANT: `flow._node_outputs` is an in-memory cache used by the visual executor
    # to resolve data edges (including lazy "pure" nodes like compare/subtract/concat).
    #
    # A single compiled `Flow` instance can be executed by multiple `RunState`s in the
    # same process when using subworkflows (START_SUBWORKFLOW) — especially with
    # self-recursion or mutual recursion. In that situation, stale cached outputs from
    # another run can break correctness (e.g. a cached `compare` result keeps a base-case
    # from ever becoming false), leading to infinite recursion.
    #
    # We isolate caches per run_id by resetting the dict *in-place* when the active run
    # changes, then rehydrating persisted outputs from `run.vars["_temp"]`.
    node_outputs = flow._node_outputs
    try:
        rid = getattr(run, "run_id", None)
        if isinstance(rid, str) and rid:
            active = getattr(flow, "_active_run_id", None)
            if active != rid:
                base = getattr(flow, "_static_node_outputs", None)
                # Backward-compat: if the baseline wasn't set (older flows), infer it
                # on first use — at this point it should contain only literal nodes.
                if not isinstance(base, dict):
                    base = dict(node_outputs) if isinstance(node_outputs, dict) else {}
                    try:
                        flow._static_node_outputs = dict(base)  # type: ignore[attr-defined]
                    except Exception:
                        pass
                if isinstance(node_outputs, dict):
                    node_outputs.clear()
                    if isinstance(base, dict):
                        node_outputs.update(base)
                try:
                    flow._active_run_id = rid  # type: ignore[attr-defined]
                except Exception:
                    pass
    except Exception:
        # Best-effort; never let cache isolation break execution.
        pass

    temp_data = run.vars.get("_temp", {})
    if not isinstance(temp_data, dict):
        return

    # Restore persisted outputs for executed (non-effect) nodes.
    persisted = temp_data.get("node_outputs")
    if isinstance(persisted, dict):
        for nid, out in persisted.items():
            if isinstance(nid, str) and nid:
                node_outputs[nid] = out

    effects = temp_data.get("effects")
    if not isinstance(effects, dict):
        effects = {}

    # Determine which output pins are actually referenced by data edges.
    # This lets us keep legacy pins (e.g. `result`) working for older saved flows,
    # without emitting them for new flows that don't wire them.
    referenced_source_pins: set[tuple[str, str]] = set()
    try:
        data_edge_map = getattr(flow, "_data_edge_map", None)
        if isinstance(data_edge_map, dict):
            for target_pins in data_edge_map.values():
                if not isinstance(target_pins, dict):
                    continue
                for src_any in target_pins.values():
                    if not (isinstance(src_any, tuple) and len(src_any) == 2):
                        continue
                    src_node_id, src_pin_id = src_any
                    if isinstance(src_node_id, str) and isinstance(src_pin_id, str):
                        referenced_source_pins.add((src_node_id, src_pin_id))
    except Exception:
        referenced_source_pins = set()

    def _get_span_id(raw: Any) -> Optional[str]:
        if not isinstance(raw, dict):
            return None
        results = raw.get("results")
        if not isinstance(results, list) or not results:
            return None
        first = results[0]
        if not isinstance(first, dict):
            return None
        meta = first.get("meta")
        if not isinstance(meta, dict):
            return None
        span_id = meta.get("span_id")
        if isinstance(span_id, str) and span_id.strip():
            return span_id.strip()
        return None

    def _as_dict_list(value: Any) -> List[Dict[str, Any]]:
        """Normalize a value into a list of dicts (best-effort, JSON-safe)."""
        if value is None:
            return []
        if isinstance(value, dict):
            return [dict(value)]
        if isinstance(value, list):
            out: List[Dict[str, Any]] = []
            for x in value:
                if isinstance(x, dict):
                    out.append(dict(x))
            return out
        return []

    def _has_nonempty_generation_map(value: Any) -> bool:
        if not isinstance(value, dict):
            return False
        for items in value.values():
            if isinstance(items, list) and items:
                return True
        return False

    def _artifact_ref_from_generated_item(item: Any) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
        if not isinstance(item, dict):
            return None, None

        raw_ref = item.get("artifact_ref")
        if isinstance(raw_ref, dict):
            artifact_id = raw_ref.get("$artifact") or raw_ref.get("artifact_id") or raw_ref.get("id")
            if isinstance(artifact_id, str) and artifact_id.strip():
                ref = dict(raw_ref)
                ref["$artifact"] = artifact_id.strip()
                ref["artifact_id"] = artifact_id.strip()
                return artifact_id.strip(), ref

        artifact_id = item.get("$artifact") or item.get("artifact_id")
        if isinstance(artifact_id, str) and artifact_id.strip():
            ref = {"$artifact": artifact_id.strip(), "artifact_id": artifact_id.strip()}
            content_type = item.get("content_type")
            if isinstance(content_type, str) and content_type.strip():
                ref["content_type"] = content_type.strip()
            size_bytes = item.get("size_bytes")
            if size_bytes is not None and not isinstance(size_bytes, bool):
                ref["size_bytes"] = size_bytes
            return artifact_id.strip(), ref

        return None, None

    def _first_generated_artifact(raw: Dict[str, Any]) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
        aid, ref = _artifact_ref_from_generated_item({"artifact_ref": raw.get("artifact_ref")})
        if aid and ref:
            return aid, ref

        top_artifact_id = raw.get("artifact_id")
        if isinstance(top_artifact_id, str) and top_artifact_id.strip():
            return top_artifact_id.strip(), {"$artifact": top_artifact_id.strip(), "artifact_id": top_artifact_id.strip()}

        for bucket_key in ("outputs", "resources"):
            bucket = raw.get(bucket_key)
            if not isinstance(bucket, dict):
                continue
            for items in bucket.values():
                if not isinstance(items, list):
                    continue
                for item in items:
                    aid, ref = _artifact_ref_from_generated_item(item)
                    if aid and ref:
                        return aid, ref
        return None, None

    def _extract_agent_tool_activity(scratchpad: Any) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
        """Extract tool call requests and tool results from an agent scratchpad.

        This is *post-run* ergonomics: it does not provide real-time streaming while the agent runs.
        For real-time tool observability, hosts should subscribe to the ledger and/or node_traces.
        """
        sp = scratchpad if isinstance(scratchpad, dict) else None
        if sp is None:
            return [], []

        node_traces = sp.get("node_traces")
        if not isinstance(node_traces, dict):
            # Allow passing a single node trace directly.
            if isinstance(sp.get("steps"), list) and sp.get("node_id") is not None:
                node_traces = {str(sp.get("node_id")): sp}
            else:
                return [], []

        # Flatten steps across nodes and sort by timestamp (ISO strings are lexicographically sortable).
        steps: List[Tuple[str, Dict[str, Any]]] = []
        for _nid, trace_any in node_traces.items():
            trace = trace_any if isinstance(trace_any, dict) else None
            if trace is None:
                continue
            entries = trace.get("steps")
            if not isinstance(entries, list):
                continue
            for entry_any in entries:
                entry = entry_any if isinstance(entry_any, dict) else None
                if entry is None:
                    continue
                ts = entry.get("ts")
                ts_s = ts if isinstance(ts, str) else ""
                steps.append((ts_s, entry))
        steps.sort(key=lambda x: x[0])

        tool_calls: List[Dict[str, Any]] = []
        tool_results: List[Dict[str, Any]] = []
        for _ts, entry in steps:
            effect = entry.get("effect")
            if not isinstance(effect, dict):
                continue
            if str(effect.get("type") or "") != "tool_calls":
                continue
            payload = effect.get("payload")
            payload_d = payload if isinstance(payload, dict) else {}
            tool_calls.extend(_as_dict_list(payload_d.get("tool_calls")))

            result = entry.get("result")
            if not isinstance(result, dict):
                continue
            results = result.get("results")
            tool_results.extend(_as_dict_list(results))

        return tool_calls, tool_results

    for node_id, flow_node in flow.nodes.items():
        effect_type = flow_node.effect_type
        if not effect_type:
            continue

        raw = effects.get(node_id)
        if raw is None:
            # Backward-compat for older runs/tests that stored by effect type.
            legacy_key = f"{effect_type}_response"
            raw = temp_data.get(legacy_key)
        if raw is None:
            continue

        current = node_outputs.get(node_id)
        if not isinstance(current, dict):
            current = {}
            node_outputs[node_id] = current
        else:
            # If this node previously produced a pre-effect placeholder from the
            # visual executor (e.g. `_pending_effect`), remove it now that we have
            # the durable effect outcome in `run.vars["_temp"]["effects"]`.
            current.pop("_pending_effect", None)

        mapped_value: Any = None

        if effect_type == "ask_user":
            if isinstance(raw, dict):
                # raw is usually {"response": "..."} (resume payload)
                current.update(raw)
                mapped_value = raw.get("response")
        elif effect_type == "answer_user":
            if isinstance(raw, dict):
                current.update(raw)
                mapped_value = raw.get("message")
            else:
                current["message"] = raw
                mapped_value = raw
        elif effect_type == "llm_call":
            if isinstance(raw, dict):
                tool_calls = _as_dict_list(raw.get("tool_calls"))
                data = raw.get("data")
                outputs = raw.get("outputs")
                resources = raw.get("resources")
                has_generated_output = _has_nonempty_generation_map(outputs) or _has_nonempty_generation_map(resources)
                response_text = ""
                if isinstance(data, (dict, list)):
                    try:
                        import json as _json

                        response_text = _json.dumps(data, ensure_ascii=False, separators=(",", ":"))
                    except Exception:
                        response_text = ""
                if not response_text:
                    content = raw.get("content")
                    response_text = str(content) if content is not None else ""
                current["response"] = response_text
                # Convenience pin: expose tool_calls directly, instead of forcing consumers
                # to drill into `result.tool_calls` via a Break Object node.
                current["tool_calls"] = tool_calls
                current["success"] = True
                if isinstance(outputs, dict):
                    current["outputs"] = outputs
                if isinstance(resources, dict):
                    current["resources"] = resources
                artifact_id, artifact_ref = _first_generated_artifact(raw)
                if artifact_id:
                    current["artifact_id"] = artifact_id
                if artifact_ref:
                    current["artifact_ref"] = artifact_ref

                meta: Dict[str, Any] = {
                    "schema": "abstractflow.llm_call.v1.meta",
                    "version": 1,
                    "output_mode": (
                        "media"
                        if has_generated_output
                        else "structured"
                        if isinstance(data, (dict, list))
                        else "unstructured"
                    ),
                    "tool_calls": len(tool_calls),
                }
                provider = raw.get("provider")
                if isinstance(provider, str) and provider.strip():
                    meta["provider"] = provider.strip()
                model = raw.get("model")
                if isinstance(model, str) and model.strip():
                    meta["model"] = model.strip()
                finish_reason = raw.get("finish_reason")
                if isinstance(finish_reason, str) and finish_reason.strip():
                    meta["finish_reason"] = finish_reason.strip()
                usage = raw.get("usage")
                if isinstance(usage, dict):
                    meta["usage"] = dict(usage)
                trace_id = raw.get("trace_id")
                if not (isinstance(trace_id, str) and trace_id.strip()):
                    md = raw.get("metadata")
                    if isinstance(md, dict):
                        trace_id = md.get("trace_id")
                if isinstance(trace_id, str) and trace_id.strip():
                    meta["trace"] = {"trace_id": trace_id.strip()}
                gen_time = raw.get("gen_time")
                if gen_time is not None and not isinstance(gen_time, bool):
                    meta["gen_time"] = gen_time
                ttft_ms = raw.get("ttft_ms")
                if ttft_ms is not None and not isinstance(ttft_ms, bool):
                    meta["ttft_ms"] = ttft_ms

                current["meta"] = meta
                # Legacy pins for older saved flows.
                # Only populate if the flow actually references these handles.
                if (node_id, "result") in referenced_source_pins:
                    current["result"] = raw
                if (node_id, "raw") in referenced_source_pins:
                    current["raw"] = raw
                if (node_id, "gen_time") in referenced_source_pins:
                    current["gen_time"] = gen_time
                if (node_id, "ttft_ms") in referenced_source_pins:
                    current["ttft_ms"] = ttft_ms
                mapped_value = response_text
        elif effect_type in {"generate_image", "generate_voice"}:
            if isinstance(raw, dict):
                modality = "image" if effect_type == "generate_image" else "voice"
                outputs = raw.get("outputs")
                current["outputs"] = outputs if isinstance(outputs, dict) else {}
                resources = raw.get("resources")
                if isinstance(resources, dict):
                    current["resources"] = resources
                artifact_id, artifact_ref = _first_generated_artifact(raw)
                if artifact_ref:
                    current["artifact_ref"] = artifact_ref
                    current["image_artifact" if modality == "image" else "audio_artifact"] = artifact_ref
                    ct = artifact_ref.get("content_type")
                    if isinstance(ct, str) and ct.strip():
                        current["content_type"] = ct.strip()
                if artifact_id:
                    current["artifact_id"] = artifact_id
                current["success"] = bool(artifact_id or artifact_ref)
                meta = {
                    "schema": f"abstractflow.{effect_type}.v1.meta",
                    "version": 1,
                    "modality": modality,
                    "provider": raw.get("provider"),
                    "model": raw.get("model"),
                }
                current["meta"] = {k: v for k, v in meta.items() if v is not None}
                current["raw"] = raw
                mapped_value = artifact_ref or artifact_id
        elif effect_type == "transcribe_audio":
            if isinstance(raw, dict):
                text_value = ""
                text_obj = raw.get("text")
                if isinstance(text_obj, dict):
                    content = text_obj.get("content")
                    if content is not None:
                        text_value = str(content)
                if not text_value:
                    content = raw.get("content")
                    if content is not None:
                        text_value = str(content)
                current["text"] = text_value
                artifact_id, artifact_ref = _first_generated_artifact(raw)
                if artifact_ref:
                    current["artifact_ref"] = artifact_ref
                    current["transcript_artifact"] = artifact_ref
                if artifact_id:
                    current["artifact_id"] = artifact_id
                current["success"] = True
                current["meta"] = {"schema": "abstractflow.transcribe_audio.v1.meta", "version": 1, "modality": "text", "task": "transcription"}
                current["raw"] = raw
                mapped_value = text_value
        elif effect_type == "listen_voice":
            if isinstance(raw, dict):
                current.update(raw)
                audio_ref = raw.get("audio_artifact") or raw.get("artifact") or raw.get("artifact_ref")
                if isinstance(audio_ref, dict):
                    current["audio_artifact"] = audio_ref
                    current["artifact_ref"] = audio_ref
                    aid = audio_ref.get("$artifact") or audio_ref.get("artifact_id")
                    if isinstance(aid, str) and aid.strip():
                        current["artifact_id"] = aid.strip()
                mapped_value = current.get("audio_artifact") or raw
            else:
                current["event_data"] = raw
                mapped_value = raw
        elif effect_type == "tool_calls":
            # Effect outcome is produced by AbstractRuntime TOOL_CALLS handler:
            # - executed: {"mode":"executed","results":[{call_id,name,success,output,error}, ...]}
            # - passthrough/untrusted: {"mode": "...", "tool_calls": [...]}
            if isinstance(raw, dict):
                mode = raw.get("mode")
                results = raw.get("results")
                if not isinstance(results, list):
                    results = []
                current["results"] = results
                # Only treat non-executed modes as failure (results are unavailable).
                if isinstance(mode, str) and mode.strip() and mode != "executed":
                    current["success"] = False
                else:
                    current["success"] = all(isinstance(r, dict) and r.get("success") is True for r in results)
                current["raw"] = raw
                mapped_value = current["results"]
        elif effect_type == "call_tool":
            # Convenience wrapper over TOOL_CALLS: expose a single tool's outcome as
            # (result, success) instead of an array of per-call results.
            if isinstance(raw, dict):
                mode = raw.get("mode")
                results = raw.get("results")
                if not isinstance(results, list):
                    results = []
                first = results[0] if results else None

                if isinstance(mode, str) and mode.strip() and mode != "executed":
                    current["success"] = False
                    current["result"] = f"Tool execution not completed (mode={mode})"
                elif isinstance(first, dict):
                    ok = first.get("success") is True
                    current["success"] = ok
                    current["result"] = first.get("output") if ok else (first.get("error") or "Tool execution failed")
                else:
                    current["success"] = False
                    current["result"] = "Missing tool result"

                current["raw"] = raw
                mapped_value = current["result"]
        elif effect_type == "agent":
            scratchpad = None
            answer: Optional[str] = None
            messages: Any = None
            bucket_success: Optional[bool] = None
            bucket_provider: Optional[str] = None
            bucket_model: Optional[str] = None
            bucket_iterations: Optional[int] = None
            bucket_sub_run_id: Optional[str] = None
            bucket_output_mode: Optional[str] = None
            agent_ns = temp_data.get("agent")
            if isinstance(agent_ns, dict):
                bucket = agent_ns.get(node_id)
                if isinstance(bucket, dict):
                    scratchpad = bucket.get("scratchpad")
                    ans = bucket.get("answer")
                    if isinstance(ans, str):
                        answer = ans
                    messages = bucket.get("messages")
                    if "success" in bucket:
                        bucket_success = bool(bucket.get("success"))
                    bp = bucket.get("provider")
                    bm = bucket.get("model")
                    if isinstance(bp, str) and bp.strip():
                        bucket_provider = bp.strip()
                    if isinstance(bm, str) and bm.strip():
                        bucket_model = bm.strip()
                    bi = bucket.get("iterations")
                    try:
                        if isinstance(bi, bool):
                            bucket_iterations = None
                        elif isinstance(bi, (int, float)):
                            bucket_iterations = int(bi)
                        elif isinstance(bi, str) and bi.strip():
                            bucket_iterations = int(float(bi.strip()))
                    except Exception:
                        bucket_iterations = None
                    bsr = bucket.get("sub_run_id")
                    if isinstance(bsr, str) and bsr.strip():
                        bucket_sub_run_id = bsr.strip()
                    bom = bucket.get("output_mode")
                    if isinstance(bom, str) and bom.strip():
                        bucket_output_mode = bom.strip()

            if scratchpad is None:
                # Fallback: use this node's own trace if present.
                try:
                    from abstractruntime.core.vars import get_node_trace as _get_node_trace
                except Exception:  # pragma: no cover
                    _get_node_trace = None  # type: ignore[assignment]
                if callable(_get_node_trace):
                    scratchpad = _get_node_trace(run.vars, node_id)

            scratchpad_obj = scratchpad if isinstance(scratchpad, dict) else None
            if scratchpad_obj is None:
                scratchpad_obj = {"node_id": node_id, "steps": []}

            # Convenience pins: expose tool activity extracted from the scratchpad trace.
            # This is intentionally best-effort and does not change agent execution behavior.
            tc, tr = _extract_agent_tool_activity(scratchpad_obj)

            # Embed tool activity inside the scratchpad for easy Break Object access.
            scratchpad_out = dict(scratchpad_obj)
            scratchpad_out["tool_calls"] = tc
            scratchpad_out["tool_results"] = tr

            # Best-effort response string:
            # - prefer preserved unstructured answer (before structured-output post-pass)
            # - fall back to common keys inside the raw result
            response = ""
            if bucket_output_mode == "structured" and isinstance(raw, dict):
                try:
                    import json as _json

                    response = _json.dumps(raw, ensure_ascii=False, separators=(",", ":"))
                except Exception:
                    response = ""
            if not response:
                if isinstance(answer, str) and answer.strip():
                    response = answer.strip()
                elif isinstance(raw, dict):
                    r1 = raw.get("result")
                    r2 = raw.get("response")
                    if isinstance(r1, str) and r1.strip():
                        response = r1.strip()
                    elif isinstance(r2, str) and r2.strip():
                        response = r2.strip()
                elif isinstance(raw, str) and raw.strip():
                    response = raw.strip()

            def _normalize_message_list(raw_msgs: Any) -> list[Dict[str, Any]]:
                if not isinstance(raw_msgs, list):
                    return []
                out: list[Dict[str, Any]] = []
                for m in raw_msgs:
                    if isinstance(m, dict):
                        out.append(dict(m))
                    else:
                        out.append({"role": "assistant", "content": str(m)})
                return out

            def _minimal_transcript() -> list[Dict[str, Any]]:
                task_s = ""
                if isinstance(raw, dict):
                    t = raw.get("task")
                    if isinstance(t, str) and t.strip():
                        task_s = t.strip()
                msgs: list[Dict[str, Any]] = []
                if task_s:
                    msgs.append({"role": "user", "content": task_s})
                if response:
                    msgs.append({"role": "assistant", "content": response})
                return msgs

            if not isinstance(scratchpad_out.get("messages"), list):
                msgs_out = _normalize_message_list(messages)
                if not msgs_out and isinstance(raw, dict):
                    ctx = raw.get("context")
                    ctx_d = ctx if isinstance(ctx, dict) else {}
                    msgs_out = _normalize_message_list(ctx_d.get("messages"))
                if not msgs_out:
                    msgs_out = _minimal_transcript()
                scratchpad_out["messages"] = msgs_out

            meta: Dict[str, Any] = {"schema": "abstractcode.agent.v1.meta", "version": 1}
            provider: Optional[str] = None
            model: Optional[str] = None
            iterations: Optional[int] = None
            sub_run_id: Optional[str] = None
            success: Optional[bool] = bucket_success

            if isinstance(raw, dict):
                p = raw.get("provider")
                m = raw.get("model")
                if isinstance(p, str) and p.strip():
                    provider = p.strip()
                if isinstance(m, str) and m.strip():
                    model = m.strip()

                it = raw.get("iterations")
                try:
                    if isinstance(it, bool):
                        iterations = None
                    elif isinstance(it, (int, float)):
                        iterations = int(it)
                    elif isinstance(it, str) and it.strip():
                        iterations = int(float(it.strip()))
                except Exception:
                    iterations = None

                sr = raw.get("sub_run_id")
                if isinstance(sr, str) and sr.strip():
                    sub_run_id = sr.strip()
                if "success" in raw:
                    success = bool(raw.get("success"))

            if sub_run_id is None:
                sr = bucket_sub_run_id or scratchpad_out.get("sub_run_id")
                if isinstance(sr, str) and sr.strip():
                    sub_run_id = sr.strip()

            if provider is None:
                provider = bucket_provider
            if model is None:
                model = bucket_model
            if iterations is None:
                iterations = bucket_iterations

            # Structured-output mode stores the final output object under `raw` without
            # provider/model metadata. When resuming from persisted state, infer these
            # fields from the last LLM_CALL step in the scratchpad trace (best-effort).
            if (provider is None or model is None) and isinstance(scratchpad_out.get("steps"), list):
                steps_any = scratchpad_out.get("steps")
                steps = steps_any if isinstance(steps_any, list) else []
                for entry_any in reversed(steps):
                    entry = entry_any if isinstance(entry_any, dict) else None
                    if entry is None:
                        continue
                    eff = entry.get("effect")
                    if not isinstance(eff, dict) or str(eff.get("type") or "") != "llm_call":
                        continue
                    payload = eff.get("payload")
                    if isinstance(payload, dict):
                        if provider is None:
                            p = payload.get("provider")
                            if isinstance(p, str) and p.strip():
                                provider = p.strip()
                        if model is None:
                            m = payload.get("model")
                            if isinstance(m, str) and m.strip():
                                model = m.strip()

                    res = entry.get("result")
                    if isinstance(res, dict):
                        if provider is None:
                            p = res.get("provider")
                            if isinstance(p, str) and p.strip():
                                provider = p.strip()
                        if model is None:
                            m = res.get("model")
                            if isinstance(m, str) and m.strip():
                                model = m.strip()

                    if provider is not None or model is not None:
                        break

            if provider:
                meta["provider"] = provider
            if model:
                meta["model"] = model
            if sub_run_id:
                meta["sub_run_id"] = sub_run_id
            if iterations is not None:
                meta["iterations"] = iterations
            meta["tool_calls"] = len(tc)
            meta["tool_results"] = len(tr)
            if bucket_output_mode:
                meta["output_mode"] = bucket_output_mode

            current["response"] = response
            current["success"] = bool(success) if success is not None else True
            current["meta"] = meta
            current["scratchpad"] = scratchpad_out
            # Legacy pin: older flows may still wire Agent.result into Break Object.
            if (node_id, "result") in referenced_source_pins:
                if isinstance(raw, dict) and (set(raw.keys()) - {"success"}):
                    current["result"] = raw
                else:
                    current["result"] = {
                        "response": response,
                        "success": current["success"],
                        "meta": meta,
                        "scratchpad": scratchpad_out,
                    }

            # Backward-compat / convenience: keep top-level tool activity too.
            current["tool_calls"] = tc
            current["tool_results"] = tr
            mapped_value = raw
        elif effect_type == "wait_event":
            current["event_data"] = raw
            mapped_value = raw
        elif effect_type == "on_event":
            # Custom event listener: the resume payload is a structured envelope.
            if isinstance(raw, dict):
                current["event"] = raw
                current["payload"] = raw.get("payload")
                current["event_id"] = raw.get("event_id")
                current["name"] = raw.get("name")
                mapped_value = raw
            else:
                current["event"] = raw
                mapped_value = raw
        elif effect_type == "on_schedule":
            cfg = flow_node.effect_config if isinstance(flow_node.effect_config, dict) else {}
            schedule_cfg = cfg.get("schedule")
            schedule_str = str(schedule_cfg or "").strip() if schedule_cfg is not None else ""
            recurrent_cfg = cfg.get("recurrent")
            recurrent_flag = True if recurrent_cfg is None else bool(recurrent_cfg)
            # ISO timestamps are treated as one-shot; recurrence is disabled.
            if schedule_str and not isinstance(schedule_cfg, (int, float)) and schedule_str:
                import re

                if not re.match(r"^\\s*\\d+(?:\\.\\d+)?\\s*(ms|s|m|h|d)\\s*$", schedule_str, re.IGNORECASE):
                    recurrent_flag = False

            if isinstance(raw, dict):
                current.update(raw)
                ts = raw.get("timestamp")
                if ts is None:
                    ts = raw.get("scheduled_for")
                current["timestamp"] = ts
                current["recurrent"] = recurrent_flag
                mapped_value = ts if ts is not None else raw
            else:
                current["timestamp"] = raw
                current["recurrent"] = recurrent_flag
                mapped_value = raw
        elif effect_type == "wait_until":
            if isinstance(raw, dict):
                current.update(raw)
            else:
                current["result"] = raw
            mapped_value = raw
        elif effect_type == "emit_event":
            # Custom event emission result (dispatch summary).
            if isinstance(raw, dict):
                current.update(raw)
                mapped_value = raw
            else:
                current["result"] = raw
                mapped_value = raw
        elif effect_type == "memory_note":
            span_id = _get_span_id(raw)
            current["note_id"] = span_id
            current["raw"] = raw
            mapped_value = span_id
        elif effect_type == "memory_compact":
            span_id = _get_span_id(raw)
            current["span_id"] = span_id
            current["raw"] = raw
            mapped_value = span_id
        elif effect_type == "memory_query":
            # Runtime returns a tool-results envelope:
            #   {"mode":"executed","results":[{call_id,name,success,output,error,meta?}, ...]}
            rendered = ""
            matches: list[Any] = []
            span_ids: list[Any] = []
            if isinstance(raw, dict):
                results_list = raw.get("results")
                if isinstance(results_list, list) and results_list:
                    first = results_list[0]
                    if isinstance(first, dict):
                        out = first.get("output")
                        if isinstance(out, str):
                            rendered = out
                        meta = first.get("meta")
                        if isinstance(meta, dict):
                            m = meta.get("matches")
                            if isinstance(m, list):
                                matches = m
                            sids = meta.get("span_ids")
                            if isinstance(sids, list):
                                span_ids = sids

            current["rendered"] = rendered
            current["results"] = matches
            current["span_ids"] = span_ids
            current["raw"] = raw
            mapped_value = current["results"]
        elif effect_type == "memory_tag":
            rendered = ""
            success = False
            results_list: list[Any] = []
            if isinstance(raw, dict):
                rl = raw.get("results")
                if isinstance(rl, list):
                    results_list = rl
                    if results_list:
                        first = results_list[0]
                        if isinstance(first, dict):
                            out = first.get("output")
                            if isinstance(out, str):
                                rendered = out
                            success = first.get("success") is True

            current["rendered"] = rendered
            current["success"] = bool(success)
            current["results"] = results_list
            current["raw"] = raw
            mapped_value = rendered
        elif effect_type == "memory_rehydrate":
            if isinstance(raw, dict):
                current["inserted"] = raw.get("inserted")
                current["skipped"] = raw.get("skipped")
                current["artifacts"] = raw.get("artifacts")
            else:
                current["inserted"] = 0
                current["skipped"] = 0
                current["artifacts"] = []
            current["raw"] = raw
            mapped_value = raw
        elif effect_type == "memory_kg_assert":
            if isinstance(raw, dict):
                ids = raw.get("assertion_ids")
                current["assertion_ids"] = ids if isinstance(ids, list) else []
                current["count"] = raw.get("count")
                current["ok"] = raw.get("ok")
            else:
                current["assertion_ids"] = []
                current["count"] = 0
                current["ok"] = False
            current["raw"] = raw
            mapped_value = current.get("assertion_ids")
        elif effect_type == "memory_kg_query":
            if isinstance(raw, dict):
                items = raw.get("items")
                current["items"] = items if isinstance(items, list) else []
                current["count"] = raw.get("count")
                current["ok"] = raw.get("ok")
                # Active Memory packing (optional; only present when max_input_tokens is set).
                if "packets_version" in raw:
                    current["packets_version"] = raw.get("packets_version")
                if "packets" in raw:
                    current["packets"] = raw.get("packets")
                if "packed_count" in raw:
                    current["packed_count"] = raw.get("packed_count")
                if "active_memory_text" in raw:
                    current["active_memory_text"] = raw.get("active_memory_text")
                if "estimated_tokens" in raw:
                    current["estimated_tokens"] = raw.get("estimated_tokens")
                if "dropped" in raw:
                    current["dropped"] = raw.get("dropped")
                if "warnings" in raw:
                    current["warnings"] = raw.get("warnings")
            else:
                current["items"] = []
                current["count"] = 0
                current["ok"] = False
                current["packets"] = []
                current["packed_count"] = 0
                current["active_memory_text"] = ""
                current["estimated_tokens"] = 0
                current["dropped"] = 0
            current["raw"] = raw
            mapped_value = current.get("items")
        elif effect_type == "memory_kg_resolve":
            if isinstance(raw, dict):
                candidates = raw.get("candidates")
                current["candidates"] = candidates if isinstance(candidates, list) else []
                current["count"] = raw.get("count")
                current["ok"] = raw.get("ok")
                if "warnings" in raw:
                    current["warnings"] = raw.get("warnings")
            else:
                current["candidates"] = []
                current["count"] = 0
                current["ok"] = False
            current["raw"] = raw
            mapped_value = current.get("candidates")
        elif effect_type == "start_subworkflow":
            if isinstance(raw, dict):
                current["sub_run_id"] = raw.get("sub_run_id")
                out = raw.get("output")
                if isinstance(out, dict) and "result" in out:
                    result_value = out.get("result")
                    current["output"] = result_value
                    current["child_output"] = out
                else:
                    result_value = out
                    current["output"] = result_value
                    if isinstance(out, dict):
                        current["child_output"] = out
                mapped_value = current.get("output")

                cfg = flow_node.effect_config or {}
                out_pins = cfg.get("output_pins")
                if isinstance(out_pins, list) and out_pins:
                    if isinstance(result_value, dict):
                        for pid in out_pins:
                            if isinstance(pid, str) and pid:
                                if pid == "output":
                                    continue
                                current[pid] = result_value.get(pid)
                    elif len(out_pins) == 1 and isinstance(out_pins[0], str) and out_pins[0]:
                        current[out_pins[0]] = result_value
            else:
                current["output"] = raw
                mapped_value = raw

        # Optional: also write the mapped output to run.vars if configured.
        if flow_node.output_key and mapped_value is not None:
            _set_nested(run.vars, flow_node.output_key, mapped_value)


def _set_nested(target: Dict[str, Any], dotted_key: str, value: Any) -> None:
    """Set nested dict value using dot notation."""
    parts = dotted_key.split(".")
    cur = target
    for p in parts[:-1]:
        nxt = cur.get(p)
        if not isinstance(nxt, dict):
            nxt = {}
            cur[p] = nxt
        cur = nxt
    cur[parts[-1]] = value


def compile_flow(flow: Flow) -> "WorkflowSpec":
    """Compile a Flow definition into an AbstractRuntime WorkflowSpec.

    This function transforms a declarative Flow definition into an executable
    WorkflowSpec that can be run by AbstractRuntime. Each flow node is converted
    to a workflow node handler based on its type:

    - Functions: Executed directly within the workflow
    - Agents: Run as subworkflows using START_SUBWORKFLOW effect
    - Nested Flows: Compiled recursively and run as subworkflows

    Args:
        flow: The Flow definition to compile

    Returns:
        A WorkflowSpec that can be executed by AbstractRuntime

    Raises:
        ValueError: If the flow is invalid (no entry node, missing nodes, etc.)
        TypeError: If a node handler is of unknown type

    Example:
        >>> flow = Flow("my_flow")
        >>> flow.add_node("start", my_func)
        >>> flow.set_entry("start")
        >>> spec = compile_flow(flow)
        >>> runtime.start(workflow=spec)
    """
    from abstractruntime.core.spec import WorkflowSpec

    # Validate flow
    errors = flow.validate()
    if errors:
        raise ValueError(f"Invalid flow: {'; '.join(errors)}")

    outgoing: Dict[str, list] = {}
    incoming: Dict[str, list] = {}
    for edge in flow.edges:
        outgoing.setdefault(edge.source, []).append(edge)
        incoming.setdefault(edge.target, []).append(edge)

    # Build next-node map (linear) and branch maps (If/Else).
    next_node_map: Dict[str, Optional[str]] = {}
    branch_maps: Dict[str, Dict[str, str]] = {}
    control_specs: Dict[str, Dict[str, Any]] = {}

    def _is_supported_branch_handle(handle: str) -> bool:
        return handle in {"true", "false", "default"} or handle.startswith("case:")

    for node_id in flow.nodes:
        outs = outgoing.get(node_id, [])
        if not outs:
            next_node_map[node_id] = None
            continue

        flow_node = flow.nodes.get(node_id)
        node_effect_type = getattr(flow_node, "effect_type", None) if flow_node else None

        # Sequence / Parallel: deterministic fan-out scheduling (Blueprint-style).
        #
        # Important: these nodes may have 0..N connected outputs; even a single
        # `then:0` edge should compile (it is not "branching" based on data).
        if node_effect_type in {"sequence", "parallel"}:
            # Validate all execution edges have a handle
            handles: list[str] = []
            targets_by_handle: Dict[str, str] = {}
            for e in outs:
                h = getattr(e, "source_handle", None)
                if not isinstance(h, str) or not h:
                    raise ValueError(
                        f"Control node '{node_id}' has an execution edge with no source_handle."
                    )
                handles.append(h)
                targets_by_handle[h] = e.target

            cfg = getattr(flow_node, "effect_config", None) if flow_node else None
            cfg_dict = cfg if isinstance(cfg, dict) else {}
            then_handles = cfg_dict.get("then_handles")
            if not isinstance(then_handles, list):
                then_handles = [h for h in handles if h.startswith("then:")]

                def _then_key(h: str) -> int:
                    try:
                        if h.startswith("then:"):
                            return int(h.split(":", 1)[1])
                    except Exception:
                        pass
                    return 10**9

                then_handles = sorted(then_handles, key=_then_key)
            else:
                then_handles = [str(h) for h in then_handles if isinstance(h, str) and h]

            allowed = set(then_handles)
            completed_target: Optional[str] = None
            if node_effect_type == "parallel":
                allowed.add("completed")
                completed_target = targets_by_handle.get("completed")

            unknown = [h for h in handles if h not in allowed]
            if unknown:
                raise ValueError(
                    f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
                )

            control_specs[node_id] = {
                "kind": node_effect_type,
                "then_handles": then_handles,
                "targets_by_handle": targets_by_handle,
                "completed_target": completed_target,
            }
            next_node_map[node_id] = None
            continue

        # Loop (Foreach): structured scheduling via `loop` (body) and `done` (completed).
        #
        # This is a scheduler node (like Sequence/Parallel), not data-driven branching.
        if node_effect_type == "loop":
            handles: list[str] = []
            targets_by_handle: Dict[str, str] = {}
            for e in outs:
                h = getattr(e, "source_handle", None)
                if not isinstance(h, str) or not h:
                    raise ValueError(
                        f"Control node '{node_id}' has an execution edge with no source_handle."
                    )
                handles.append(h)
                targets_by_handle[h] = e.target

            allowed = {"loop", "done"}
            unknown = [h for h in handles if h not in allowed]
            if unknown:
                raise ValueError(
                    f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
                )

            control_specs[node_id] = {
                "kind": "loop",
                "loop_target": targets_by_handle.get("loop"),
                "done_target": targets_by_handle.get("done"),
            }
            next_node_map[node_id] = None
            continue

        # While: structured scheduling via `loop` (body) and `done` (completed),
        # gated by a boolean condition pin resolved via data edges.
        if node_effect_type == "while":
            handles = []
            targets_by_handle = {}
            for e in outs:
                h = getattr(e, "source_handle", None)
                if not isinstance(h, str) or not h:
                    raise ValueError(
                        f"Control node '{node_id}' has an execution edge with no source_handle."
                    )
                handles.append(h)
                targets_by_handle[h] = e.target

            allowed = {"loop", "done"}
            unknown = [h for h in handles if h not in allowed]
            if unknown:
                raise ValueError(
                    f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
                )

            control_specs[node_id] = {
                "kind": "while",
                "loop_target": targets_by_handle.get("loop"),
                "done_target": targets_by_handle.get("done"),
            }
            next_node_map[node_id] = None
            continue

        # For: structured scheduling via `loop` (body) and `done` (completed),
        # over a numeric range resolved via data edges (start/end/step).
        if node_effect_type == "for":
            handles = []
            targets_by_handle = {}
            for e in outs:
                h = getattr(e, "source_handle", None)
                if not isinstance(h, str) or not h:
                    raise ValueError(
                        f"Control node '{node_id}' has an execution edge with no source_handle."
                    )
                handles.append(h)
                targets_by_handle[h] = e.target

            allowed = {"loop", "done"}
            unknown = [h for h in handles if h not in allowed]
            if unknown:
                raise ValueError(
                    f"Control node '{node_id}' has unsupported execution outputs: {unknown}"
                )

            control_specs[node_id] = {
                "kind": "for",
                "loop_target": targets_by_handle.get("loop"),
                "done_target": targets_by_handle.get("done"),
            }
            next_node_map[node_id] = None
            continue

        if len(outs) == 1:
            h = getattr(outs[0], "source_handle", None)
            if isinstance(h, str) and h and h != "exec-out":
                if not _is_supported_branch_handle(h):
                    raise ValueError(
                        f"Node '{node_id}' has unsupported branching output '{h}'. "
                        "Branching is not yet supported."
                    )
                branch_maps[node_id] = {h: outs[0].target}  # type: ignore[arg-type]
                next_node_map[node_id] = None
            else:
                next_node_map[node_id] = outs[0].target
            continue

        handles: list[str] = []
        for e in outs:
            h = getattr(e, "source_handle", None)
            if not isinstance(h, str) or not h:
                handles = []
                break
            handles.append(h)

        if len(handles) != len(outs) or len(set(handles)) != len(handles):
            raise ValueError(
                f"Node '{node_id}' has multiple outgoing edges. "
                "Branching is not yet supported."
            )

        # Minimal branching support: If/Else uses `true` / `false` execution outputs.
        if set(handles) <= {"true", "false"}:
            branch_maps[node_id] = {e.source_handle: e.target for e in outs}  # type: ignore[arg-type]
            next_node_map[node_id] = None
            continue

        # Switch branching: stable case handles + optional default.
        if all(h == "default" or h.startswith("case:") for h in handles):
            branch_maps[node_id] = {e.source_handle: e.target for e in outs}  # type: ignore[arg-type]
            next_node_map[node_id] = None
            continue

        raise ValueError(
            f"Node '{node_id}' has multiple outgoing edges. "
            "Branching is not yet supported."
        )

    # Determine exit node if not set
    exit_node = flow.exit_node
    if not exit_node:
        terminal_nodes = flow.get_terminal_nodes()
        if len(terminal_nodes) == 1:
            exit_node = terminal_nodes[0]
        elif len(terminal_nodes) > 1:
            # Multiple terminals - each will complete the flow when reached
            pass

    # Create node handlers
    handlers: Dict[str, Callable] = {}

    def _wrap_return_to_active_control(
        handler: Callable,
        *,
        node_id: str,
        visual_type: Optional[str],
    ) -> Callable:
        """If a node tries to complete the run inside an active control block, return to the scheduler.

        This is crucial for Blueprint-style nodes:
        - branch chains can end (no outgoing exec edges) without ending the whole flow
        - Sequence/Parallel can then continue scheduling other branches
        """
        from abstractruntime.core.models import StepPlan

        try:
            from .adapters.control_adapter import get_active_control_node_id
        except Exception:  # pragma: no cover
            get_active_control_node_id = None  # type: ignore[assignment]

        def wrapped(run: Any, ctx: Any) -> "StepPlan":
            plan: StepPlan = handler(run, ctx)
            if not callable(get_active_control_node_id):
                return plan

            active = get_active_control_node_id(run.vars)
            if not isinstance(active, str) or not active:
                return plan
            if active == node_id:
                return plan

            # Explicit end node should always terminate the run, even inside a control block.
            if visual_type == "on_flow_end":
                return plan

            # If the node is about to complete the run, treat it as "branch complete" instead.
            if plan.complete_output is not None:
                return StepPlan(node_id=plan.node_id, next_node=active)

            # Terminal effect node: runtime would auto-complete if next_node is missing.
            if plan.effect is not None and not plan.next_node:
                return StepPlan(node_id=plan.node_id, effect=plan.effect, next_node=active)

            # Defensive fallback.
            if plan.effect is None and not plan.next_node and plan.complete_output is None:
                return StepPlan(node_id=plan.node_id, next_node=active)

            return plan

        return wrapped

    for node_id, flow_node in flow.nodes.items():
        next_node = next_node_map.get(node_id)
        branch_map = branch_maps.get(node_id)
        handler_obj = getattr(flow_node, "handler", None)
        effect_type = getattr(flow_node, "effect_type", None)
        effect_config = getattr(flow_node, "effect_config", None) or {}
        visual_type = effect_config.get("_visual_type") if isinstance(effect_config, dict) else None

        # Check for effect/control nodes first
        if effect_type == "sequence":
            from .adapters.control_adapter import create_sequence_node_handler

            spec = control_specs.get(node_id) or {}
            handlers[node_id] = create_sequence_node_handler(
                node_id=node_id,
                ordered_then_handles=list(spec.get("then_handles") or []),
                targets_by_handle=dict(spec.get("targets_by_handle") or {}),
            )
        elif effect_type == "parallel":
            from .adapters.control_adapter import create_parallel_node_handler

            spec = control_specs.get(node_id) or {}
            handlers[node_id] = create_parallel_node_handler(
                node_id=node_id,
                ordered_then_handles=list(spec.get("then_handles") or []),
                targets_by_handle=dict(spec.get("targets_by_handle") or {}),
                completed_target=spec.get("completed_target"),
            )
        elif effect_type == "join_exec":
            from .adapters.control_adapter import create_join_exec_node_handler

            if not next_node:
                raise ValueError(f"Join Exec node '{node_id}' must have an outgoing exec edge.")

            ins = incoming.get(node_id, []) if isinstance(incoming, dict) else []
            routes: list[dict[str, str]] = []
            for e in ins:
                try:
                    src = getattr(e, "source", None)
                    if not isinstance(src, str) or not src:
                        continue
                    h = getattr(e, "source_handle", None)
                    handle = h if isinstance(h, str) and h else "exec-out"
                    routes.append({"source": src, "handle": handle})
                except Exception:
                    continue

            base_join = create_join_exec_node_handler(node_id=node_id, next_node=next_node, routes=routes)

            pure_ids = getattr(flow, "_pure_node_ids", None) if flow is not None else None
            pure_ids = set(pure_ids) if isinstance(pure_ids, (set, list, tuple)) else set()

            def _wrapped_join(
                run: Any,
                ctx: Any,
                *,
                _base: Any = base_join,
                _pure_ids: set[str] = pure_ids,
            ) -> StepPlan:
                # Join Exec is the canonical re-entry boundary for wiring-based loops.
                # Invalidate cached pure node outputs so trajectory-dependent pins recompute.
                if flow is not None and _pure_ids and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                    node_outputs = getattr(flow, "_node_outputs", None)
                    if isinstance(node_outputs, dict):
                        for nid in _pure_ids:
                            node_outputs.pop(nid, None)

                plan = _base(run, ctx)

                if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                return plan

            handlers[node_id] = _wrapped_join
        elif effect_type == "loop":
            from .adapters.control_adapter import create_loop_node_handler

            spec = control_specs.get(node_id) or {}
            loop_data_handler = handler_obj if callable(handler_obj) else None

            # Precompute upstream pure-node ids for cache invalidation (best-effort).
            #
            # Pure nodes (e.g. concat/split/break_object) are cached in `flow._node_outputs`.
            # Inside a Loop, the inputs to those pure nodes often change per-iteration
            # (index/item, evolving scratchpad vars, etc.). If we don't invalidate, the
            # loop body may reuse stale values from iteration 0.
            pure_ids = getattr(flow, "_pure_node_ids", None) if flow is not None else None
            pure_ids = set(pure_ids) if isinstance(pure_ids, (set, list, tuple)) else set()

            def _resolve_items(
                run: Any,
                _handler: Any = loop_data_handler,
                _node_id: str = node_id,
            ) -> list[Any]:
                if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                if not callable(_handler):
                    return []
                last_output = run.vars.get("_last_output", {})
                try:
                    resolved = _handler(last_output)
                except Exception as e:
                    # Surface this as a workflow error (don't silently treat as empty).
                    try:
                        run.vars["_flow_error"] = f"Loop items resolution failed: {e}"
                        run.vars["_flow_error_node"] = _node_id
                    except Exception:
                        pass
                    raise
                if not isinstance(resolved, dict):
                    return []
                raw = resolved.get("items")
                if isinstance(raw, list):
                    return raw
                if isinstance(raw, tuple):
                    return list(raw)
                if raw is None:
                    return []
                return [raw]

            base_loop = create_loop_node_handler(
                node_id=node_id,
                loop_target=spec.get("loop_target"),
                done_target=spec.get("done_target"),
                resolve_items=_resolve_items,
            )

            def _wrapped_loop(
                run: Any,
                ctx: Any,
                *,
                _base: Any = base_loop,
                _pure_ids: set[str] = pure_ids,
            ) -> StepPlan:
                # Ensure pure nodes feeding the loop body are re-evaluated per iteration.
                if flow is not None and _pure_ids and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                    node_outputs = getattr(flow, "_node_outputs", None)
                    if isinstance(node_outputs, dict):
                        for nid in _pure_ids:
                            node_outputs.pop(nid, None)
                plan = _base(run, ctx)
                # The loop scheduler persists `{item,index,total}` into run.vars, but
                # UI node_complete events read from `flow._node_outputs`. Sync after
                # scheduling so observability reflects the current iteration.
                if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                return plan

            handlers[node_id] = _wrapped_loop
        elif effect_type == "agent":
            data_aware_handler = handler_obj if callable(handler_obj) else None
            handlers[node_id] = _create_visual_agent_effect_handler(
                node_id=node_id,
                next_node=next_node,
                agent_config=effect_config if isinstance(effect_config, dict) else {},
                data_aware_handler=data_aware_handler,
                flow=flow,
            )
        elif effect_type == "while":
            from .adapters.control_adapter import create_while_node_handler

            spec = control_specs.get(node_id) or {}
            while_data_handler = handler_obj if callable(handler_obj) else None

            # Precompute upstream pure-node ids for cache invalidation (best-effort).
            pure_ids = getattr(flow, "_pure_node_ids", None) if flow is not None else None
            pure_ids = set(pure_ids) if isinstance(pure_ids, (set, list, tuple)) else set()

            data_edge_map = getattr(flow, "_data_edge_map", None) if flow is not None else None
            data_edge_map = data_edge_map if isinstance(data_edge_map, dict) else {}

            upstream_pure: set[str] = set()
            if pure_ids:
                stack2 = [node_id]
                seen2: set[str] = set()
                while stack2:
                    cur = stack2.pop()
                    if cur in seen2:
                        continue
                    seen2.add(cur)
                    deps = data_edge_map.get(cur)
                    if not isinstance(deps, dict):
                        continue
                    for _pin, src in deps.items():
                        if not isinstance(src, tuple) or len(src) != 2:
                            continue
                        src_node = src[0]
                        if not isinstance(src_node, str) or not src_node:
                            continue
                        stack2.append(src_node)
                        if src_node in pure_ids:
                            upstream_pure.add(src_node)

            def _resolve_condition(
                run: Any,
                _handler: Any = while_data_handler,
                _node_id: str = node_id,
                _upstream_pure: set[str] = upstream_pure,
            ) -> bool:
                if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                    # Ensure pure nodes feeding the condition are re-evaluated per iteration.
                    if _upstream_pure and hasattr(flow, "_node_outputs"):
                        node_outputs = getattr(flow, "_node_outputs", None)
                        if isinstance(node_outputs, dict):
                            for nid in _upstream_pure:
                                node_outputs.pop(nid, None)

                if not callable(_handler):
                    return False

                last_output = run.vars.get("_last_output", {})
                try:
                    resolved = _handler(last_output)
                except Exception as e:
                    try:
                        run.vars["_flow_error"] = f"While condition resolution failed: {e}"
                        run.vars["_flow_error_node"] = _node_id
                    except Exception:
                        pass
                    raise

                if isinstance(resolved, dict) and "condition" in resolved:
                    return bool(resolved.get("condition"))
                return bool(resolved)

            base_while = create_while_node_handler(
                node_id=node_id,
                loop_target=spec.get("loop_target"),
                done_target=spec.get("done_target"),
                resolve_condition=_resolve_condition,
            )

            def _wrapped_while(
                run: Any,
                ctx: Any,
                *,
                _base: Any = base_while,
                _pure_ids: set[str] = pure_ids,
            ) -> StepPlan:
                # Ensure pure nodes feeding the loop body are re-evaluated per iteration.
                if flow is not None and _pure_ids and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                    node_outputs = getattr(flow, "_node_outputs", None)
                    if isinstance(node_outputs, dict):
                        for nid in _pure_ids:
                            node_outputs.pop(nid, None)
                plan = _base(run, ctx)
                # While scheduler persists `index` into run.vars; sync so WS/UI
                # node_complete events show the latest iteration count.
                if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                return plan

            handlers[node_id] = _wrapped_while
        elif effect_type == "for":
            from .adapters.control_adapter import create_for_node_handler

            spec = control_specs.get(node_id) or {}
            for_data_handler = handler_obj if callable(handler_obj) else None

            # Precompute upstream pure-node ids for cache invalidation (best-effort).
            pure_ids = getattr(flow, "_pure_node_ids", None) if flow is not None else None
            pure_ids = set(pure_ids) if isinstance(pure_ids, (set, list, tuple)) else set()

            def _resolve_range(
                run: Any,
                _handler: Any = for_data_handler,
                _node_id: str = node_id,
            ) -> Dict[str, Any]:
                if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                if not callable(_handler):
                    return {}
                last_output = run.vars.get("_last_output", {})
                try:
                    resolved = _handler(last_output)
                except Exception as e:
                    try:
                        run.vars["_flow_error"] = f"For range resolution failed: {e}"
                        run.vars["_flow_error_node"] = _node_id
                    except Exception:
                        pass
                    raise
                return resolved if isinstance(resolved, dict) else {}

            base_for = create_for_node_handler(
                node_id=node_id,
                loop_target=spec.get("loop_target"),
                done_target=spec.get("done_target"),
                resolve_range=_resolve_range,
            )

            def _wrapped_for(
                run: Any,
                ctx: Any,
                *,
                _base: Any = base_for,
                _pure_ids: set[str] = pure_ids,
            ) -> StepPlan:
                # Ensure pure nodes feeding the loop body are re-evaluated per iteration.
                if flow is not None and _pure_ids and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                    node_outputs = getattr(flow, "_node_outputs", None)
                    if isinstance(node_outputs, dict):
                        for nid in _pure_ids:
                            node_outputs.pop(nid, None)
                plan = _base(run, ctx)
                # For scheduler persists `{i,index,total}` into run.vars; sync so
                # WS/UI node_complete events show the current iteration.
                if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                return plan

            handlers[node_id] = _wrapped_for
        elif effect_type == "on_event":
            from .adapters.event_adapter import create_on_event_node_handler

            on_event_data_handler = handler_obj if callable(handler_obj) else None

            def _resolve_inputs(
                run: Any,
                _handler: Any = on_event_data_handler,
            ) -> Dict[str, Any]:
                if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                if not callable(_handler):
                    return {}
                last_output = run.vars.get("_last_output", {})
                try:
                    resolved = _handler(last_output)
                except Exception:
                    resolved = {}
                return resolved if isinstance(resolved, dict) else {}

            # Blank/unspecified name is treated as "listen to any event" (wildcard).
            default_name = ""
            scope = "session"
            if isinstance(effect_config, dict):
                raw_name = effect_config.get("name") or effect_config.get("event_name")
                if isinstance(raw_name, str) and raw_name.strip():
                    default_name = raw_name
                raw_scope = effect_config.get("scope")
                if isinstance(raw_scope, str) and raw_scope.strip():
                    scope = raw_scope

            handlers[node_id] = create_on_event_node_handler(
                node_id=node_id,
                next_node=next_node,
                resolve_inputs=_resolve_inputs if callable(on_event_data_handler) else None,
                default_name=default_name,
                scope=scope,
                flow=flow,
            )
        elif effect_type == "on_schedule":
            from .adapters.event_adapter import create_on_schedule_node_handler

            on_schedule_data_handler = handler_obj if callable(handler_obj) else None

            def _resolve_inputs(
                run: Any,
                _handler: Any = on_schedule_data_handler,
            ) -> Dict[str, Any]:
                if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                if not callable(_handler):
                    return {}
                last_output = run.vars.get("_last_output", {})
                try:
                    resolved = _handler(last_output)
                except Exception:
                    resolved = {}
                return resolved if isinstance(resolved, dict) else {}

            schedule = "15s"
            recurrent = True
            if isinstance(effect_config, dict):
                raw_schedule = effect_config.get("schedule")
                if isinstance(raw_schedule, str) and raw_schedule.strip():
                    schedule = raw_schedule.strip()
                raw_recurrent = effect_config.get("recurrent")
                if isinstance(raw_recurrent, bool):
                    recurrent = raw_recurrent

            handlers[node_id] = create_on_schedule_node_handler(
                node_id=node_id,
                next_node=next_node,
                resolve_inputs=_resolve_inputs if callable(on_schedule_data_handler) else None,
                schedule=schedule,
                recurrent=recurrent,
                flow=flow,
            )
        elif effect_type == "emit_event":
            from .adapters.event_adapter import create_emit_event_node_handler

            emit_data_handler = handler_obj if callable(handler_obj) else None

            def _resolve_inputs(
                run: Any,
                _handler: Any = emit_data_handler,
            ) -> Dict[str, Any]:
                if flow is not None and hasattr(flow, "_node_outputs") and hasattr(flow, "_data_edge_map"):
                    _sync_effect_results_to_node_outputs(run, flow)
                if not callable(_handler):
                    return {}
                last_output = run.vars.get("_last_output", {})
                try:
                    resolved = _handler(last_output)
                except Exception:
                    resolved = {}
                return resolved if isinstance(resolved, dict) else {}

            default_name = ""
            default_session_id: Optional[str] = None
            scope = "session"
            if isinstance(effect_config, dict):
                raw_name = effect_config.get("name") or effect_config.get("event_name")
                if isinstance(raw_name, str) and raw_name.strip():
                    default_name = raw_name
                raw_session = effect_config.get("session_id")
                if raw_session is None:
                    raw_session = effect_config.get("sessionId")
                if isinstance(raw_session, str) and raw_session.strip():
                    default_session_id = raw_session.strip()
                raw_scope = effect_config.get("scope")
                if isinstance(raw_scope, str) and raw_scope.strip():
                    scope = raw_scope

            handlers[node_id] = create_emit_event_node_handler(
                node_id=node_id,
                next_node=next_node,
                resolve_inputs=_resolve_inputs,
                default_name=default_name,
                default_session_id=default_session_id,
                scope=scope,
            )
        elif effect_type:
            # Pass the handler_obj as data_aware_handler if it's callable
            # This allows visual flows to resolve data edges before creating effects
            data_aware_handler = handler_obj if callable(handler_obj) else None
            handlers[node_id] = _create_effect_node_handler(
                node_id=node_id,
                effect_type=effect_type,
                effect_config=effect_config,
                next_node=next_node,
                input_key=getattr(flow_node, "input_key", None),
                output_key=getattr(flow_node, "output_key", None),
                data_aware_handler=data_aware_handler,
                flow=flow,
            )
        elif _is_agent(handler_obj):
            handlers[node_id] = create_agent_node_handler(
                node_id=node_id,
                agent=handler_obj,
                next_node=next_node,
                input_key=getattr(flow_node, "input_key", None),
                output_key=getattr(flow_node, "output_key", None),
            )
        elif _is_flow(handler_obj):
            # Nested flow - compile recursively
            nested_spec = compile_flow(handler_obj)
            handlers[node_id] = create_subflow_node_handler(
                node_id=node_id,
                nested_workflow=nested_spec,
                next_node=next_node,
                input_key=getattr(flow_node, "input_key", None),
                output_key=getattr(flow_node, "output_key", None),
            )
        elif visual_type == "set_var":
            from .adapters.variable_adapter import create_set_var_node_handler

            data_aware_handler = handler_obj if callable(handler_obj) else None
            handlers[node_id] = create_set_var_node_handler(
                node_id=node_id,
                next_node=next_node,
                data_aware_handler=data_aware_handler,
                flow=flow,
            )
        elif visual_type == "add_message":
            from .adapters.context_adapter import create_add_message_node_handler

            data_aware_handler = handler_obj if callable(handler_obj) else None
            handlers[node_id] = create_add_message_node_handler(
                node_id=node_id,
                next_node=next_node,
                data_aware_handler=data_aware_handler,
                flow=flow,
            )
        elif visual_type == "memact_compose":
            from .adapters.memact_adapter import create_memact_compose_node_handler

            data_aware_handler = handler_obj if callable(handler_obj) else None
            handlers[node_id] = create_memact_compose_node_handler(
                node_id=node_id,
                next_node=next_node,
                data_aware_handler=data_aware_handler,
                flow=flow,
            )
        elif visual_type == "set_var_property":
            from .adapters.variable_adapter import create_set_var_property_node_handler

            data_aware_handler = handler_obj if callable(handler_obj) else None
            handlers[node_id] = create_set_var_property_node_handler(
                node_id=node_id,
                next_node=next_node,
                data_aware_handler=data_aware_handler,
                flow=flow,
            )
        elif visual_type == "set_vars":
            from .adapters.variable_adapter import create_set_vars_node_handler

            data_aware_handler = handler_obj if callable(handler_obj) else None
            handlers[node_id] = create_set_vars_node_handler(
                node_id=node_id,
                next_node=next_node,
                data_aware_handler=data_aware_handler,
                flow=flow,
            )
        elif callable(handler_obj):
            # Plain Flow callables should behave like standard function nodes (input defaults to run.vars).
            #
            # VisualFlow callables (builtins/code/function/etc.) need data-edge awareness, `_last_output`
            # pipelining, and persisted node outputs for pause/resume, so they use the visual handler.
            is_visual_flow = bool(
                hasattr(flow, "_data_edge_map") and hasattr(flow, "_node_outputs")
            ) or isinstance(visual_type, str)

            if not is_visual_flow:
                handlers[node_id] = create_function_node_handler(
                    node_id=node_id,
                    func=handler_obj,
                    next_node=next_node,
                    input_key=getattr(flow_node, "input_key", None),
                    output_key=getattr(flow_node, "output_key", None),
                )
            else:
                handlers[node_id] = _create_visual_function_handler(
                    node_id=node_id,
                    func=handler_obj,
                    next_node=next_node,
                    input_key=getattr(flow_node, "input_key", None),
                    output_key=getattr(flow_node, "output_key", None),
                    branch_map=branch_map,
                    flow=flow,
                )
        else:
            raise TypeError(
                f"Unknown handler type for node '{node_id}': {type(handler_obj)}. "
                "Expected agent, function, or Flow."
            )

        # Blueprint-style control flow: terminal nodes inside Sequence/Parallel should
        # return to the active scheduler instead of completing the whole run.
        handlers[node_id] = _wrap_return_to_active_control(
            handlers[node_id],
            node_id=node_id,
            visual_type=visual_type if isinstance(visual_type, str) else None,
        )

    return WorkflowSpec(
        workflow_id=flow.flow_id,
        entry_node=flow.entry_node,
        nodes=handlers,
    )


def compile_visualflow(raw_or_model: Any) -> "WorkflowSpec":
    """Compile a VisualFlow JSON object (or Pydantic-like model) into a WorkflowSpec."""
    from .visual.models import load_visualflow_json
    from .visual.executor import visual_to_flow

    vf = load_visualflow_json(raw_or_model)
    flow = visual_to_flow(vf)
    return compile_flow(flow)


def compile_visualflow_tree(
    *,
    root_id: Optional[str],
    flows_by_id: Dict[str, Any],
) -> Dict[str, "WorkflowSpec"]:
    """Compile a flow tree (root + referenced subflows) to WorkflowSpecs.

    Notes:
    - This compiles VisualFlow JSON into callable WorkflowSpecs (not portable).
    - Subflow references are detected from `subflow` nodes (`subflowId`/`flowId`).
    - Cycles are allowed; each flow id is compiled once.
    """
    from .visual.models import load_visualflow_json
    from .visual.executor import visual_to_flow

    parsed: Dict[str, Any] = {}
    for fid, raw in dict(flows_by_id or {}).items():
        fid2 = str(fid or "").strip()
        if not fid2:
            continue
        parsed[fid2] = load_visualflow_json(raw)

    if not parsed:
        return {}

    start = str(root_id or "").strip() or next(iter(parsed.keys()))

    def _subflow_ref(node: Any) -> Optional[str]:
        data = getattr(node, "data", None)
        if not isinstance(data, dict):
            return None
        sid = data.get("subflowId") or data.get("flowId") or data.get("workflowId") or data.get("workflow_id")
        if isinstance(sid, str) and sid.strip():
            return sid.strip()
        return None

    visited: set[str] = set()
    queue: list[str] = [start]
    while queue:
        fid = queue.pop(0)
        if not fid or fid in visited:
            continue
        vf = parsed.get(fid)
        if vf is None:
            # Missing subflow: skip (hosts may choose to error earlier).
            continue
        visited.add(fid)
        for n in getattr(vf, "nodes", []) or []:
            t = getattr(n, "type", None)
            type_str = t.value if hasattr(t, "value") else str(t or "")
            if type_str != "subflow":
                continue
            sid = _subflow_ref(n)
            if sid:
                queue.append(sid)

    specs: Dict[str, "WorkflowSpec"] = {}
    for fid in visited:
        vf = parsed.get(fid)
        if vf is None:
            continue
        f = visual_to_flow(vf)
        spec = compile_flow(f)
        specs[str(spec.workflow_id)] = spec
    return specs
