Async–Sync Bridge

June 9, 2026 · View on GitHub

EverAlgo operators follow a strict naming convention to distinguish async I/O methods from synchronous pure-compute methods.


The a-prefix rule

Methods with an a prefixaextract, adetect, arank, aparse — are native async. They perform real I/O (LLM calls, OCR, ASR, URL fetch) and must be called with await.

Methods without an a prefixextract, rank, count_tokens, rrf — are synchronous. They are pure-compute (no I/O) and must be called without await.

# ✅ Correct — async I/O method, use await
episode = await EpisodeExtractor(llm=client).aextract(memcell, sender_id="u_alice")

# ✅ Correct — sync pure-compute method, no await
merged  = cluster_by_geometry(new_cluster, existing_clusters)
fused   = rank.fusion.rrf(vec_hits, keyword_hits)
n       = boundary._tokenize.count_tokens(text)

Reading the method name tells you its calling convention immediately — no need to look up documentation. This is the same convention used by dspy.acall / dspy.aforward, litellm.acompletion, and instructor.AsyncInstructor.

Exception — LLMClient. The a-prefix rule governs EverAlgo operator methods. The LLMClient Protocol (everalgo.llm.protocols) is a caller-injected client interface, not an operator; its single method is async def chat(...) — async but without the a prefix — to mirror the OpenAI SDK client surface. EverAlgo operators call it internally via await self._llm.chat(...).


Why pure-compute methods are not async

Python's asyncio is designed for I/O-bound concurrency, not for CPU-bound work. Wrapping a millisecond-scale computation in async def adds overhead and forces callers to await a result that was ready instantly.

NumPy, SciPy, scikit-learn, and PyTorch all follow the same rule: pure-compute functions are synchronous. EverAlgo's fusion math (rrf, cosine_to_lr_score, score_propagation), token counting, and clustering geometry calculations are in this category.

If a specific computation grows large enough to block the event loop (roughly above ~100 ms), the caller wraps it with asyncio.run_in_executor — the operator's API stays synchronous.


The sync bridge for I/O operators

Every I/O operator provides a synchronous wrapper as a convenience for non-event-loop callers (CLI scripts, plain unit tests). The wrapper is derived from the async implementation using asgiref.sync.async_to_sync, which means there is exactly one implementation to maintain.

# In everalgo/user_memory/episode.py (simplified)
from asgiref.sync import async_to_sync

class EpisodeExtractor:
    def __init__(self, *, llm: LLMClient) -> None:
        self._llm = llm

    async def aextract(self, memcell, *, sender_id):
        ...

    extract = async_to_sync(aextract)

The sync bridge is only safe in non-event-loop contexts. Do not call extract(...) inside a running event loop (FastAPI handler, async def function, Jupyter cell after asyncio.run has started). In those contexts, always use await aextract(...).

Calling the sync bridge from within a running event loop will raise a RuntimeError from asgiref.


Quick reference

OperatorMethodTypeCalling convention
EpisodeExtractoraextractasync I/Oawait extractor.aextract(mc, sender_id=...)
EpisodeExtractorextractsync bridgeextractor.extract(mc, sender_id=...) — non-event-loop only
BoundaryDetectoradetectasync I/Oawait detector.adetect(messages, is_final=True)
BoundaryDetectordetectsync bridgedetector.detect(messages, is_final=True) — non-event-loop only
cluster_by_geometrycluster_by_geometrysync pure-computecluster_by_geometry(new_cluster, existing_clusters)
rank.episodic.arankarankasync I/Oawait rank.episodic.arank(rank_input)
rank.profile.rankranksync pure-computerank.profile.rank(rank_input)
rank.fusion.rrfrrfsync pure-computerank.fusion.rrf(hits_a, hits_b)

Mixing sync and async in the same pipeline

A typical pipeline has both kinds of calls:

# All I/O operators — use await
result   = await BoundaryDetector(llm=llm).adetect(messages, is_final=True)
mc       = result.cells[0]
episode  = await EpisodeExtractor(llm=llm).aextract(mc, sender_id="u_alice")

# Pure-compute — no await
merged   = cluster_by_geometry(new_cluster, existing_clusters)
fused    = rank.fusion.rrf(vec_hits, keyword_hits)
n_tokens = boundary._tokenize.count_tokens(text)

The naming convention ensures there is no ambiguity even when both styles appear in the same function.