Skip to content

Commit 8f02e2c

Browse files
yiyixuxuclaude
andauthored
Add tests and agent docs for kwargs_type input/output (#14157)
* Add tests and agent docs for kwargs_type input delivery in modular pipelines Document how kwargs_type-tagged values flow from block outputs and user inputs to consumer blocks (the mechanism behind denoiser_input_fields), pin the behavior down with tests, and add a key-pattern section to .ai/modular.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review feedback: drop 'bag' term, assert on returned state, test pipeline call params Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cca8af2 commit 8f02e2c

2 files changed

Lines changed: 144 additions & 0 deletions

File tree

.ai/modular.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,37 @@ class HeliosChunkDenoiseStep(HeliosChunkLoopWrapper):
102102

103103
Note: sub-blocks inside `LoopSequentialPipelineBlocks` receive `(components, block_state, i, t)` for denoise loops or `(components, block_state, k)` for chunk loops.
104104

105+
## Key pattern: `kwargs_type` inputs (`denoiser_input_fields`)
106+
107+
The conditioning inputs a denoiser needs often vary by workflow — especially for omni models like Cosmos3, where the action workflow requires additional action conditioning, and a workflow that generates sound along with video requires additional sound inputs. Tag these outputs with `kwargs_type="denoiser_input_fields"` when they are written; the denoiser then declares a single input with that `kwargs_type` and receives every tagged value collected into one dict. This avoids creating a new denoiser block for each workflow just to list its specific inputs:
108+
109+
```python
110+
# producer side: standard conditioning outputs already carry the tag via their templates
111+
OutputParam.template("prompt_embeds") # kwargs_type="denoiser_input_fields"
112+
# workflow-specific fields declare it explicitly
113+
OutputParam(
114+
"action_embeds",
115+
kwargs_type="denoiser_input_fields",
116+
type_hint=torch.Tensor,
117+
description="Action conditioning fed into the transformer.",
118+
)
119+
120+
# consumer side (the loop denoiser): declare the kwargs_type input once
121+
InputParam.template("denoiser_input_fields")
122+
123+
# inside the denoiser __call__: every tagged value arrives in one dict —
124+
# and also individually (block_state.prompt_embeds, block_state.action_embeds, ...)
125+
block_state.denoiser_input_fields # {"prompt_embeds": ..., "action_embeds": ...}
126+
```
127+
128+
The denoiser typically filters this dict against the transformer's forward signature and forwards the matches — so a new block can add conditioning just by tagging its output (no change to the denoiser), and tagged fields the transformer doesn't accept are silently ignored (see `qwenimage/denoise.py` or `helios/denoise.py`; `z_image/denoise.py` is a minimal consumer).
129+
130+
How the tagging works (behavior is pinned down in `tests/modular_pipelines/test_modular_pipelines_custom_blocks.py::TestBlockKwargsTypeInputs`):
131+
132+
- A value gets its tag when it is **written** to pipeline state: a block output is tagged if declared with `OutputParam(..., kwargs_type=...)`; a user-passed input is tagged if the pipeline-level `InputParam` it matches declares a kwargs_type.
133+
- Users can always pass all the tagged values as a dict under the kwargs_type name — `pipe(denoiser_input_fields={"prompt_embeds": ...})` — and every entry gets tagged. In a full pipeline this is rarely needed: named inputs and tagged block outputs get tagged on their own; the dict form matters mainly for standalone runs (below).
134+
- **Gotcha — standalone runs:** a named input declared *without* the kwargs_type lands in state by name but never gets tagged, so it never reaches the consumer's dict. So when a denoise block runs standalone (without the upstream blocks whose tagged outputs normally supply these values), passing them as plain named inputs silently does nothing — they must go through the `denoiser_input_fields={...}` dict, or the block must declare them as named `InputParam(..., kwargs_type="denoiser_input_fields")` inputs.
135+
105136
## Key pattern: Workflow selection
106137

107138
```python

tests/modular_pipelines/test_modular_pipelines_custom_blocks.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,119 @@ def test_loop_block_requirements_save_load(self, tmp_path):
578578
assert expected_requirements == config["requirements"]
579579

580580

581+
class DummyKwargsProducerStep(ModularPipelineBlocks):
582+
"""Takes `a` and `b` as regular named inputs and passes them through (with a `-producer`
583+
suffix so tests can see the values went through this block), writing `a` back as an output
584+
tagged with kwargs_type `typea` and `b` as an untagged output."""
585+
586+
@property
587+
def inputs(self) -> List[InputParam]:
588+
return [InputParam(name="a", default=None), InputParam(name="b", default=None)]
589+
590+
@property
591+
def intermediate_outputs(self) -> List[OutputParam]:
592+
return [OutputParam("a", kwargs_type="typea"), OutputParam("b")]
593+
594+
def __call__(self, components, state: PipelineState) -> PipelineState:
595+
block_state = self.get_block_state(state)
596+
block_state.a = f"{block_state.a}-producer"
597+
block_state.b = f"{block_state.b}-producer"
598+
self.set_block_state(state, block_state)
599+
return components, state
600+
601+
602+
class DummyKwargsConsumerStep(ModularPipelineBlocks):
603+
"""Consumes the values tagged `typea` (as the `typea` dict) and the named input `b`, and
604+
records what it received as outputs (`received_*`) so tests can assert on the returned state."""
605+
606+
@property
607+
def inputs(self) -> List[InputParam]:
608+
return [
609+
InputParam(kwargs_type="typea"),
610+
InputParam(name="b", default=None),
611+
]
612+
613+
@property
614+
def intermediate_outputs(self) -> List[OutputParam]:
615+
return [
616+
OutputParam("received_typea"),
617+
OutputParam("received_a"),
618+
OutputParam("received_b"),
619+
]
620+
621+
def __call__(self, components, state: PipelineState) -> PipelineState:
622+
block_state = self.get_block_state(state)
623+
block_state.received_typea = block_state.typea
624+
# tagged values delivered through the `typea` dict are also set individually on
625+
# block_state; `a` only exists here if it was delivered as a tagged value
626+
block_state.received_a = getattr(block_state, "a", "<not-set>")
627+
block_state.received_b = block_state.b
628+
self.set_block_state(state, block_state)
629+
return components, state
630+
631+
632+
class TestBlockKwargsTypeInputs:
633+
"""Test how `kwargs_type` fields flow from user inputs and block outputs to consumer blocks.
634+
635+
This is the mechanism behind `denoiser_input_fields`: a block declaring
636+
`InputParam(kwargs_type=...)` receives a dict of every state value *tagged* with that
637+
kwargs_type. A value gets its tag when it is written to the pipeline state: an output
638+
written by a block is tagged if the block declared it with
639+
`OutputParam(..., kwargs_type=...)`, and a user-passed input is tagged if the pipeline's
640+
`InputParam` for it declares a kwargs_type. A named input declared *without* a kwargs_type
641+
therefore never reaches the consumer's dict, even though it is available in state by name.
642+
"""
643+
644+
def test_tagged_block_outputs_are_delivered_to_consumer(self):
645+
blocks = SequentialPipelineBlocks.from_blocks_dict(
646+
{"producer": DummyKwargsProducerStep(), "consumer": DummyKwargsConsumerStep()}
647+
)
648+
pipe = blocks.init_pipeline()
649+
650+
# `a` goes through the producer and is written back as a tagged output, so it reaches
651+
# the consumer through the `typea` dict; `b` also goes through the producer, but its
652+
# output is untagged: it reaches the consumer only as the named input
653+
received = pipe(a="testa", b="testb", output=["received_typea", "received_a", "received_b"])
654+
assert received["received_typea"] == {"a": "testa-producer"}
655+
assert received["received_a"] == "testa-producer"
656+
assert received["received_b"] == "testb-producer"
657+
658+
def test_user_inputs_passed_by_name_are_not_tagged(self):
659+
pipe = DummyKwargsConsumerStep().init_pipeline()
660+
661+
# the consumer only knows `a` as a tagged value: passing it by name does not reach
662+
# the block at all. `b` is declared by name without a kwargs_type: it reaches the
663+
# block as the named input, but never through the `typea` dict.
664+
received = pipe(a="testa", b="testb", output=["received_typea", "received_a", "received_b"])
665+
assert received["received_typea"] == {}
666+
assert received["received_a"] == "<not-set>"
667+
assert received["received_b"] == "testb"
668+
669+
def test_kwargs_type_dict_input_is_delivered(self):
670+
pipe = DummyKwargsConsumerStep().init_pipeline()
671+
672+
# tagged values can be passed as a dict under the kwargs_type name: every entry is
673+
# tagged individually, so `a` now reaches the consumer through the `typea` dict
674+
received = pipe(typea={"a": "testa"}, output=["received_typea", "received_a", "received_b"])
675+
assert received["received_typea"] == {"a": "testa"}
676+
assert received["received_a"] == "testa"
677+
assert received["received_b"] is None
678+
679+
def test_kwargs_type_input_in_pipeline_call_params(self):
680+
blocks = SequentialPipelineBlocks.from_blocks_dict(
681+
{"producer": DummyKwargsProducerStep(), "consumer": DummyKwargsConsumerStep()}
682+
)
683+
pipe = blocks.init_pipeline()
684+
685+
# the kwargs_type input is exposed as a single nameless param alongside the named
686+
# inputs, and renders as a `**typea` kwargs-style param in the docstring
687+
named = [inp.name for inp in pipe.blocks.inputs if inp.name is not None]
688+
kwargs_inputs = [inp.kwargs_type for inp in pipe.blocks.inputs if inp.name is None]
689+
assert named == ["a", "b"]
690+
assert kwargs_inputs == ["typea"]
691+
assert "**typea" in pipe.blocks.doc
692+
693+
581694
@slow
582695
@nightly
583696
@require_torch

0 commit comments

Comments
 (0)