Control Flow

Anything inside a workflow's run() method is parsed into Waymark IR the first time the workflow is run or scheduled, then compiled into the program the runtime executes. The patterns below are what the compiler recognizes today. Compiler limitations describe which forms to use while broader support is being added. Some dynamic values still expose runtime limitations that compilation cannot yet detect.

Every sample in this guide compiles.

Sequential chains

The simplest case: one action's result feeds the next.

@workflow
class SequentialChainWorkflow(Workflow):
    async def run(self, text: str) -> ChainResult:
        step1 = await step_uppercase(text)
        step2 = await step_reverse(step1)
        step3 = await step_add_stars(step2)
        return await build_chain_result(text, step1, step2, step3)

Each await compiles to an action dispatch. The runtime executes them in order, threading each result into the next action's inputs.

Parallel fan-out

asyncio.gather is the parallel primitive. The compiler turns it into a fan-out / fan-in pair:

@workflow
class ParallelMathWorkflow(Workflow):
    async def run(self, number: int) -> ComputationResult:
        factorial_value, fib_value = await asyncio.gather(
            compute_factorial(number),
            compute_fibonacci(number),
            return_exceptions=False,
        )
        return await summarize_math(
            input_number=number,
            factorial_value=factorial_value,
            fibonacci_value=fib_value,
        )

Omit return_exceptions or pass the literal False: failures propagate to the workflow. The compiler rejects return_exceptions=True until the runtime can collect exceptions as results. Unknown keywords and **kwargs are rejected too.

You can also spread a list comprehension into gather for variable-width fan-outs:

results = await asyncio.gather(
    *[send_email(to=user.email, subject="Welcome") for user in active_users],
    return_exceptions=False,
)

The comprehension element can be an @action call or a workflow helper method - *[self.process(item) for item in items] fans out one helper call per item, and each helper can await actions internally.

A gather over an empty list returns an empty result without dispatching any actions.

Conditionals

Plain if / elif / else works. Each branch compiles separately; the runtime evaluates the condition at execution time and runs only the branch that's taken.

@workflow
class ConditionalBranchWorkflow(Workflow):
    async def run(self, value: int) -> BranchResult:
        if value >= 75:
            return await evaluate_high(value)
        elif value >= 25:
            return await evaluate_medium(value)
        else:
            return await evaluate_low(value)

if without else also works - control flow falls through to whatever comes next. This is the common "guard with default" shape:

@workflow
class GuardFallbackWorkflow(Workflow):
    async def run(self, user: str) -> GuardFallbackResult:
        notes = await fetch_recent_notes(user)
        summary = "no notes found"
        if notes:
            summary = await summarize_notes(notes)
        return await build_guard_fallback_result(user, len(notes), summary)

Loops

Use a for loop to process items one at a time and append each result:

@workflow
class LoopProcessingWorkflow(Workflow):
    async def run(self, items: list[str]) -> LoopResult:
        processed = []
        for item in items:
            result = await process_item(item)
            processed.append(result)
        return await build_loop_result(items, processed)

Use a while loop when the next action depends on a condition. break and continue are also supported.

@workflow
class WhileLoopWorkflow(Workflow):
    async def run(self, target: int) -> WhileLoopResult:
        current = 0
        iterations = 0
        while current < target:
            current = await increment_counter(current)
            iterations = iterations + 1
        return await build_while_result(
            target=target, final=current, iterations=iterations,
        )

Try / except

try/except is the recovery primitive. The compiler lowers the except arm into a recovery path the runtime takes when the action raises a matching exception.

@workflow
class ErrorHandlingWorkflow(Workflow):
    async def run(self, should_fail: bool) -> ErrorResult:
        recovered = False
        message = ""
        try:
            result = await self.run_action(
                risky_action(should_fail),
                retry=RetryPolicy(attempts=1),  # don't retry, let it fail
            )
            message = await success_action(result)
        except IntentionalError:
            message = await recovery_action("IntentionalError was caught")
            recovered = True
        return await build_error_result(True, recovered, message)

