Per-engine portability doc. Read
00-essence-and-core-abstractions.mdfirst — this doc is written against its essence (§2), capability inventory (§3, C1..C12), Engine SPI (§4c), matrix (§6), and ranked fit (§7), and follows the §9 six-section template. Target: pure Python, no JVM, no JPype.
Dask is a partial fit, and the part it fits, it fits beautifully. Dask is a
task-graph + futures + distributed-collections engine: dask.bag/dataframe
for embarrassingly-parallel data, dask.delayed/distributed.Client.submit
for arbitrary task DAGs, an adaptive scheduler that fans work across a cluster.
That is the data plane of this project, not the live agent plane.
Keep on Dask, idiomatically and at scale:
- Batch RAG ingestion (essence §2.2, the
IngestionPipelinelogic): crawl → chunk → embed → build the cold vector index, parallelised across the cluster. Thousands of documents, hundreds of workers, onebagpipeline. - Offline eval / benchmark sweeps: run the retrieval pipeline (or a full routed graph replay) over a labelled dataset, parametrised across configs (k, chunk size, embedder, reranker), in parallel — recall@k / nDCG / latency matrices as a DataFrame. This is the thing Dask is unfairly good at.
Do not put the live keyed-stateful agent loop on Dask. distributed.Actor
gives a stateful, single-threaded-per-instance actor, so a conversation-actor is
technically constructible (C1/C2 weakly) — but Dask is built for bounded
task graphs that complete, not long-lived per-key ordered streaming with
low-latency turn handling and backpressure-as-topology. You would fight the
model the whole way: no partitioned ordered ingest, no native durability of
actor state, no streaming source/sink. The honest recommendation (keystone §7):
port the ingestion + retrieval-eval pipelines to Dask; run the live loop on
Faust or Ray; use Dask as the heavy offline data engine alongside them.
What you keep unchanged: the entire pure core (§4a) — Chunker,
TwoTierRetriever, the embed→search→rerank→answer logic, ToolExecutor
semantics, ConversationStore/VectorStore as the durability seam. What you
re-implement: nothing of the agent operator — you simply don't host it here.
| Cap | §6 | On Dask | Mechanism / honest note |
|---|---|---|---|
| C1 keyed durable state | L* | distributed.Actor fields |
An actor is a single Python object pinned to one worker; its fields are per-key state — but in-memory only, lost on worker death. Durability = write through to the ConversationStore/VectorStore SPI. Weak, and out of the sweet spot. |
| C2 per-key ordered proc | — | not native | No keyBy→partition. You can route by key to a per-key actor (one actor per conversationId) and the actor's mailbox serialises calls, but there is no ordered partitioned ingest, no replay, no rebalance. For batch, ordering is irrelevant. |
| C3 fault tolerance | L | task retry | Dask reruns failed tasks from the graph; great for idempotent batch (re-embed a chunk). No checkpointed streaming state. Eval/ingest get C3 for free; the agent loop does not. |
| C4 async I/O | L | futures / asyncio client | client.submit returns futures; as_completed bounds in-flight work; the async Client runs inside asyncio. Maps the embed/LLM call fan-out well; maps the per-event agent loop poorly. |
| C5 backpressure | L | scheduler + batching | The scheduler caps concurrency; you throttle with as_completed over a sliding window or a Semaphore. Protects the embedder/model endpoint in batch. Not end-to-end streaming backpressure. |
| C6 connectors | L | read_* / to_* |
db.read_text, dd.read_parquet, read_sql, object-store globs, to_parquet. Bounded sources/sinks, not Kafka/streaming connectors. Perfect for "ingest this corpus", wrong for "subscribe to a topic". |
| C7 side outputs | L | extra return / extra collection | A task returns a tuple, or you build a second bag. Trivial for the debug/metrics side-channel of a batch run. |
| C8 broadcast state | L | client.scatter(broadcast=True) |
Scatter the embedder config / control dims to every worker once, reference by future. Idiomatic and cheap. |
| C9 event-time/windows | — | not a fit | No streaming time model. N/A for batch. |
| C10 CEP | — | not a fit | No pattern-over-stream. Out of scope. |
| C11 distributed scale | N | adaptive cluster | Dask's home turf: Client, LocalCluster, dask-kubernetes, adaptive scaling. This is why you'd reach for it — embed 10⁶ chunks across 200 workers. |
| C12 topology builder | N | task graph | delayed/bag/dataframe build a native lazy DAG the scheduler optimises. The routed-graph pattern expresses cleanly as a DAG per input row (router→path→verifier as delayed nodes). |
* = present but caveated, as spelled out above. The shape of the column is the
verdict: C11/C12 native, the data-plane capabilities L, and the
live-streaming-core capabilities (C2, C9, C10) flat —.
Chunker, RecursiveTextChunker, TwoTierRetriever, the embed→search→rerank→
answer steps, and the ToolExecutor contract have no Flink in them — they're
pure functions over plain values. In Python they're plain callables. The
RecursiveTextChunker separator cascade (["\n\n","\n",". "," ",""], target
max_chars, 10% overlap) transcribes 1:1:
# pyagentic/ingest.py — pure, engine-free (mirrors RecursiveTextChunker.java)
from dataclasses import dataclass
@dataclass(frozen=True)
class Chunk:
id: str; text: str; source_id: str; position: int; token_count: int
class RecursiveTextChunker:
SEPARATORS = ("\n\n", "\n", ". ", " ", "")
def __init__(self, max_chars: int, overlap_chars: int | None = None):
if max_chars <= 0: raise ValueError("max_chars must be positive")
self.max_chars = max_chars
self.overlap = max_chars // 10 if overlap_chars is None else overlap_chars
def chunk(self, source_id: str, text: str) -> list[Chunk]:
if not text: return []
merged = self._merge_overlap(self._split(text, 0))
return [Chunk(f"{source_id}::{i}", t, source_id, i, max(1, len(t)//4))
for i, t in enumerate(merged)]
# _split / _merge_overlap: direct transcription of the Java cascade.These are already the portable contract (keystone §4a). On Dask they are plain Protocols backed by Postgres/pgvector/Redis — exactly the existing backends, addressed over the network instead of via Flink keyed state. Dask workers are stateless; all durable state lives behind these Protocols.
from typing import Protocol, Optional
from pyagentic.llm import ChatMessage
class ConversationStore(Protocol): # mirrors ConversationStore.java
def append(self, conversation_id: str, message: ChatMessage) -> None: ...
def history(self, conversation_id: str) -> list[ChatMessage]: ...
def put_attribute(self, conversation_id: str, key: str, value: str) -> None: ...
def get_attribute(self, conversation_id: str, key: str) -> Optional[str]: ...
class VectorStore(Protocol): # the cold tier; pgvector/Qdrant
def upsert(self, id: str, embedding: list[float], text: str,
metadata: dict[str, str]) -> None: ...
def search(self, query: list[float], k: int) -> list["ScoredItem"]: ...The hot+cold merge (query both tiers, dedupe by id keeping the higher score, degrade gracefully if a tier fails) is pure logic. On Dask the "hot" tier is usually empty/irrelevant for offline eval (no live ingest stream), so you run it cold-only — but the class is identical to the Java one so a future live engine reuses it:
# pyagentic/retrieve.py — mirrors TwoTierRetriever.java, degradation included
class TwoTierRetriever:
def __init__(self, hot, cold, hot_k: int, cold_k: int):
self.hot, self.cold = hot, cold
self.hot_k, self.cold_k = max(1, hot_k), max(1, cold_k)
def retrieve(self, query, k: int):
best: dict[str, ScoredItem] = {}
for tier, kk in ((self.hot, self.hot_k), (self.cold, self.cold_k)):
if tier is None: continue
try:
for s in tier.search(query, kk):
p = best.get(s.id)
if p is None or s.score > p.score: best[s.id] = s
except Exception as e: # a dead tier degrades recall, not the query
log.warning("tier search failed (degrading): %s", e)
return sorted(best.values(), key=lambda s: s.score, reverse=True)[:max(1, k)]Dask realises the SPI for bounded collections, not live streams. source
becomes "read a bounded corpus", sink becomes "write results", and the unit of
parallelism is a partition of a bag, not a keyed operator:
import dask.bag as db
from dask.distributed import Client
class DaskRuntime:
"""Engine SPI (§4c) for the DATA PLANE only. No keyedAgent — see §5."""
def __init__(self, client: Client): self.client = client
def source(self, glob: str) -> db.Bag: # C6 (bounded)
return db.read_text(glob) # or read_parquet / read_sql
def map_stage(self, bag: db.Bag, fn) -> db.Bag: # C12 — a DAG node, map per element
return bag.map(fn)
def sink(self, bag: db.Bag, path: str): # C6 sink
bag.to_textfiles(path)
def broadcast(self, obj): # C8
return self.client.scatter(obj, broadcast=True)
def execute(self, *collections): # C11 — fan across the cluster
return self.client.compute(list(collections), sync=True)asyncStage (C4) is client.submit + as_completed; there is deliberately no
keyedAgent — that's the line §5 will not cross.
A ToolExecutor is params -> result. In a batch eval each tool/LLM call is a
submitted future; as_completed bounds in-flight work (C5, protecting the model
endpoint). This is the right home for "run 5 000 LLM-judge scorings in
parallel"; it is the wrong home for "one ReAct loop per live turn":
from dask.distributed import as_completed
def run_bounded(client, fn, items, max_in_flight=64): # C4 + C5
pending, results = set(), []
it = iter(items)
for x in (next(it, None) for _ in range(max_in_flight)):
if x is not None: pending.add(client.submit(fn, x))
for done in as_completed(pending):
results.append(done.result())
nxt = next(it, None)
if nxt is not None: as_completed.add(client.submit(fn, nxt)) # refill window
return resultsThe faithful, Dask-shaped worked example is not the live banking turn loop — it's the data plane that feeds it. Two pipelines, both pure-core logic wired onto Dask collections.
IngestionPipeline.from(pages).chunk(...).embed(...).into(corpus) becomes a
bag pipeline. Each Flink operator (chunk/embed/index) is one bag stage;
the embedder ships to every worker once via broadcast (C8); the cold index is
the VectorStore SPI (pgvector), written idempotently by chunk id (C3 retry-safe):
import dask.bag as db
from dask.distributed import Client, get_worker
client = Client("tcp://scheduler:8786") # C11: adaptive cluster
chunker = RecursiveTextChunker(max_chars=512) # §3a pure
embed_cfg = client.scatter(EmbedSetup(model="bge-small-en", dim=384), broadcast=True) # C8
def chunk_page(page): # CrawledPage -> [Chunk] (flatMap)
return chunker.chunk(page["url"], page["text"])
def embed_chunk(c, cfg): # Chunk -> EmbeddedChunk (ProcessFunction)
model = get_worker()._embed_cache(cfg.result()) # one model per worker, lazy (≈ open())
return {"id": c.id, "text": c.text, "vec": model.embed(c.text),
"meta": {"source_url": c.source_id}}
def index_chunk(e): # EmbeddedChunk -> IngestAck (idempotent upsert)
store = vector_store() # pgvector, addressed over the network
store.upsert(e["id"], e["vec"], e["text"], e["meta"])
return e["id"]
pages = db.read_text("s3://corpus/*.jsonl").map(json.loads) # C6 bounded source
acks = (pages
.map(chunk_page).flatten() # chunk
.map(embed_chunk, embed_cfg) # embed (broadcast cfg)
.map(index_chunk)) # into(corpus)
n_indexed = acks.count().compute() # C11: whole DAG fans across the clusterThis is the exact dataflow of the Java IngestionPipeline — chunk → embed →
upsert — except the partitioned operators are bag partitions and the scheduler
provides the parallelism Flink got from operator parallelism.
Run embed→search→(rerank)→answer over a labelled QA set, parametrised across configs, and produce a recall@k / nDCG matrix as a DataFrame. Each (query, config) is an independent task; the Cartesian product fans across the cluster:
import dask.bag as db, itertools, pandas as pd
queries = load_eval_set("eval/banking_qa.jsonl") # [{q, relevant_ids}]
configs = [{"k": k, "rerank": r} for k, r in
itertools.product([3, 6, 10], [False, True])] # parameter sweep
def eval_one(item):
q, cfg = item
retr = TwoTierRetriever(hot=None, cold=vector_store(), hot_k=cfg["k"], cold_k=cfg["k"]) # §3c
qvec = embedder().embed(q["question"])
hits = retr.retrieve(qvec, cfg["k"])
if cfg["rerank"]:
hits = cross_encoder().rerank(q["question"], hits) # pure RerankFn logic
got = {h.id for h in hits[:cfg["k"]]}
rel = set(q["relevant_ids"])
return {**{f"cfg_{k}": v for k, v in cfg.items()},
"recall_at_k": len(got & rel) / max(1, len(rel)),
"ndcg": ndcg(hits, rel)}
grid = db.from_sequence(list(itertools.product(queries, configs)))
report = pd.DataFrame(grid.map(eval_one).compute()) # C11 fan-out
print(report.groupby([c for c in report if c.startswith("cfg_")])
[["recall_at_k", "ndcg"]].mean()) # the benchmark matrixYou can express BankingAgentGraph's router→path→verifier as a per-row
delayed DAG and replay it over historical transcripts in parallel — for
regression eval, not live serving. Note the verifier's cross-turn phase
(BankingPhase via the ConversationStore/PhaseStore) means each transcript
must run its turns sequentially (state threads through), but transcripts are
independent — so parallelism is across transcripts, not within one:
import dask
@dask.delayed
def replay_transcript(transcript): # one independent unit of parallelism
store = InMemoryConversationStore() # per-transcript scratch (no shared live state)
out = []
for turn in transcript["turns"]: # sequential within a transcript
path = banking_router(turn, store) # rule-based classify (router)
result = path_brain(path).run(turn, store)# focused ReAct brain (path)
out.append(banking_verifier(result, store)) # advance phase, emit (verifier)
return {"id": transcript["id"], "outputs": out}
results = dask.compute(*[replay_transcript(t) for t in transcripts]) # parallel across transcriptsThis reuses the pure router/brain/verifier logic; the graph wiring is a Python loop, not a Flink topology. It's an offline harness, and it's honest about why: the live version needs C1+C2, which Dask doesn't give.
- Live keyed streaming agents (C2). No partitioned ordered ingest, no
keyBy, no rebalance, no replay.distributed.ActorperconversationIdtechnically gives single-writer ordered handling, but you'd hand-build the router (key→actor lookup), the actor lifecycle, and eviction — reinventing a worse Faust/Ray. Don't do this on Dask. - Durable keyed state (C1). Actor fields are in-memory and die with the
worker. Durability is only via the
ConversationStore/VectorStoreSPI (Postgres/Redis), with no checkpoint coordination — so no exactly-once on the agent path; you lean on idempotent tool/A2A calls (keystone §8). - Low-latency conversation. Dask's scheduler is tuned for throughput on a task graph, not millisecond per-event dispatch. Turn latency would be at the mercy of scheduler round-trips. Wrong tool.
- Streaming connectors (C6), event-time/windows (C9), CEP (C10). Flat
—. Dask sources/sinks are bounded; there is no time model or pattern engine. - Backpressure-as-topology (C5). You get scheduler-level concurrency caps
and
as_completedwindows, not end-to-end streaming backpressure protecting a live pipeline. - The inbound A2A proxy / outbound live connectors. Out of scope — those belong to the live engine (Quarkus proxy + Faust/Ray loop). Dask has no business terminating JSON-RPC/SSE.
The clean line: anything bounded and parallel → Dask; anything live, keyed, ordered, low-latency → not Dask.
Choose Dask when the workload in front of you is the data plane, and you already run (or will run) the live loop elsewhere:
- You have a large corpus to (re)ingest — crawl/chunk/embed/index 10⁵–10⁷
documents across a cluster, faster than a single-JVM Flink embedded run, with
free task-retry idempotency. The cold index it builds is then served by the
live engine via the same
VectorStoreSPI. - You run retrieval/agent eval sweeps — recall@k, nDCG, latency, LLM-judge scoring across a parameter grid and a dataset. This is Dask's strongest case here: embarrassingly parallel, bounded, throughput-bound, DataFrame-native reporting.
- You already live in the PyData stack — pandas/NumPy/Parquet, a
dask-kubernetescluster, scientists who think in DataFrames. Dask slots in with zero new infrastructure. - You want one heavy offline engine alongside a streaming one — the keystone recommendation: Dask for the data plane, Faust or Ray for the live agent plane, both sharing the pure core (§4a) and the durable SPIs.
Do not choose Dask as the home for the agentic essence (§2.1) itself. It hosts the parts of this project that are batch data engineering with an LLM in the loop — and for those it is excellent and idiomatic. For "a fleet of per-conversation stateful agents driven by events," look at Faust (#1) or Ray (#3) and let Dask be the data engine they stand on.