Custom Wizard Development¶
Build Python-based wizards with workflow delegation, conditional steps, and testing strategies.
Prerequisites: Read Wizard Architecture first to understand the step lifecycle and session model.
When to Use Python vs YAML¶
| Approach | Use When |
|---|---|
YAML (ConfigDrivenWizard) |
Simple question → analyze → preview flows. No custom logic needed. |
Python (BaseWizard subclass) |
Workflow delegation, conditional steps, custom result processing, or complex state management. |
For YAML-based wizards, see Getting Started. This guide covers the Python approach.
Step 1: Define Your Wizard Class¶
Every Python wizard needs four things:
- A
configclass attribute (WizardConfig) - A
stepsclass attribute (list[WizardStep]) - A
build_prompt_context()method - A
process_step_result()method
Minimal Example¶
"""Code complexity wizard — analyzes and reduces cyclomatic complexity."""
from __future__ import annotations
from typing import Any
from attune.meta_workflows.models import FormQuestion, QuestionType
from attune.prompts import PromptContext
from attune.wizards.base import BaseWizard, StepType, WizardConfig, WizardStep
class ComplexityWizard(BaseWizard):
"""Guided code complexity reduction."""
config = WizardConfig(
wizard_id="complexity",
name="Complexity Wizard",
description="Analyze and reduce code complexity",
domain="development",
estimated_cost_range=(0.02, 0.25),
estimated_duration_minutes=5,
)
steps = [
WizardStep(
id="gather_info",
name="Target Selection",
description="Which code to analyze?",
step_type=StepType.QUESTION,
questions=[
FormQuestion(
id="target_path",
text="Which file or directory?",
type=QuestionType.TEXT_INPUT,
default="src/",
),
FormQuestion(
id="threshold",
text="Complexity threshold?",
type=QuestionType.SINGLE_SELECT,
options=["5 (strict)", "10 (standard)", "15 (lenient)"],
default="10 (standard)",
),
],
),
WizardStep(
id="analyze",
name="Analyze Complexity",
description="Measure cyclomatic complexity across target",
step_type=StepType.LLM_CALL,
tier="capable",
),
WizardStep(
id="decompose",
name="Simplification Plan",
description="Break complex functions into tasks",
step_type=StepType.TASK_DECOMPOSE,
tier="capable",
),
WizardStep(
id="preview",
name="Review Plan",
description="Review the simplification plan",
step_type=StepType.PREVIEW,
),
WizardStep(
id="confirm",
name="Proceed",
description="Apply these simplifications?",
step_type=StepType.CONFIRM,
),
]
def build_prompt_context(self, step: WizardStep) -> PromptContext:
"""Build LLM prompt from session state."""
assert self._session is not None
if step.id == "analyze":
target = self._session.get("target_path", "src/")
threshold = self._session.get("threshold", "10 (standard)")
return PromptContext(
role="code complexity analyst",
goal=f"Analyze cyclomatic complexity in {target}",
instructions=[
f"Identify functions exceeding threshold: {threshold}",
"Rank by complexity score (highest first)",
"Suggest specific simplification strategies",
],
constraints=[
"Preserve existing behavior",
"Each suggestion should be independently applicable",
],
input_type="complexity_request",
input_payload=f"Target: {target}\nThreshold: {threshold}",
)
if step.id == "decompose":
analysis = self._session.step_results.get("analyze", {})
return PromptContext(
role="refactoring planner",
goal="Create simplification tasks for complex functions",
instructions=["One task per function or closely related group"],
constraints=["Tests must pass after each change"],
input_type="analysis",
input_payload=str(analysis),
)
return PromptContext(role="assistant", goal="Analyze complexity")
def process_step_result(self, step: WizardStep, result: dict[str, Any]) -> None:
"""Store analysis results in session."""
assert self._session is not None
if step.id == "analyze":
self._session.set("complexity_analysis", result)
Step 2: Add Conditional Steps¶
Steps can be skipped based on session state using the condition parameter.
Define a Condition Function¶
from attune.wizards.session import WizardSession
def _has_complex_functions(session: WizardSession) -> bool:
"""Only decompose if complex functions were found."""
analysis = session.step_results.get("analyze", {})
functions = analysis.get("complex_functions", [])
return len(functions) > 0
Attach It to a Step¶
WizardStep(
id="decompose",
name="Simplification Plan",
step_type=StepType.TASK_DECOMPOSE,
condition=_has_complex_functions, # Skipped if no complex functions
)
The condition receives the WizardSession and returns bool. When False, the step is recorded in session.steps_skipped and the wizard moves to the next step.
Step 3: Add Workflow Delegation¶
Workflow delegation is the pattern used by production wizards (SecurityWizard, RefactorWizard) to run multi-stage analysis pipelines instead of simple LLM calls.
Why Delegate?¶
- Better results: Multi-stage pipelines (triage → analyze → assess) produce richer output than a single LLM call
- Graceful fallback: If the workflow isn't available, the wizard falls back to a basic LLM call
- Composability: Reuse existing workflow engines without duplicating logic
Implementation Pattern¶
from typing import Any
from attune.workflows.compat import ModelTier
class ComplexityWizard(BaseWizard):
# ... config and steps from above ...
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._complexity_workflow: Any = None
def _get_or_create_workflow(self) -> Any:
"""Lazily instantiate the workflow engine."""
if self._complexity_workflow is not None:
return self._complexity_workflow
try:
from attune.workflows.complexity import ComplexityWorkflow
self._complexity_workflow = ComplexityWorkflow(
enable_auth_strategy=False,
)
return self._complexity_workflow
except ImportError:
return None # Workflow not installed
except Exception: # noqa: BLE001
# INTENTIONAL: Workflow is optional enhancement
return None
async def _run_llm_step(self, step: WizardStep) -> None:
"""Override to intercept the analyze step."""
if step.id == "analyze":
try:
await self._run_analysis_via_workflow()
return
except Exception: # noqa: BLE001
# INTENTIONAL: Graceful fallback to basic LLM call
pass
# Fall back to base LLM call
await super()._run_llm_step(step)
async def _run_analysis_via_workflow(self) -> None:
"""Run multi-stage analysis via workflow engine."""
assert self._session is not None
workflow = self._get_or_create_workflow()
if workflow is None:
raise RuntimeError("Workflow not available")
target = self._session.get("target_path", "src/")
input_data = {"path": target}
# Stage 1: Scan (cheap)
scan_result, _, _ = await workflow.run_stage(
"scan", ModelTier.CHEAP, input_data,
)
# Stage 2: Analyze (capable)
analyze_input = {**input_data, **scan_result}
analyze_result, _, _ = await workflow.run_stage(
"analyze", ModelTier.CAPABLE, analyze_input,
)
# Combine and store
combined = {
"complex_functions": scan_result.get("functions", []),
"analysis": analyze_result.get("analysis", {}),
"workflow_delegated": True,
}
self.process_step_result(
next(s for s in self.steps if s.id == "analyze"),
combined,
)
self._session.complete_step("analyze", result=combined)
Key Points¶
_get_or_create_workflow()— Lazy import withtry/except ImportError. Cache the instance._run_llm_step()override — Checkstep.id, try workflow, fall back tosuper().- Stage chaining — Each stage's output feeds into the next via dict merging.
workflow_delegated: True— Flag so downstream steps know the source.
Step 4: Register Your Wizard¶
Option A: Built-in Registration¶
Add to src/attune/wizards/builtin/__init__.py:
from .complexity_wizard import ComplexityWizard
BUILTIN_WIZARDS: dict[str, type[BaseWizard]] = {
# ... existing wizards ...
"complexity": ComplexityWizard,
}
Option B: Entry Point Registration¶
Add to pyproject.toml:
[project.entry-points."empathy.wizards"]
complexity = "your_package.complexity_wizard:ComplexityWizard"
Option C: Runtime Registration¶
from attune.wizards import register_wizard
from your_package import ComplexityWizard
register_wizard("complexity", ComplexityWizard)
Step 5: Test Your Wizard¶
Unit Test Structure¶
import pytest
from attune.wizards.builtin.complexity_wizard import ComplexityWizard
class TestComplexityWizard:
"""Tests for ComplexityWizard."""
def test_config(self):
"""Verify wizard metadata."""
assert ComplexityWizard.config.wizard_id == "complexity"
assert ComplexityWizard.config.domain == "development"
def test_steps_order(self):
"""Verify step sequence."""
step_ids = [s.id for s in ComplexityWizard.steps]
assert step_ids == [
"gather_info", "analyze", "decompose",
"preview", "confirm",
]
def test_step_types(self):
"""Verify each step has the correct type."""
from attune.wizards.base import StepType
steps = {s.id: s for s in ComplexityWizard.steps}
assert steps["gather_info"].step_type == StepType.QUESTION
assert steps["analyze"].step_type == StepType.LLM_CALL
assert steps["decompose"].step_type == StepType.TASK_DECOMPOSE
def test_questions_defined(self):
"""Verify question step has questions."""
gather = next(s for s in ComplexityWizard.steps if s.id == "gather_info")
assert gather.questions is not None
assert len(gather.questions) >= 1
def test_build_prompt_context_analyze(self):
"""Verify prompt context for analyze step."""
wizard = ComplexityWizard()
from attune.wizards.session import WizardSession
wizard._session = WizardSession(wizard_id="complexity")
wizard._session.set("target_path", "src/main.py")
wizard._session.set("threshold", "10 (standard)")
step = next(s for s in wizard.steps if s.id == "analyze")
ctx = wizard.build_prompt_context(step)
assert "complexity" in ctx.role.lower()
assert "src/main.py" in ctx.goal
def test_workflow_fallback(self):
"""Verify wizard works when workflow is unavailable."""
wizard = ComplexityWizard()
result = wizard._get_or_create_workflow()
# Should return None gracefully, not raise
assert result is None or result is not None # Either is fine
Testing Conditional Steps¶
def test_condition_skips_when_no_findings(self):
"""Verify decompose step is skipped without complex functions."""
from attune.wizards.session import WizardSession
session = WizardSession(wizard_id="complexity")
session.step_results["analyze"] = {"complex_functions": []}
assert not _has_complex_functions(session)
def test_condition_runs_when_findings_exist(self):
"""Verify decompose step runs with complex functions."""
from attune.wizards.session import WizardSession
session = WizardSession(wizard_id="complexity")
session.step_results["analyze"] = {
"complex_functions": [{"name": "parse", "score": 15}]
}
assert _has_complex_functions(session)
Patterns and Best Practices¶
Session State Management¶
- Store analysis results with descriptive keys:
self._session.set("complexity_analysis", result) - Access prior step results via
self._session.step_results["step_id"] - Use
self._session.get(key, default)for safe lookups with fallbacks
Error Handling¶
- Workflow delegation uses broad
except Exceptionwith# noqa: BLE001and# INTENTIONAL:comments - Always fall back to
super()._run_llm_step(step)when delegation fails - Log exceptions before falling back:
logger.exception("Workflow failed, falling back")
Prompt Context Quality¶
- Use specific
rolestrings:"security remediation specialist"not"assistant" - Include
constraintsthat guard behavior:"Preserve existing tests","Changes must be backward-compatible" - Pass relevant session data in
input_payloadso the LLM has context
Multi-Step Delegation¶
When a wizard has multiple LLM_CALL steps that delegate to the same workflow, accumulate state:
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._workflow_state: dict[str, Any] = {} # Accumulates across stages
async def _run_scan_via_workflow(self):
# ... run stages, store in self._workflow_state["scan"] ...
async def _run_fixes_via_workflow(self):
# Use accumulated state from scan
input_data = {**self._workflow_state.get("scan", {})}
# ... run remediation stage ...
File Structure¶
src/attune/wizards/
├── __init__.py # Public API exports
├── base.py # BaseWizard, StepType, WizardConfig, WizardStep
├── session.py # WizardSession state management
├── registry.py # Discovery and registration
├── config_driven.py # YAML-based ConfigDrivenWizard
├── decomposer.py # XML task decomposition engine
├── internal_workflow.py # LLM bridge (BaseWorkflow wrapper)
└── builtin/
├── __init__.py # BUILTIN_WIZARDS registry dict
├── debug_wizard.py # DebugWizard
├── test_gen_wizard.py # TestGenWizard
├── refactor_wizard.py # RefactorWizard (with workflow delegation)
├── security_wizard.py # SecurityWizard (with workflow delegation)
└── release_prep_wizard.py # ReleasePrepWizard
Next Steps¶
- Getting Started — Run your first wizard
- Wizard Architecture — System design deep dive
- Software Wizards — Full reference for all 16 software wizards
- XML-Enhanced Prompts — Task decomposition schema