How to use and extend the compiler's extensible rule system — the knowledge base that teaches the flow compiler how to interpret Python decorators, context managers, and function calls.

For the formal specification, see reference/specs/compiler-rules.md.


What are compiler rules?#

When the flow compiler encounters a Python construct like @retry(...) or with asyncio.timeout(30):, it needs to know what to do with it. Should it become a separate actor? Should the compiler extract configuration from it? Should it be inlined into the router?

Compiler rules answer these questions. Each rule maps a Python symbol to a compiler behavior and optionally tells the compiler how to extract parameter values into AsyncActor manifest fields.

Explicit over implicit#

The central design principle: the rules file is the single source of truth. There is no hardcoded behavior for any specific library inside the compiler. Tenacity, asyncio, stamina — all handled through the same rule mechanism that you extend for your own project.

If you want to know what the compiler does with @retry(...), read the YAML:

- match: "tenacity.retry"
  treat-as: config
  where:
  - param: stop
    ...

No source diving required. No hidden magic.

User-accessible configuration#

Rules live in .asya/config.compiler.rules.yaml — a file delivered to you via asya init, checked into your project, fully under your control. The compiler's knowledge base is not locked inside a package; it is a project artifact you read, modify, and version alongside your flow code.

Why the long filename? Asya uses a filename-to-key convention: dotted parts in the filename become nested config keys. config.compiler.rules.yaml automatically maps to compiler.rules in the merged config tree. This means you can also define rules inside config.yaml under compiler.rules: — both sources are merged (extended, not replaced).

Subdirectory merging. Asya walks up from the current directory to the git root, collecting all .asya/ directories it finds. Config files are merged root-first, nearest-wins. Lists (like rules) are extended across directories, so a subdirectory's rules add to the parent's rules rather than replacing them:

project/
  .asya/config.compiler.rules.yaml      # 2 rules (root)
  pipelines/
    .asya/config.compiler.rules.yaml    # 1 rule (subdirectory)

Compiling from pipelines/ sees all 3 rules. Compiling from project/ sees only the root 2.

Pure syntactic mapping#

Rules operate on AST structure, not runtime values. @retry(stop=stop_after_attempt(3)) is matched and extracted at compile time; 3 is a literal the compiler reads directly from the syntax tree. No decorator invocation, no imports executed, no side effects.


Getting started#

Rules are created automatically when you initialize a project:

asya init --registry ghcr.io/my-org

This creates .asya/config.compiler.rules.yaml with the shipped defaults as annotated examples. The file is ready to extend.


Where rules apply: flows vs actors#

Rules operate in two contexts. The same rule YAML works in both — the compiler auto-detects scope from Python syntax.

Context managers in flows#

Context managers appear inside the flow function body. The compiler strips the with block and injects extracted config into every actor within the scope.

Source: examples/flows/with_asyncio_timeout.py

@flow
async def document_pipeline(p: dict) -> dict:
    p["status"] = "processing"

    async with asyncio.timeout(30):
        p = ocr_extractor(p)
        p = language_detector(p)

    if p.get("language") != "en":
        p = translator(p)

    p = sentiment_analyzer(p)
    p["status"] = "done"
    return p

The compiler: 1. Strips async with asyncio.timeout(30): from the generated routers 2. Extracts 30 via the asyncio.timeout rule's where-tree 3. Writes spec.resiliency.timeout.actor: 30 into the manifests for ocr_extractor and language_detector (the actors inside the scope) 4. Leaves translator and sentiment_analyzer unaffected (outside the scope)

The generated router has no trace of the timeout — it is pure routing logic:

Compiled output: examples/flows/compiled/with_asyncio_timeout/routers.py

async def start_document_pipeline(payload: dict):
    p = payload
    _next = []
    p['status'] = 'processing'
    _next.append(resolve("ocr_extractor"))
    _next.append(resolve("language_detector"))
    _next.append(resolve("router_document_pipeline_line_23_if_2"))
    yield "SET", ".route.next[:0]", _next
    yield payload

Nested scopes are supported — each tracks its own actors independently.

Source: examples/flows/with_nested_timeout_scopes.py

