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-callretry=RetryPolicy(...)ortimeout=. A bareawait some_action(...)uses the defaults. Note thatRetryPolicy()withattemptsomitted defaults to a budget of 100 retries; passattempts=1when you want a failure to surface immediately.- An
exceptclause 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 aConnectionError, andexcept LookupError:catches aKeyError. Waymark imports nothing at runtime - only names are compared, soexcept httpx.ConnectError:means the class calledConnectError, whatever module it came from. The runtime'sActionTimeoutandActionExecutionLostderive fromBaseException, soexcept Exception:does not catch them; import them fromwaymark.vm_exceptionsto list them, or catchBaseException.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/finallyandtry/else. Usetry/excepttoday, with operations that requirefinallycleanup contained in an action. For success-only work, initialize a flag toFalsebefore thetry, set it toTrueat the end of thetrybody, and check it in anifafter the entiretry/except. This keeps errors from that work outside the original handler. Addingfinallyorelsecurrently raises a compilation error; support is planned in #580.- Loop
elseclauses.for/elseandwhile/elsecurrently raise a compilation error. Track whether the loop completed with an explicit flag until loopelsesupport is available (#581). - Chained comparisons and short-circuit operators. Use explicit nested
conditions today. Chained comparisons such as
0 < x < 10andand/orcurrently 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 insidegather. 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 withtry/except.- Other Python constructs. Keep
with,lambda,match,yield, walrus (:=),assert,del,global/nonlocal,import, and nesteddeforclassinside 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.sleepandasyncio.gather,len,range,enumerate, and local-listappendusing the forms described here. Put other builtins such asstr()andint(), library calls such asdatetime.now()oruuid4(), and other object methods such asModel.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 anexceptclause instead.asyncio.gatheroptions and operands. Omitreturn_exceptionsor pass the literalFalsetoday.return_exceptions=Trueraises 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. Directasyncio.sleepoperands 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.gatherin 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/**kwargsraise 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. rangeandenumerate. Use one to three integer bounds forrangedirectly in a loop, assignment, or return; a literal zero step is an error. Useenumerate(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 asa = b = valuecurrently 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 asasync def handle(self, request: T)onclass Base(Workflow, Generic[T])readsTasPrompt, 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 * 6reads as30, and an imported constant works the same way. That coversNone, booleans, numbers, strings, enum members (as their value), and tuples of these; aRetryPolicyortimedeltaglobal also works as a wholeretry=ortimeout=. 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 forretry, and a number ortimedelta(...)with literalseconds,minutes,hoursordaysfortimeout. A module-level constant counts as the literal it holds, in a single field or as the whole policy. Aself.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, positionalRetryPolicyarguments, a policy passed torun_actionpositionally, aself.attribute assigned more than once or under a branch, a tuple forexception_types, a non-string or dotted entry in it, a timeout of zero or less, a negative backoff, either above 100 years, and any otherrun_actionkeyword. 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 calledConnectError. An alias resolves to the class it names, soexcept VE:withVE = ValueErrorcatches aValueError. Rejected:except self.error:,except cfg.error_cls:withcfga value, a class that is not an exception such asexcept int:, a value reached through a module such asexcept os.sep:, a tuple alias such asexcept ERRORS:, an undefined name, a subscript or a call as the handler type, andexcept ():, which would catch nothing. A name imported only underTYPE_CHECKINGor 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, andEnumvalues can lose their type. Receiving annotations restore only some types: enum annotations do not restore enum members, andbytes | strcan 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).