Add server-side TypeResolver for prompt-graph type resolution

Resolves the concrete io_type of any output/input slot in a prompt by
walking the graph, so API-submitted workflows (no frontend) and the
execution engine agree on resolved types even when MatchType chains are
involved.

* New comfy_execution/type_resolver.py: TypeResolver class with output
  resolution (incl. MatchType template walking, cycle detection, depth
  cap, AnyType fallback + one-shot warning), input resolution (links and
  literals), is_output_list / is_input_list helpers, effective slot
  io_type peeling for dynamic wrappers (Autogrow -> wrapped element
  type, DynamicSlot -> underlying slot type), and bulk
  compute_live_input_types.
* DynamicPrompt now lazily exposes get_type_resolver() and invalidates
  the resolver cache on add_ephemeral_node.
* get_finalized_class_inputs / parse_class_inputs / DYNAMIC_INPUT_LOOKUP
  callable signature accept an optional live_input_types dict. Existing
  Autogrow/DynamicSlot/DynamicCombo expansions accept and ignore it;
  future per-type dynamic inputs use it as their discriminator.
* validate_inputs and get_input_data both build live_input_types via
  the resolver and pass it through; validate_inputs also uses the
  resolver to determine received_type for linked inputs so MatchType
  chains in API workflows validate correctly.
* validate_prompt builds one TypeResolver and shares it across all
  output-node validations to avoid re-walking chains.
* tests-unit/execution_test/test_type_resolver.py covers V1 static
  return types, V1 wildcard warning behavior, MatchType resolution
  including first-wins, cycle termination, chain walking, input
  resolution, Autogrow peeling, list info, and cache invalidation.

Amp-Thread-ID: https://ampcode.com/threads/T-019e8568-f382-743d-a97f-0de3ff29d501
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Jedrzej Kosinski
2026-06-01 16:24:48 -07:00
parent 06b710aa68
commit 19390c112a
5 changed files with 835 additions and 22 deletions

View File

@@ -83,8 +83,9 @@ class IsChangedCache:
self.is_changed[node_id] = node["is_changed"]
return self.is_changed[node_id]
# Intentionally do not use cached outputs here. We only want constants in IS_CHANGED
input_data_all, _, v3_data = get_input_data(node["inputs"], class_def, node_id, None)
# Intentionally do not use cached outputs here. We only want constants in IS_CHANGED.
# Pass dynprompt so the TypeResolver can resolve link types for V3 dynamic schemas.
input_data_all, _, v3_data = get_input_data(node["inputs"], class_def, node_id, None, self.dynprompt)
try:
is_changed = await _async_map_node_over_list(self.prompt_id, node_id, class_def, input_data_all, is_changed_name, v3_data=v3_data)
is_changed = await resolve_map_node_over_list_results(is_changed)
@@ -158,7 +159,15 @@ def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt=
hidden_inputs_v3 = {}
valid_inputs = class_def.INPUT_TYPES()
if is_v3:
valid_inputs, hidden, v3_data = _io.get_finalized_class_inputs(valid_inputs, inputs)
# Build the type-resolution map for this node so dynamic schemas can
# branch on resolved upstream types (and not only on literal values).
# When no DynamicPrompt is available (e.g. some IsChangedCache paths
# in tests), live_input_types stays None and only literal-driven
# dynamic types continue to work.
live_input_types = None
if dynprompt is not None and hasattr(dynprompt, "get_type_resolver"):
live_input_types = dynprompt.get_type_resolver().compute_live_input_types(unique_id)
valid_inputs, hidden, v3_data = _io.get_finalized_class_inputs(valid_inputs, inputs, live_input_types=live_input_types)
input_data_all = {}
missing_keys = {}
for x in inputs:
@@ -821,9 +830,19 @@ class PromptExecutor:
self._notify_prompt_lifecycle("end", prompt_id)
async def validate_inputs(prompt_id, prompt, item, validated, visiting=None):
async def validate_inputs(prompt_id, prompt, item, validated, visiting=None, type_resolver=None):
"""Validate inputs for a single node, recursing into upstream nodes.
``type_resolver`` (a ``comfy_execution.type_resolver.TypeResolver``) is
built once at the top of the recursion and reused so MatchType chains are
only walked once. It also gives V3 dynamic schemas an accurate map of
resolved upstream types for API-submitted workflows.
"""
if visiting is None:
visiting = []
if type_resolver is None:
from comfy_execution.type_resolver import TypeResolver
type_resolver = TypeResolver(prompt)
unique_id = item
if unique_id in validated:
@@ -858,7 +877,8 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None):
if issubclass(obj_class, _ComfyNodeInternal):
obj_class: _io._ComfyNodeBaseInternal
class_inputs = obj_class.INPUT_TYPES()
class_inputs, _, v3_data = _io.get_finalized_class_inputs(class_inputs, inputs)
live_input_types = type_resolver.compute_live_input_types(unique_id)
class_inputs, _, v3_data = _io.get_finalized_class_inputs(class_inputs, inputs, live_input_types=live_input_types)
validate_function_name = "validate_inputs"
validate_function = first_real_override(obj_class, validate_function_name)
else:
@@ -909,8 +929,11 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None):
o_id = val[0]
o_class_type = prompt[o_id]['class_type']
r = nodes.NODE_CLASS_MAPPINGS[o_class_type].RETURN_TYPES
received_type = r[val[1]]
# Resolve the upstream output's effective type through the
# TypeResolver. This walks MatchType/template chains, so an API
# workflow without frontend-injected type metadata still gets the
# same answer the UI does.
received_type = type_resolver.resolve_output_type(o_id, val[1])
received_types[x] = received_type
if 'input_types' not in validate_function_inputs and not validate_node_input(received_type, input_type):
details = f"{x}, received_type({received_type}) mismatch input_type({input_type})"
@@ -930,7 +953,7 @@ async def validate_inputs(prompt_id, prompt, item, validated, visiting=None):
try:
visiting.append(unique_id)
try:
r = await validate_inputs(prompt_id, prompt, o_id, validated, visiting)
r = await validate_inputs(prompt_id, prompt, o_id, validated, visiting, type_resolver)
finally:
visiting.pop()
if r[0] is False:
@@ -1155,11 +1178,15 @@ async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[
errors = []
node_errors = {}
validated = {}
# Share one TypeResolver across all output validations so MatchType chains
# are only walked once per prompt.
from comfy_execution.type_resolver import TypeResolver
type_resolver = TypeResolver(prompt)
for o in outputs:
valid = False
reasons = []
try:
m = await validate_inputs(prompt_id, prompt, o, validated)
m = await validate_inputs(prompt_id, prompt, o, validated, None, type_resolver)
valid = m[0]
reasons = m[1]
except Exception as ex: