Custom Nodes
In AmritaSense, nodes are the basic units of execution flow. Built-in instructions are ultimately expanded into node compositions, while custom nodes are the direct way developers encapsulate their business logic. This chapter explains node essence, lifecycle, and how to use POINTER_DEPENDS to gain full interpreter control when necessary.
4.6.1 The @Node decorator and node essence
The @Node() decorator converts an ordinary Python function or coroutine into a workflow node. It does not do anything complex — it simply packages the function object, signature metadata, and a few data fields into a Node instance.
Decorator parameters
@Node(
tag="custom_tag", # optional: breakpoint label, autogenerated by default
wrap_to_async=True, # optional: whether to wrap sync functions as async
address_able=True # optional: whether it can be referenced by ALIAS
)
def my_function():
passtag is both a debugging identifier and the suspension point name used by flow interrupts. If omitted, it defaults to NodeSuspend::{function_name}.
Node essence: a thin wrapper around a callable
Each node is essentially a thin wrapper around the original function. It preserves the original function’s signature information (fun_sign) and execution capability (func), while adding AmritaSense metadata:
func: the original function object, which the interpreter calls during executionfun_sign: the function signature extracted byinspect.signature, used by dependency injection to match parameterstag: the node’s unique identifier stringwrap_to_async: whether synchronous functions should be wrapped to asyncaddress_able: whether the node can be referenced byALIAS; onlyTruenodes can becomeGOTOorCALLtargets
Everything is a node — this is AmritaSense’s core philosophy. Conditionals, loop bodies, exception handlers, and GOTO targets are all Node or BaseNode instances. Custom nodes are no exception.
4.6.2 Handling sync and async nodes
AmritaSense unifies synchronous and asynchronous nodes. In _call(), it decides how to execute based on iscoroutinefunction and wrap_to_async.
Asynchronous nodes
Nodes defined with async def are awaited directly:
@Node()
async def fetch_data():
return await http_get("/api")The interpreter detects the coroutine function and executes await fun(*args, **kwargs).
Synchronous nodes + wrap_to_async=True (default)
Synchronous functions default to wrap_to_async=True, and the interpreter runs them with asyncio.to_thread:
@Node()
def heavy_compute():
return sum(range(10**7))This avoids blocking the event loop. It is suitable for CPU-bound tasks or legacy synchronous code.
Synchronous nodes + wrap_to_async=False
When wrap_to_async=False, the interpreter calls fun(*args, **kwargs) directly:
@Node(wrap_to_async=False)
def quick_check():
return len(queue) > 0This is appropriate for extremely lightweight sync operations, such as simple condition checks with zero scheduling overhead.
Performance considerations
- CPU-bound tasks →
wrap_to_async=Trueto avoid blocking the event loop. - I/O-bound tasks → prefer native
async def. - Minimal sync operations (like
NOPor simple boolean checks) →wrap_to_async=False.
The Node class is also memory-optimized using __slots__, avoiding a default __dict__ and keeping each node as lightweight as possible.
4.6.3 Node lifecycle and atomicity
Lifecycle
- Creation: when the module loads, the
@Node()decorator runs and creates aNodeinstance. - Compilation: during
render(), the node is placed intoNodeComposeRendered’s_grapharray and given aPointerVectoraddress. - Pre-check: before each execution,
_pre_checkis called. This is the entry for compile-time validation, such as alias resolution inCallNode. - Execution: the interpreter acquires the lock, checks suspension points, resolves dependencies, and calls
func. - Completion: after execution, the lock is released and the interpreter advances the pointer.
Atomicity guarantees
Node atomicity is guaranteed by the interpreter lock and cooperative interruption:
- Only one node executes at a time: the interpreter lock prevents concurrent node execution or external injection.
- Node boundaries are safe boundaries: interrupt signals are checked only after a node finishes, so the node body is not preempted.
- Exception safety: if a node raises an exception, the interpreter’s
run_step_by()loop catches and handles it at the top level. The return stack (_ret_addr_stack) and pointer state remain consistent because nodes do not manipulate those structures directly; the interpreter manages them.
Pre-check mechanism
If a custom node class inherits from BaseNode, it can override _pre_check. This method is called before each node execution and can access the current interpreter instance to perform address validation, alias lookup, or other checks. CallNode and JumpNode both use this mechanism to resolve aliases to addresses before their first execution.
4.6.4 POINTER_DEPENDS: access to the interpreter
POINTER_DEPENDS is a special dependency injection factory that allows a node to obtain the current WorkflowInterpreter instance.
from amrita_sense.runtime.deps import POINTER_DEPENDS
from amrita_sense.runtime.workflow import WorkflowInterpreter
@Node()
def my_node(pc: WorkflowInterpreter = Depends(POINTER_DEPENDS)):
current_addr = pc._pointer # read current position
target = pc.find_addr_alias("foo") # resolve alias
await pc.call_sub(target, arg=42) # call a subroutineWhat does the node gain?
By injecting pc, the node gains full access to the interpreter — reading the current pointer, resolving aliases, calling subroutines, and even performing dynamic jumps. This is one of AmritaSense’s core ideas: nodes are not passive units called by the framework; they can actively control execution flow.
When to use and when not to use it
- Use
POINTER_DEPENDSwhen a node needs to call subroutines (call_sub), resolve aliases (find_addr_alias), or perform dynamic jumps. - Do not use it when the node only implements pure business logic, such as data processing or API calls. In those cases, declare only business dependencies like a database connection or HTTP client.
Power comes with responsibility
With interpreter access, nodes can directly manipulate pointers and the call stack. This power comes with responsibility — internal jumps set _jump_marked, affecting interpreter behavior, and manual stack management can break call stack integrity.
Therefore, inject POINTER_DEPENDS only when necessary. Most nodes should use normal Python logic and composition-level instructions (IF, WHILE, CALL) to express control flow, and only directly access the interpreter when instructions cannot express the desired behavior.
Summary
Custom nodes are the “cells” of AmritaSense. They remain simple in essence — a thin wrapper around a Python function — while gaining full access to the Amrita ecosystem via Depends and POINTER_DEPENDS. In the next section, we will explore how to package recurring composition patterns into new self-compiled instructions to extend workflow expressiveness.