Two notes:

  • self.run_action(...) is the form to use when you want a per-call retry=RetryPolicy(...) or timeout=. A bare await some_action(...) uses the defaults. Note that RetryPolicy() with attempts omitted defaults to a budget of 100 retries; pass attempts=1 when you want a failure to surface immediately.
  • An except clause matches an exception by its class name or any of its base classes' names, as Python does and the same way retry policies do: except OSError: catches a ConnectionError, and except LookupError: catches a KeyError. Waymark imports nothing at runtime - only names are compared, so except httpx.ConnectError: means the class called ConnectError, whatever module it came from. The runtime's ActionTimeout and ActionExecutionLost derive from BaseException, so except Exception: does not catch them; import them from waymark.vm_exceptions to list them, or catch BaseException. except (): is rejected at compile time, since it would catch nothing.

try/except also works inside loops - useful for "process each item, skip the failures":

@workflow
class LoopExceptionWorkflow(Workflow):
    async def run(self, items: list[str]) -> LoopExceptionResult:
        processed: list[str] = []
        error_count = 0
        for item in items:
            try:
                result = await self.run_action(
                    process_item_may_fail(item),
                    retry=RetryPolicy(attempts=1),
                )
                processed.append(result)
            except ItemProcessingError:
                error_count = error_count + 1
        return await build_loop_exception_result(items, processed, error_count)

Durable sleep

await asyncio.sleep(seconds) accepts literal delays, variables, and expressions. It's a durable sleep - its wake-up time is recorded in Postgres, so the sleep survives node restarts: if the node running the workflow dies, another node picks the instance up and it still wakes at the original time. While it sleeps, the instance stays loaded on its node and counts toward that node's WAYMARK_MAX_CONCURRENT_INSTANCES. Sleeps are woken by the node's sleep poller, so one can wake up to one poll interval late - WAYMARK_SLEEP_POLL_INTERVAL_MS, 250 ms by default. Fractional seconds work. The compiler validates literal delays; dynamic delays are evaluated and validated by the VM at runtime.

@workflow
class DurableSleepWorkflow(Workflow):
    async def run(self, seconds: float) -> SleepResult:
        started = await get_timestamp()
        await asyncio.sleep(seconds)  # survives worker restarts
        resumed = await get_timestamp()
        return await format_sleep_result(started, resumed, seconds)

This is also fine inside loops - useful for polling or for slow drip behaviors:

for i in range(iterations):
    await asyncio.sleep(sleep_seconds)
    await perform_loop_action(iteration=i + 1)