@flow
async def ingestion_pipeline(p: dict) -> dict:
    async with asyncio.timeout(60):        # scope: fetch, parse, validate
        p = data_fetcher(p)
        async with asyncio.timeout(10):    # scope: parse, validate only
            p = data_parser(p)
            p = data_validator(p)
    p = data_writer(p)                     # no timeout scope
    return p

Decorators on actor definitions#

Decorators appear on handler function definitions — either in the same file as the flow or in imported modules. The compiler extracts config from the decorator arguments and applies it to that single actor's manifest.

Source: examples/flows/decorator_retry.py

import asyncio
from tenacity import retry, stop_after_attempt

@flow
async def resilient_pipeline(p: dict) -> dict:
    p["status"] = "processing"

    async with asyncio.timeout(30):
        p = await fetch_data(p)
        p = await transform_data(p)

    p = await store_results(p)
    return p


@actor
@retry(stop=stop_after_attempt(5))
def fetch_data(p: dict) -> dict:
    """Fetch data from external API — retries up to 5 times on failure."""
    p["data"] = "fetched"
    return p


@actor
def transform_data(p: dict) -> dict:
    """Transform fetched data."""
    p["transformed"] = True
    return p


@actor
def store_results(p: dict) -> dict:
    """Store final results."""
    p["stored"] = True
    return p

Here both mechanisms work together on the same flow:

Actor Context manager scope Decorator Manifest result
fetch_data asyncio.timeout(30) @retry(stop=stop_after_attempt(5)) timeout.actor: 30 + maxAttempts: 5 + ASYA_IGNORE_DECORATORS=tenacity.retry
transform_data asyncio.timeout(30) (none) timeout.actor: 30
store_results (outside scope) (none) (no extracted config)

The compiler: 1. Scans @retry(stop=stop_after_attempt(5)) on fetch_data 2. Resolves retry to tenacity.retry via the import map 3. Walks the where-tree: param: stop -> match: stop_after_attempt -> param: max_attempt_number -> assign-to: spec.resiliency.policies.default.maxAttempts 4. Extracts 5 and stores it for fetch_data's manifest 5. Adds tenacity.retry to ASYA_IGNORE_DECORATORS so the runtime strips @retry before loading the handler

Compiled output: examples/flows/compiled/decorator_retry/routers.py

Inline overrides on flow statements#

For per-call control without rules, use # asya: <action> comments directly on flow statements.

Source: examples/flows/inline_comment_overrides.py

@flow
def order_pipeline(p: dict) -> dict:
    p = normalize_keys(p)  # asya: inline   — runs inside the router process
    p = validate_order(p)  # asya: actor    — separate actor with its own queue

    if p.get("fraud_score", 0) > 0.8:
        p["status"] = "rejected"
        return p

    p = charge_payment(p)
    return p

Compiled output: examples/flows/compiled/inline_comment_overrides/routers.py

In the generated router, normalize_keys is inlined (its code runs inside the router function), while validate_order and charge_payment are resolved as separate actors via resolve().

Definition-site decorators#

Functions decorated with @actor, @inline, @flow, or @unfold at their definition site carry that classification everywhere they are called.

Source: examples/flows/decorator_definitions.py

@inline
def inject_trace(p: dict) -> dict:
    """Runs inside the router — no actor boundary."""
    p.setdefault("trace_id", str(uuid.uuid4()))
    return p

@actor
def validate(p: dict) -> dict:
    """Deployed as a separate actor."""
    if "id" not in p:
        raise ValueError("missing required field: id")
    return p

And the equivalent call-site pattern for functions defined elsewhere:

Source: examples/flows/decorator_callsite.py

p = inline(stamp_timestamp)(p)  # force inline regardless of definition
p = actor(validator)(p)          # force actor regardless of definition

Built-in rules#

Seven rules ship with asya-lab and apply automatically (no configuration needed). See the full YAML in src/asya-lab/asya_lab/defaults/compiler.rules.yaml.

Tool decorator rules (claude_agent_sdk.tool, langchain.tools.tool, langchain_core.tools.tool)#

Functions decorated with @tool from supported frameworks are classified as actors. When called with non-standard signatures, the compiler generates an adapter wrapper.

Source: examples/flows/tool_adapter.py

from claude_agent_sdk import tool

@tool
async def get_weather(city: str) -> dict:
    """Get current weather for a city."""
    return {"temp": 22, "condition": "sunny"}

