```python:inference/core/workflows/core_steps/composition/workflow_caller/v1.py
import logging
from typing import List, Optional, Tuple

from inference.core.roboflow_api import get_workflow_specification

WORKFLOW_CALLER_BLOCK_TYPE = "roboflow_core/workflow_caller@v1"


def _check_workflow_for_circular_references(
    api_key: Optional[str],
    workspace_name: str,
    target_workflow_id: str,
    visited: frozenset,
    target_workflow_version_id: Optional[str] = None,
    prefetched_spec: Optional[dict] = None,
) -> None:
    """Recursively check if a target workflow creates a circular call chain.

    Raises ``ExecutionGraphStructureError`` if a cycle is found. Silently
    returns on any fetch error — the runtime check will catch those cases.

    When ``prefetched_spec`` is provided it is used directly instead of
    fetching the spec again.  The caller in
    ``validate_workflow_caller_no_circular_references`` already fetches the
    spec for the top-level workflow, so passing it here avoids a duplicate
    API call.
    """
    from inference.core.workflows.errors import ExecutionGraphStructureError

    if target_workflow_id in visited:
        chain = " -> ".join([*visited, target_workflow_id])
        raise ExecutionGraphStructureError(
            public_message=(
                f"Circular workflow call detected at compile time: {chain}. "
                f"This would cause infinite recursion at runtime."
            ),
            context="workflow_compilation | workflow_caller_validation",
        )
    if prefetched_spec is not None:
        spec = prefetched_spec
    else:
        try:
            spec = get_workflow_specification(
                api_key=api_key,
                workspace_id=workspace_name,
                workflow_id=target_workflow_id,
                workflow_version_id=target_workflow_version_id,
            )
        except Exception as error:
            logging.debug(
                f"Could not fetch workflow '{target_workflow_id}' for circular "
                f"dependency check: {error}"
            )
            return
    nested_workflows = _extract_workflow_caller_ids_from_spec(spec=spec)
    if not nested_workflows:
        return
    visited_with_current = visited | {target_workflow_id}
    for nested_id, nested_version_id in nested_workflows:
        _check_workflow_for_circular_references(
            api_key=api_key,
            workspace_name=workspace_name,
            target_workflow_id=nested_id,
            target_workflow_version_id=nested_version_id,
            visited=visited_with_current,
        )


def _extract_workflow_caller_ids_from_spec(
    spec: dict,
) -> List[Tuple[str, Optional[str]]]:
    """Extract static (workflow_id, workflow_version_id) tuples from a raw
    workflow specification.  Only includes entries where ``workflow_id`` is a
    literal string (not a selector).  ``workflow_version_id`` is included when
    it is a literal string, otherwise ``None``."""
    steps = spec.get("steps", [])
    if not steps:
        return []
    
    result: List[Tuple[str, Optional[str]]] = []
    for step in steps:
        if step.get("type") != WORKFLOW_CALLER_BLOCK_TYPE:
            continue
        workflow_id = step.get("workflow_id", "")
        if not isinstance(workflow_id, str) or not workflow_id or workflow_id[0] == "$":
            continue
        version_id = step.get("workflow_version_id")
        if isinstance(version_id, str) and version_id and version_id[0] == "$":
            version_id = None
        result.append((workflow_id, version_id))
    return result
```