From 5c7d2ee177bbba3d3917db10b7078bee670d0671 Mon Sep 17 00:00:00 2001 From: Ari Aye Date: Sun, 30 Aug 2026 13:41:08 -0700 Subject: [PATCH 1/2] fix: PROMPT_MUTATE persists in knob_values for compile() round-trips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mutate_prompt() now stores the rewritten prompt in knob_values under a synthetic `_prompt_` key. Package.compile() reads these back and applies them to node prompt_templates. Without this, any consumer that rebuilds a Package from config and calls compile() loses prompt mutations — the rewritten prompt lives only on the Workflow IR that apply_random_mutation returned, and the fresh compile() overwrites it with the original. Fixes #1410. Co-Authored-By: Claude Opus 4.6 (1M context) --- factory/outer_loop/mutations.py | 8 +++++++- factory/workflow/package.py | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/factory/outer_loop/mutations.py b/factory/outer_loop/mutations.py index 9090be31..519fcea0 100644 --- a/factory/outer_loop/mutations.py +++ b/factory/outer_loop/mutations.py @@ -599,9 +599,15 @@ def mutate_prompt( try: updated = node.model_copy(update={"prompt_template": new_prompt}) wf.nodes[node_id] = updated # type: ignore[assignment] - except Exception: + except Exception as exc: + log.warning("prompt_mutate_validation_failed", node=node_id, error=str(exc)) return None + # Persist in knob_values so the mutation survives Package.compile() round-trips + prompt_knob = f"_prompt_{node_id}" + wf.knob_values[prompt_knob] = new_prompt + wf.knob_expandable[prompt_knob] = f"Prompt for {node_id}" + record = MutationRecord( operator=MutationType.PROMPT_MUTATE, target_node=node_id, diff --git a/factory/workflow/package.py b/factory/workflow/package.py index 63f54823..2c3cc323 100644 --- a/factory/workflow/package.py +++ b/factory/workflow/package.py @@ -130,7 +130,12 @@ def configure(self, **knob_values: str | float) -> Package: return self.model_copy(update={"knobs": new_knobs}) def compile(self) -> Workflow: - """Lower this package to a flat, mutable Workflow IR.""" + """Lower this package to a flat, mutable Workflow IR. + + Prompt knobs (``_prompt_`` in knob_values) are applied + back to node prompt_templates so that PROMPT_MUTATE mutations + survive compile() round-trips. + """ wf = self.graph.model_copy(deep=True) if self.knobs: wf.knob_values = {k.name: k.default for k in self.knobs} @@ -138,6 +143,17 @@ def compile(self) -> Workflow: wf.knob_expandable = { k.name: k.expansion_hint for k in self.knobs if k.expandable } + for key, val in list(wf.knob_values.items()): + if key.startswith("_prompt_") and isinstance(val, str): + node_id = key[len("_prompt_"):] + node = wf.nodes.get(node_id) + if node and hasattr(node, "prompt_template"): + try: + wf.nodes[node_id] = node.model_copy( + update={"prompt_template": val} + ) + except Exception: + pass return wf From 528fde196d2e8501dc9b5efb9476692dd63d3f19 Mon Sep 17 00:00:00 2001 From: Ari Aye Date: Mon, 31 Aug 2026 04:57:39 -0700 Subject: [PATCH 2/2] fix: preserve _prompt_* entries when compile() has declared OptKnobs compile() was replacing knob_values/knob_expandable entirely from declared OptKnobs, destroying _prompt_* entries before they could be read back. Now saves and restores _prompt_* entries across the overwrite. Found by CEO adversarial QA (Test 3): any workflow with both PROMPT_MUTATE and declared OptKnobs silently lost all prompt mutations. Co-Authored-By: Claude Opus 4.6 (1M context) --- factory/workflow/package.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/factory/workflow/package.py b/factory/workflow/package.py index 2c3cc323..41827e89 100644 --- a/factory/workflow/package.py +++ b/factory/workflow/package.py @@ -137,12 +137,23 @@ def compile(self) -> Workflow: survive compile() round-trips. """ wf = self.graph.model_copy(deep=True) + # Preserve _prompt_* entries from previous mutations before overwriting + saved_prompts = { + k: v for k, v in wf.knob_values.items() + if k.startswith("_prompt_") + } + saved_expandable = { + k: v for k, v in wf.knob_expandable.items() + if k.startswith("_prompt_") + } if self.knobs: wf.knob_values = {k.name: k.default for k in self.knobs} wf.knob_bounds = {k.name: list(k.bounds) for k in self.knobs if k.bounds} wf.knob_expandable = { k.name: k.expansion_hint for k in self.knobs if k.expandable } + wf.knob_values.update(saved_prompts) + wf.knob_expandable.update(saved_expandable) for key, val in list(wf.knob_values.items()): if key.startswith("_prompt_") and isinstance(val, str): node_id = key[len("_prompt_"):]