asyncio.sleep also accepts result=. Both arguments are evaluated in source order before the wait. Nonpositive literal delays return the result without a VM wait; positive literal delays must fit the runtime duration range. Dynamic delays currently must be positive: zero, negative, or invalid values end the run with a VM error rather than Python-compatible behavior (#602).

Early return

Returning from inside a branch - including from inside a loop - works:

@workflow
class LoopReturnWorkflow(Workflow):
    async def run(self, items: list[int], needle: int) -> LoopReturnResult:
        checked = 0
        for value in items:
            checked += 1
            if await matches_needle(value=value, needle=needle):
                return await build_loop_return_result(
                    items=items, needle=needle,
                    found=True, value=value, checked=checked,
                )
        return await build_loop_return_result(
            items=items, needle=needle,
            found=False, value=None, checked=checked,
        )

Compiler limitations

Waymark compiles a growing subset of Python. The limitations below describe what to use today and where support is planned. For example, finally currently raises a compilation error: put an operation and its finally cleanup in the same action while workflow-level support is being added.

Compilation happens when a workflow is first run or scheduled. Unsupported patterns detected by the SDK raise UnsupportedPatternError with a source location and guidance. A limitation detected during runtime compilation raises RuntimeError (or the underlying gRPC error under pytest); see Exceptions. The linked issues stay open until the behavior is supported.

Control flow

  • try/finally and try/else. Use try/except today, with operations that require finally cleanup contained in an action. For success-only work, initialize a flag to False before the try, set it to True at the end of the try body, and check it in an if after the entire try/except. This keeps errors from that work outside the original handler. Adding finally or else currently raises a compilation error; support is planned in #580.
  • Loop else clauses. for/else and while/else currently raise a compilation error. Track whether the loop completed with an explicit flag until loop else support is available (#581).
  • Chained comparisons and short-circuit operators. Use explicit nested conditions today. Chained comparisons such as 0 < x < 10 and and / or currently raise compilation errors; support is planned in #582 and #590.
  • Nested await. Await an action into its own variable before using its result in a larger expression. Nested awaits currently raise a compilation error; general expression support is tracked in #585.
  • Comprehensions. List comprehensions compile when assigned directly to a variable or spread into gather; generator expressions compile only as starred arguments inside gather. Dict and set comprehensions currently raise compilation errors. Dict-comprehension support is tracked in #583.
  • raise. Raise inside an action today; the workflow handles it with try/except.
  • Other Python constructs. Keep with, lambda, match, yield, walrus (:=), assert, del, global/nonlocal, import, and nested def or class inside actions for now; they have no workflow IR lowerings yet.

Calls and parallel work

  • String formatting. Build formatted strings inside actions today; f-strings currently raise compilation errors anywhere in the workflow body.
  • Supported calls. Call actions, the workflow's own methods, supported data-only constructors, awaited asyncio.sleep and asyncio.gather, len, range, enumerate, and local-list append using the forms described here. Put other builtins such as str() and int(), library calls such as datetime.now() or uuid4(), and other object methods such as Model.model_validate(...) inside actions; direct workflow calls currently raise compilation errors.
  • isinstance(value, SomeError). The compiler rejects runtime type checks until the VM supports them (#790). Catch the class with an except clause instead.
  • asyncio.gather options and operands. Omit return_exceptions or pass the literal False today. return_exceptions=True raises a compilation error until exception collection is supported (#575). Awaitables must be direct action or workflow-helper calls, or a single starred list/generator comprehension. Direct asyncio.sleep operands and coroutine variables currently raise compilation errors (#579). Mixed spreads, async comprehensions, and an empty argument list are also unsupported; an empty collection inside a supported comprehension spread works. Dynamic option values, unknown keywords, **kwargs, and statically known unpacking target-count mismatches raise compilation errors.
  • gather in larger expressions. Await it in a statement of its own, on the right-hand side of an assignment, or directly in a return: return await asyncio.gather(...). Spreading a variable (*tasks) currently raises a compilation error; spread a list comprehension directly instead.
  • Call arguments. Pass action and helper arguments explicitly today; *args / **kwargs raise compilation errors (#584). Missing, duplicate, unknown, or extra arguments are also errors. Dependency-injected action parameters are excluded from the public signature. Aliases and module re-exports follow the same validation rules.
  • range and enumerate. Use one to three integer bounds for range directly in a loop, assignment, or return; a literal zero step is an error. Use enumerate(items, start=0) directly in a loop; other starts and expression positions currently raise compilation errors.

Values and workflow state

  • Models and dataclasses. Data-only pydantic models and dataclasses become dictionaries. Factories, inherited hooks, custom initializers, validators and computed fields are rejected. Construct those objects inside an action, even if the call supplies every field. Omitted defaults are populated when they consist of supported primitive values, lists, tuples, or string-keyed dictionaries. Other defaults, such as enum members, must be passed explicitly or constructed inside an action. Tuple identity is still subject to the transport limitation below. Broader construction support is planned in #829.
  • List mutation through aliases. Use append() and list += on fresh local lists whose ownership the compiler can establish. Mutating a parameter or aliased list currently raises a compilation error; return a new list and reassign it until shared mutation is supported (#770).
  • Assignment targets. Use names and flat unpacking targets today. Attribute and subscript writes, including d[key] += value, and chained assignments such as a = b = value currently raise compilation errors. Additional assignment forms are tracked in #583 and #584.
  • Dictionary construction. Use explicit entries with keys known to be strings today. Dictionary spreads such as {**base, "extra": 1} raise a compilation error until spread support is added (#587).
  • Instance state. Pass runtime state through run() arguments. Reads of a unique literal assigned in __init__ compile, including inside larger expressions. Unknown or ambiguous instance attributes and all instance writes currently raise compilation errors (#586).
  • String lengths. Literal strings use Python's character count. Compute other string lengths inside an action for now; operands known to be strings or caught exceptions raise compilation errors until the VM can preserve their semantics.
  • Numeric literals. Use finite floats and integers within signed 64-bit range; other numeric literals raise compilation errors.
  • Generic annotations. Specialize open type parameters in workflow inputs and returns before compilation. Helpers on a generic workflow base compile for each subclass that specializes it: on class Agent(Base[Prompt]), a helper written as async def handle(self, request: T) on class Base(Workflow, Generic[T]) reads T as Prompt, through intermediate bases too. A parameter no subclass specializes, or one declared only by the method itself, still raises a compilation error.
  • Module-level values. A global the workflow never binds itself compiles as the value it holds when the workflow compiles, however that value was computed: BATCH_SIZE = 5 * 6 reads as 30, and an imported constant works the same way. That covers None, booleans, numbers, strings, enum members (as their value), and tuples of these; a RetryPolicy or timedelta global also works as a whole retry= or timeout=. Exception classes resolve at compilation too. A global holding a list, dict or set raises a compilation error: each read compiles to its own copy, so mutations would not be shared as in Python. Use a tuple instead. Pass other values through workflow inputs or fetch them inside an action. A global rebound after the workflow compiles keeps its compiled value, and one read from the environment at import compiles per process.

Policies and exception handlers

  • Retry and timeout policies. Use RetryPolicy(...) with keyword literals for retry, and a number or timedelta(...) with literal seconds, minutes, hours or days for timeout. A module-level constant counts as the literal it holds, in a single field or as the whole policy. A self. attribute assigned one of these once at the top level of __init__ also works. Pass policies by keyword. Compilation errors include a variable, a call result, positional RetryPolicy arguments, a policy passed to run_action positionally, a self. attribute assigned more than once or under a branch, a tuple for exception_types, a non-string or dotted entry in it, a timeout of zero or less, a negative backoff, either above 100 years, and any other run_action keyword. Retries & Timeouts and the RetryPolicy reference point here for the rejected forms.
  • Exception handlers. Name an exception class in the workflow's module or the builtins. The name must resolve to an exception class, and a dotted name's root to a module or a class: except httpx.ConnectError: compiles and matches the class called ConnectError. An alias resolves to the class it names, so except VE: with VE = ValueError catches a ValueError. Rejected: except self.error:, except cfg.error_cls: with cfg a value, a class that is not an exception such as except int:, a value reached through a module such as except os.sep:, a tuple alias such as except ERRORS:, an undefined name, a subscript or a call as the handler type, and except ():, which would catch nothing. A name imported only under TYPE_CHECKING or inside a function is not in scope.

Runtime limitations

The compiler also rejects statically identifiable VM mismatches: mixed numeric operations and integer true division (#589), dictionary iteration (#591), sequence repetition, collection comparisons, and dynamic string lengths (#592), and inspection of a caught exception (#594). Literal string lengths use Python's character count. Passing a caught exception to an action remains supported.

These reports remain open: the checks use local source facts, not a complete runtime type system. Values returned by actions or reached through dynamic containers can still encounter these runtime limitations after compilation. Follow #772 for the remaining work.

Dynamic asyncio.sleep delays that evaluate to zero, negative numbers, or invalid values still end the run with a VM error. Nonpositive literal delays are handled by the compiler (#602).

Values crossing between workflows and actions have these current limitations:

  • Models and dataclasses become dictionaries inside the workflow, and tuples become lists (#596).
  • Non-finite numbers and out-of-range integers produced at runtime still require validation at the action/wire boundary (#597).
  • datetime, Decimal, UUID, bytes, set, and Enum values can lose their type. Receiving annotations restore only some types: enum annotations do not restore enum members, and bytes | str can reinterpret strings as bytes (#599).
  • A subclass passed under a base-class annotation can lose its fields. Open generic workflow and action annotations are rejected during compilation, but preserving subclass identity needs a transport change (#771).