@tool
def format_greeting(name: str) -> str:
    """Format a personalized greeting."""
    return f"Hello, {name}!"

@flow
async def tool_adapter(p: dict) -> dict:
    p = await validate_input(p)
    p["weather"] = await get_weather(p["city"])        # adapter generated
    p["greeting"] = format_greeting(p["user_name"])    # adapter generated
    p = await compose_response(p)
    return p

The compiler: 1. Resolves tool to claude_agent_sdk.tool via the import map 2. Classifies get_weather and format_greeting as actors (treat-as: actor) 3. Detects non-standard call patterns (p["weather"] = get_weather(p["city"])) 4. Generates adapter files: adapter_get_weather.py and adapter_format_greeting.py 5. Adds claude_agent_sdk.tool to ASYA_IGNORE_DECORATORS so the runtime strips @tool

Generated adapter (adapter_get_weather.py):

async def adapter_get_weather(payload: dict):
    """Adapter: wraps get_weather() for dict-in/dict-out protocol"""
    _result = await get_weather(payload["city"])
    payload["weather"] = _result
    yield payload

Standard p = fn(p) calls with @tool are treated as regular actor calls (no adapter needed). The adapter is only generated for non-standard patterns.

Compiled output: examples/flows/compiled/tool_adapter/

asyncio.timeout#

async with asyncio.timeout(30):
    p = ocr_extractor(p)

Extracts 30 into spec.resiliency.timeout.actor for all actors in scope.

tenacity.retry#

@retry(stop=stop_after_attempt(5), wait=wait_exponential(min=1, max=60))
def fetch_data(p: dict) -> dict: ...

Extracts into the manifest: - spec.resiliency.policies.default.maxAttempts: 5 - spec.resiliency.policies.default.initialDelay: 1 - spec.resiliency.policies.default.maxInterval: 60

Also handles combined stop conditions with |:

@retry(stop=stop_after_attempt(5) | stop_after_delay(30))
def fetch_data(p: dict) -> dict: ...

Extracts both maxAttempts: 5 and maxDuration: 30.

timeout_decorator.timeout#

@timeout(30)
def slow_handler(p: dict) -> dict: ...

Extracts 30 into spec.resiliency.timeout.actor.

stamina.retry#

@stamina.retry(attempts=3, timeout=60)
def resilient_handler(p: dict) -> dict: ...

Extracts maxAttempts: 3 and maxDuration: 60.


Writing custom rules#

Add rules to .asya/config.compiler.rules.yaml. User rules extend shipped defaults — they do not replace them.

Simple decorator extraction#

Map a custom timeout decorator to a manifest field:

# @my_timeout(seconds=30) -> spec.resiliency.actorTimeout: 30
- match: "mylib.my_timeout"
  treat-as: config
  where:
  - param: seconds
    assign-to: spec.resiliency.actorTimeout

Given:

from mylib import my_timeout

@my_timeout(seconds=30)
def slow_handler(p: dict) -> dict: ...

The compiler extracts 30 and writes spec.resiliency.actorTimeout: 30 to the actor's manifest. The @my_timeout decorator is stripped at runtime via ASYA_IGNORE_DECORATORS.

Nested extraction with where-trees#

For decorators with nested function calls as arguments, use a where: tree to navigate into the call structure. The built-in tenacity.retry rule is the canonical example:

- match: "mylib.retry"
  treat-as: config
  where:
  - param: attempts
    assign-to: spec.resiliency.policies.default.maxAttempts
  - param: backoff
    where:
    - match: "exponential"
      where:
      - param: base
        assign-to: spec.resiliency.policies.default.initialDelay
      - param: cap
        assign-to: spec.resiliency.policies.default.maxInterval

This handles:

@retry(attempts=5, backoff=exponential(base=1, cap=60))
def fragile_service(p: dict) -> dict: ...

Result: {maxAttempts: 5, initialDelay: 1, maxInterval: 60}.

Handling operator-combined arguments#

Some libraries combine parameters with |, &, or + operators. Use flatten-on to flatten the expression tree before matching. This is how the built-in tenacity.retry rule handles combined stop conditions:

