Files
DumiFlex 67da3cfbb8 fix(negatives): template rows and toggle values carry what they read (#397)
Context Injector template rows and WP Toggles values now carry the negatives of the $variables they read, before their own; the Assembler's negative preview mirrors both.
2026-11-13 02:32:01 +01:00

406 lines
17 KiB
Python

"""WP_ContextInjector — lifts arbitrary ComfyUI outputs into named
$var ctx bindings on the PipelineContext.
Spec: docs/superpowers/specs/2026-05-10-context-injector-design.md
"""
from __future__ import annotations
import json
import re
from typing import Any
from comfy_api.latest import io # pyright: ignore[reportMissingImports]
from engine import negatives
from engine.syntax.types import bool_text
from wp_nodes.types import InjectorRowsInput, PipelineContext
_BINDING_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*$")
# Tokenizer for the per-row template field. Matches `$$` (literal `$`),
# `$<name>` slot refs, and falls through to literal text.
_TEMPLATE_TOKEN_RE = re.compile(r"\$\$|\$([A-Za-z_][A-Za-z0-9_]*)")
def _render_template(template: str, slot_values: dict[str, Any]) -> str:
"""Substitute `$<slot_name>` refs with stringified socket values.
- `$$` -> literal `$`
- `$slot_name` where slot is wired -> stringified value at that slot
(booleans yield "true"/"false", as everywhere a variable is read,
so a Boolean node can drive a derivation's `equals true`.)
- `$slot_name` where slot is NOT wired -> left as literal `$slot_name`
so the user can spot the typo in the rendered output
"""
def repl(m: re.Match[str]) -> str:
if m.group(0) == "$$":
return "$"
name = m.group(1)
if name in slot_values:
value = slot_values[name]
return bool_text(value) if isinstance(value, bool) else str(value)
return m.group(0)
return _TEMPLATE_TOKEN_RE.sub(repl, template)
#: `$name` (not `$$`) in a template, for WP Debug's list of what it read.
_TEMPLATE_VAR_RE = re.compile(r"(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)")
class WPContextInjector(io.ComfyNode):
"""Graph-side ctx writer. No library, no payload, no resolve-time logic.
For each enabled row with a non-empty binding AND a live wire,
writes ``ctx[binding] = stringify(value)``.
"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="WP_ContextInjector",
display_name="WP Context Injector",
category="wildcard-pipeline",
inputs=[
PipelineContext.Input(
"upstream",
optional=True,
tooltip=(
"Optional upstream Context to extend. Variables pass "
"through; this node's rows write fresh bindings on "
"top. Leave unconnected to start a new chain."
),
),
InjectorRowsInput.Input("wp_rows", socketless=True),
# NO `io.Autogrow.Input` here. V3 Autogrow declares all
# `max` slots up-front (see comfy_api/latest/_io.py:997)
# and ComfyUI's frontend doesn't shrink on disconnect.
# We manage `input_*` sockets manually on the frontend
# via litegraph's `addInput`/`removeInput` (see
# `src/widgets/injector.ts`) and rely on
# `accept_all_inputs=True` to receive whatever kwargs
# the user has wired at execute time.
],
outputs=[PipelineContext.Output("context")],
# The node's graph id, so WP Debug can group its trace rows.
hidden=[io.Hidden.unique_id],
not_idempotent=True,
accept_all_inputs=True,
)
@classmethod
def execute(
cls,
wp_rows: str = "",
upstream: PipelineContext | None = None,
**slot_values: Any,
):
# V3 Autogrow namespaces dynamic inputs as `inputs.input_0`,
# `inputs.input_1`, … — but the widget JSON's `slot_name` field
# holds the bare label `input_0`. Normalize incoming kwargs to
# strip the parent input id prefix so lookups work with either
# form (whatever ComfyUI's executor decides to pass).
normalized: dict[str, Any] = {}
for key, val in slot_values.items():
if key.startswith("inputs."):
normalized[key[len("inputs."):]] = val
else:
normalized[key] = val
slot_values = normalized
upstream_ctx: dict = upstream.context if upstream is not None else {}
upstream_debug: dict = upstream.debug if upstream is not None else {}
upstream_internals: dict = upstream.internals if upstream is not None else {}
ctx: dict = dict(upstream_ctx)
for key, value in upstream_internals.items():
if isinstance(value, list):
ctx[key] = list(value)
elif isinstance(value, dict):
ctx[key] = dict(value)
else:
ctx[key] = value
# Empty rows / parse failure → forward unchanged.
try:
parsed = json.loads(wp_rows) if wp_rows else {"version": 1, "rows": []}
except json.JSONDecodeError:
parsed = {"version": 1, "rows": []}
warnings: list[dict] = []
traces: list[dict] = []
internal_keys: set[str] = set()
existing_internal = upstream_ctx.get("__wp_internal_keys__")
if isinstance(existing_internal, list):
internal_keys.update(existing_internal)
elif isinstance(existing_internal, set):
internal_keys.update(existing_internal)
rows_list = parsed.get("rows", []) if isinstance(parsed, dict) else []
# Send-to-negative: an injected value replaces the variable, so it
# replaces what the variable carried. A row's own Negative (same
# `$slot` grammar as its template) becomes the new negatives.
neg_table: dict[str, list] = {
k: list(v)
for k, v in (upstream_internals.get(negatives.NEG_KEY) or {}).items()
if isinstance(v, list)
}
def _file_row_negative(
binding_name: str, row: dict, slots: dict[str, Any],
reads: list[str] | None = None,
) -> None:
# A template row that reads `$var`s carries their negatives, as a
# combine does (negatives follow usage), then its own Negative.
entries = negatives.entries_for_reads(
{negatives.NEG_KEY: neg_table}, [(n, None) for n in reads or []],
)
raw = row.get("negative")
text = (
_render_template(raw.strip(), slots).strip()
if isinstance(raw, str) and raw.strip() else ""
)
if text:
entries.append({"text": text, "pick": None, "source": "injector"})
if entries:
neg_table[binding_name] = entries
else:
neg_table.pop(binding_name, None)
def _emit_trace(
binding_name: str, stored_val: Any, trace_kind: str, is_internal: bool,
detail: dict[str, Any] | None = None,
) -> None:
# `value` surfaces the written ctx value in Debug's trace
# tab. Engine module traces use `writes: [{...}]` for the
# same purpose; injector trace stays flat (single-write per
# row) so a top-level `value` keeps the shape minimal while
# giving Debug something to render. Stringified to keep the
# JSON shape stable across primitive types — Debug formats
# further.
value = (
stored_val
if isinstance(stored_val, (str, int, float, bool))
else str(stored_val)
)
traces.append({
"node": "WP_ContextInjector",
"binding": binding_name,
"internal": is_internal,
"type": trace_kind,
"value": value,
**({"detail": detail} if detail else {}),
})
def _template_detail(tmpl: str, scope: dict[str, Any]) -> dict[str, Any]:
"""WP Debug: the template and what each `$name` in it filled with
(``None`` when nothing in scope set it)."""
reads: dict[str, str | None] = {}
for name in _TEMPLATE_VAR_RE.findall(tmpl):
if name in reads:
continue
val = scope.get(name)
reads[name] = None if val is None else str(val)[:160]
return {"template": tmpl[:400], **({"reads": reads} if reads else {})}
def _validated_binding(row: dict) -> str | None:
"""Shared binding validation for both row kinds. Returns the
stripped binding, or None when the row should be skipped
(appending a warning for reserved / invalid names)."""
binding = row.get("binding", "")
if not isinstance(binding, str) or not binding.strip():
return None
stripped = binding.strip()
if stripped.startswith("_"):
warnings.append({
"type": "injector_reserved_binding",
"severity": "warning",
"message": (
f"WP Context Injector skipped ${stripped}: names that "
"start with _ are kept for the engine."
),
"binding": stripped,
"row_uid": row.get("_uid"),
})
return None
if not _BINDING_RE.match(stripped):
warnings.append({
"type": "injector_invalid_binding",
"severity": "warning",
"message": (
f"WP Context Injector skipped \"{stripped}\": a variable "
"name starts with a letter and uses only letters, digits and _."
),
"binding": stripped,
"row_uid": row.get("_uid"),
})
return None
return stripped
# Two-tier row model:
# - SOCKET rows (kind absent / "socket"): bind one wired socket
# to a $variable. An optional template substitutes ONLY the
# row's OWN `$input_N` — rows resolve top-to-bottom, so an
# early row must not read a later socket's value.
# - GENERAL rows (kind "general"): durable, not tied to a
# socket. Survives socket disconnect/reconnect. Resolved
# AFTER all socket rows so its template can reference BOTH the
# raw sockets (`$input_N`) AND the variables produced by the
# socket rows (`$test`).
socket_rows: list[dict] = []
general_rows: list[dict] = []
for row in rows_list:
if not isinstance(row, dict):
continue
if row.get("kind") == "general":
general_rows.append(row)
else:
socket_rows.append(row)
# ── Pass 1: socket rows (existing order) ──────────────────────
for row in socket_rows:
if not row.get("enabled", False):
continue
stripped = _validated_binding(row)
if stripped is None:
continue
slot_name = row.get("slot_name", "")
template_raw = row.get("template", None)
template_str = (
template_raw.strip()
if isinstance(template_raw, str) and template_raw.strip()
else ""
)
own_slot: dict[str, Any] = {}
# Pass-through path: row has no template, just needs the
# wired socket value. Requires the slot to be wired or the
# row is skipped (matches legacy behavior).
if not template_str:
if not isinstance(slot_name, str) or slot_name not in slot_values:
continue
value = slot_values[slot_name]
if isinstance(value, bool):
stored: Any = value
elif isinstance(value, (str, int, float)):
stored = value
else:
stored = str(value)
trace_type = type(value).__name__
else:
# Template path: render `$<slot_name>` substitutions
# against ONLY this row's OWN socket. Other `$input_M`
# refs are out of scope (left as literal `$input_M`, same
# as any unknown ref) — top-to-bottom resolution means an
# early socket row must not read a later socket. To
# compose multiple sockets, use a GENERAL row instead.
own_slot = (
{slot_name: slot_values.get(slot_name, "")}
if isinstance(slot_name, str)
else {}
)
stored = _render_template(template_str, own_slot)
trace_type = "str(template)"
ctx[stripped] = stored
_file_row_negative(
stripped, row,
{slot_name: slot_values.get(slot_name, "")}
if isinstance(slot_name, str) else {},
)
is_internal = bool(row.get("internal", False))
if is_internal:
internal_keys.add(stripped)
_emit_trace(
stripped, stored, trace_type, is_internal,
{
**({"input": slot_name} if isinstance(slot_name, str) and slot_name else {}),
**(_template_detail(template_str, own_slot) if template_str else {}),
},
)
# ── Pass 2: general rows (in order, after all socket rows) ────
# A general row resolves its template against `merged`: every
# non-`__`-prefixed ctx var written so far (socket-row bindings +
# any earlier general-row binding) overlaid with the raw sockets.
# So `$test` (a socket-row output) AND `$input_0` (a raw socket)
# both resolve. Processing in order lets a later general row read
# an earlier general row's binding.
for row in general_rows:
if not row.get("enabled", False):
continue
stripped = _validated_binding(row)
if stripped is None:
continue
template_raw = row.get("template", None)
template_str = (
template_raw.strip()
if isinstance(template_raw, str) and template_raw.strip()
else ""
)
# A general row has no socket — it needs a non-empty template
# to produce anything. Skip otherwise.
if not template_str:
continue
merged: dict[str, Any] = {
k: v for k, v in ctx.items() if not k.startswith("__")
}
merged.update(slot_values)
stored = _render_template(template_str, merged)
ctx[stripped] = stored
# Variables the template read (not raw sockets: those carry no
# negatives of their own).
var_reads = [
m.group(1) for m in _TEMPLATE_TOKEN_RE.finditer(template_str)
if m.group(1) and m.group(1) not in slot_values
and m.group(1) in merged
]
_file_row_negative(stripped, row, merged, var_reads)
is_internal = bool(row.get("internal", False))
if is_internal:
internal_keys.add(stripped)
_emit_trace(
stripped, stored, "str(template)", is_internal,
_template_detail(template_str, merged),
)
if internal_keys:
ctx["__wp_internal_keys__"] = sorted(internal_keys)
# Carry user-marked-internal bindings on `__wp_internal_flags__` in the
# cross-node `internals` field — the SAME channel WP_Context +
# WP_ContextLoop use, and the only one the PromptAssembler consults
# (via `strip_internals`) to keep an internal var out of the rendered
# prompt. Writing only `__wp_internal_keys__` above left the toggle
# cosmetic: nothing downstream stripped on it.
out_internals = dict(upstream_internals)
if neg_table or negatives.NEG_KEY in out_internals:
out_internals[negatives.NEG_KEY] = neg_table
ctx[negatives.NEG_KEY] = neg_table
if internal_keys:
flags = dict(out_internals.get("__wp_internal_flags__") or {})
for name in internal_keys:
flags[name] = True
out_internals["__wp_internal_flags__"] = flags
debug = dict(upstream_debug)
hidden = getattr(cls, "hidden", None)
node_id = getattr(hidden, "unique_id", None) if hidden is not None else None
if node_id is not None:
for row in traces + warnings:
row["node_id"] = str(node_id)
if traces:
existing_trace = debug.get("__wp_trace__", [])
debug["__wp_trace__"] = list(existing_trace) + traces
if warnings:
existing_warn = debug.get("__wp_warnings__", [])
debug["__wp_warnings__"] = list(existing_warn) + warnings
return io.NodeOutput(
PipelineContext.Type(
context=ctx,
debug=debug,
internals=out_internals,
)
)