Skip to main content

Control flow

Beyond static edges and routers (Graph), three primitives cover the dynamic shapes real agents take. All three build on the journal, which is why the map-reduce, self-routing, and retry stories all stay free of double-effects.

PrimitiveSpecQuestion it answers
send§14Run one node many times in parallel, each with its own input.
command§15Let a node decide its own next hop after seeing its update.
retry§16Re-run a flaky node safely, without repeating committed effects.
import { graph, channel, START, END, send, command, run } from "@ilmek/core";
import type { ChannelMap, StateOf, RetryPolicy } from "@ilmek/core";

// A service that fails the first `failsFor` calls per key, then succeeds.
const attempts = new Map<string, number>();
function flakyUppercase(key: string, text: string, failsFor: number): string {
const n = (attempts.get(key) ?? 0) + 1;
attempts.set(key, n);
if (n <= failsFor) throw new Error(`503 on ${key} (attempt ${n})`);
return text.toUpperCase();
}

/** Shout state — words in, shouted results out, a round counter. */
const ShoutState = {
words: channel.lastWrite<string[]>([]),
shouted: channel.append<string>(),
rounds: channel.lastWrite<number>(0),
} satisfies ChannelMap;

type ShoutState = StateOf<typeof ShoutState>;

/** A fan-out worker's input is its OWN send payload, not the channel state. */
type Job = { word: string; failsFor: number };

const retry: RetryPolicy = { maxAttempts: 4, backoffMs: 5, factor: 2 };

const g = graph("shout", ShoutState)
.node("plan", (state) => ({})) // nothing to write; the router below fans out
// §16 retry — the worker retries its flaky call safely; completed steps are journaled
.node("worker", (job: Job, ctx) => ({ shouted: [flakyUppercase(job.word, job.word, job.failsFor)] }), { retry })
// §15 command — a node decides its own next hop, seeing the update it just wrote
.node("gate", (state) =>
state.rounds === 0
? command({ update: { rounds: 1, words: ["again"] }, goto: "plan" }) // loop once
: command({ goto: END })) // then finish
.edge(START, "plan")
// §14 send — one worker task per word, each with its OWN input
.router("plan", (state) => state.words.map((word, i) => send("worker", { word, failsFor: i })))
.edge("worker", "gate")
.compile();

const { status, state } = await run(g, { words: ["red", "green", "blue"] });
console.log(status, state.shouted); // "done" [ "RED", "GREEN", "BLUE", "AGAIN" ]

pnpm demo:mapreduce runs all three together.