- match: "mylib.retry"
  treat-as: config
  where:
  - param: stop
    flatten-on: "|"
    where:
    - match: "after_attempts"
      where:
      - param: n
        assign-to: spec.resiliency.policies.default.maxAttempts
    - match: "after_delay"
      where:
      - param: seconds
        assign-to: spec.resiliency.policies.default.maxDuration

This handles:

@retry(stop=after_attempts(5) | after_delay(30))
def api_call(p: dict) -> dict: ...

Context manager rules#

Context manager rules work the same way — the parser auto-detects that a with statement triggers the rule. The built-in asyncio.timeout rule is the canonical example:

- match: "mylib.deadline"
  treat-as: config
  where:
  - param: seconds
    assign-to: spec.resiliency.actorTimeout
async with mylib.deadline(seconds=30):
    p = process(p)
    p = validate(p)

Both process and validate get spec.resiliency.actorTimeout: 30 in their manifests.


How it works end-to-end#

Here is the full pipeline for a @retry decorator:

1. Parser encounters @retry(stop=stop_after_attempt(5)) on def fetch_data
2. Import map resolves "retry" -> "tenacity.retry"
3. RuleEngine.classify("tenacity.retry") -> TreatAs.CONFIG
4. RuleEngine.get_rule("tenacity.retry") -> CompilerRule with where: tree
5. ValueExtractor.extract(ast_call_node, rule) walks the where: tree:
   a. Bind args: stop=stop_after_attempt(5)
   b. Navigate into "stop" param -> find ast.Call(stop_after_attempt, args=[5])
   c. Match discriminator "stop_after_attempt" -> match
   d. Bind args: max_attempt_number=5
   e. Terminal: assign-to spec.resiliency.policies.default.maxAttempts = 5
6. Parser stores extracted_values: {"spec.resiliency.policies.default.maxAttempts": 5}
7. Parser adds "tenacity.retry" to ignore_decorators list
8. Templater writes maxAttempts: 5 into the AsyncActor manifest
9. Templater writes ASYA_IGNORE_DECORATORS=tenacity.retry into the manifest env
10. At runtime, asya-runtime strips @retry before loading the handler module

Scope semantics#

The parser auto-detects scope from Python syntax:

Syntax Scope Applies to
with foo(): Context manager All actors inside the with body
@foo(...) Decorator The decorated function only
p = foo(p) Call-site The call itself

Nested context manager scopes#

Each with block tracks its own set of actors:

async with asyncio.timeout(60):        # scope: fetch, parse, validate
    p = fetch(p)
    async with asyncio.timeout(10):    # scope: parse, validate only
        p = parse(p)
        p = validate(p)
p = store(p)                           # no timeout scope

The compiler understands nested scopes. In this example: - A 60s timeout applies to the group of actors: fetch, parse, and validate - A nested, more restrictive 10s timeout applies to the subgroup containing only parse and validate - The store actor is outside of any timeout scope defined here


Rule matching#

Matching is exact — the rule's match field must equal the fully-qualified symbol name. No wildcards, no regex, no pattern matching.

The compiler resolves bare names to FQNs using the flow file's import statements:

from tenacity import retry    # "retry" resolves to "tenacity.retry"
import asyncio                # "asyncio.timeout" stays as-is

If a symbol cannot be resolved to an FQN, matching uses the bare name.


Examples#

The examples/flows/ directory contains working examples. Each has a compiled/ subdirectory with the generated routers so you can see input and output side by side.

Source file Compiled output Demonstrates
decorator_retry.py compiled/decorator_retry/ @retry config extraction + asyncio.timeout scope
with_asyncio_timeout.py compiled/with_asyncio_timeout/ Context manager scoped to multiple actors
with_nested_timeout_scopes.py compiled/with_nested_timeout_scopes/ Nested scopes with different timeouts
decorator_definitions.py compiled/decorator_definitions/ @actor and @inline on function definitions
decorator_callsite.py compiled/decorator_callsite/ actor(fn)(p) and inline(fn)(p) wrappers
inline_comment_overrides.py compiled/inline_comment_overrides/ # asya: actor / # asya: inline directives
tool_adapter.py compiled/tool_adapter/ @tool adapter generation for non-standard signatures

Compile any example yourself:

asya flow compile examples/flows/decorator_retry.py --output-dir compiled/ --verbose

See also#