{"id":51389598,"url":"https://github.com/eumemic/agentfx","last_synced_at":"2026-07-03T22:08:13.715Z","repository":{"id":362827501,"uuid":"1260903224","full_name":"eumemic/agentfx","owner":"eumemic","description":"A typed effect system whose production interpreter is a durable distributed runtime (iii). One program, two interpreters, differentially tested.","archived":false,"fork":false,"pushed_at":"2026-06-06T05:00:29.000Z","size":65,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-06T06:12:45.724Z","etag":null,"topics":["agents","at-least-once","distributed-systems","durable-execution","effect-system","functional-programming","idempotency","iii","interpreter","type-safety","typescript"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/eumemic.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-06T02:22:23.000Z","updated_at":"2026-06-06T05:00:33.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/eumemic/agentfx","commit_stats":null,"previous_names":["eumemic/agentfx"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/eumemic/agentfx","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eumemic%2Fagentfx","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eumemic%2Fagentfx/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eumemic%2Fagentfx/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eumemic%2Fagentfx/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/eumemic","download_url":"https://codeload.github.com/eumemic/agentfx/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/eumemic%2Fagentfx/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35102814,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-03T02:00:05.635Z","response_time":110,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["agents","at-least-once","distributed-systems","durable-execution","effect-system","functional-programming","idempotency","iii","interpreter","type-safety","typescript"],"created_at":"2026-07-03T22:08:12.757Z","updated_at":"2026-07-03T22:08:13.666Z","avatar_url":"https://github.com/eumemic.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# agentfx\n\n**A small typed effect system whose production interpreter is a durable distributed runtime.**\n\nYou write one typed program from a tiny algebra. A *reference* interpreter (`runMemory`) runs\nit in-process — great for tests. A *distributing* interpreter (`runDist`) runs the same program\non [iii](https://iii.dev): an at-least-once durable queue with atomic state, so it survives\nprocess death. The two are differentially tested.\n\n```mermaid\nflowchart TD\n  prog[\"one typed program — the Effect algebra\"]\n  prog --\u003e walk[\"walk · one reified tree, one interpreter core\"]\n  walk --\u003e|runMemory| mem[\"in-process promise pool\u003cbr/\u003efast · for tests\"]\n  walk --\u003e|runDist| dist[\"iii backend\"]\n  dist --\u003e q[\"durable queue\u003cbr/\u003eat-least-once · DLQ · survives kill -9\"]\n  dist --\u003e st[\"atomic state\u003cbr/\u003eidempotent claim-once\"]\n  mem -.-\u003e|\"differentially tested: identical results\"| dist\n```\n\nThe thesis in a line: **a typed effect algebra on top, a durable runtime underneath, and the\nboundary between \"elegant\" and \"durable\" is the interpreter** — a fan-out that survives a\n`kill -9`, which a pure in-memory `Observable` never could.\n\n\u003e **Status: a working spike**, not production. The example \"LLM\" is a `setTimeout` and tasks are\n\u003e `toUpperCase`/`length` — the point is the control plane + the lowering, not inference. See\n\u003e **Limits** for exactly what's real.\n\n---\n\n## Two type laws (enforced by the compiler)\n\nChecked by `tsc` in [`src/laws.ts`](src/laws.ts) via `@ts-expect-error` — if a guard stopped\nfiring, the build would fail.\n\n**1. You cannot `retry` a non-idempotent effect** (the cold-resubscribe / double-charge footgun\nis a *compile error*). Only `task(...)` mints the `Replayable` brand; `flatMap` strips it.\n\n```ts\nretry(charge.effect({ amt: 10 }), 3); // ✓ a task is Replayable\nretry(succeed(5), 3);                 // ✗ compile error: not Replayable\n```\n\n**2. You cannot run an effect without supplying its capabilities** `R`; `provide` rejects keys\nthat aren't required.\n\n```ts\nrunMemory(prog, { llm, db }); // ✓\nrunMemory(prog, { llm });     // ✗ compile error: missing `db`\n```\n\n## One program, two interpreters\n\n```ts\nimport { flatMap, forEachTask, map, runMemory, runDist, makeIIIBackend } from \"agentfx\";\n\nconst program = flatMap(\n  forEachTask(items, upper, 3),                              // type-safe distributable fan-out\n  (uppers) =\u003e map(forEachTask(uppers, lengthOf, 2),\n                  (lens) =\u003e ({ uppers, totalLen: lens.reduce((a, b) =\u003e a + b, 0) })),\n);\n\nawait runMemory(program, {});                  // in-process promise pool — instant, for tests\nawait runDist(program, {}, makeIIIBackend());  // iii: durable queue — crash-surviving\n```\n\n[`ex-differential.ts`](src/ex-differential.ts) runs both on the same program: the happy path is\nbyte-identical, and a throwing task surfaces a typed `fail()` under **both** (no hang, no\nunhandled rejection — the error *shapes* differ by design: `runMemory` returns the raw cause,\n`runDist` an aggregated batch error). [`ex-durability.ts`](src/ex-durability.ts) survives an\nexecutor `kill -9` mid-batch.\n\n## The algebra\n\n| combinator | meaning |\n|---|---|\n| `succeed` / `failWith` | pure success / typed failure |\n| `flatMap` / `map` | sequence (unions `R` and `E`; strips `Replayable`) |\n| `catchAll(e, h)` | recover from a typed failure with another effect |\n| `retry(e, n)` | re-run — **requires `Replayable`** |\n| `provide(e, layer)` | discharge capabilities from `R` |\n| `task(fnId, keyOf, impl)` | a distributable, idempotent unit; `impl` gets a `TaskCtx.idempotencyKey` |\n| `forEachTask(items, task, n)` | type-safe distributable fan-out (children guaranteed `Remote`) |\n| `forEachPar(items, f, n)` | generic fan-out; **`runDist` requires `task` children** (closures don't serialize → it throws) |\n\n## Typing the wire (optional): `schemaTask`\n\nThe cross-worker boundary is stringly-typed by default. `schemaTask` closes that with one\n[zod](https://zod.dev) schema that becomes three things at once:\n\n- **static types** — `In`/`Out` are inferred via `z.infer`, so callers are compile-time checked\n- **a published JSON Schema** — sent to the engine as `request_format`/`response_format`; read it\n  back with `iii trigger engine::functions::info --json '{\"function_id\":\"agentfx::greet\"}'`\n  (discoverable by the console and LLM tool-use)\n- **runtime validation** — a bad payload is rejected at the executor and rides the `fail()`\n  channel as a `ZodError`\n\n```ts\nconst greet = schemaTask(\n  \"agentfx::greet\",\n  { input: z.object({ name: z.string().min(1), times: z.number().int().min(1).max(5) }),\n    output: z.string() },\n  async ({ name, times }) =\u003e Array.from({ length: times }, () =\u003e `hi ${name}`).join(\" \"),\n);\n\ngreet.effect({ name: \"ada\", times: 3 });       // ✓ typed\ngreet.effect({ name: \"ada\", times: \"lots\" });  // ✗ compile error (inferred from the schema)\n```\n\n`npm run schema` runs it live. (Caveat: the engine stores the schema under `request_schema` in\n`functions::info`; the `iii trigger \u003cfn\u003e --help` view doesn't surface it yet — a CLI display gap.)\n\nA contract violation is just a typed failure, so `catchAll` recovers it into a branch —\n`catchAll(parseAmount.effect(s), () =\u003e succeed(0))` turns a bad parse into a fallback,\nidentically under both interpreters (`npm run catch`). The recovered value matches across\nbackends; the raw error *shape* differs by design (structured `ZodError` in-process, a message\nstring over the wire).\n\n## Types from other workers' contracts (any language)\n\n`schemaTask` types the surface *you* author. But you also call functions implemented by *other*\nworkers — maybe in Python or Rust. Those workers declare their own contracts\n(`request_format`/`response_format`), which the engine stores. `npm run gen` walks the live\nregistry and writes `src/contracts.generated.ts` — a typed `Contracts` map of every declared\nfunction, regardless of language. `remote(fnId)` is statically typed from it:\n\n```ts\n// pymath::add is implemented in PYTHON (pymath_worker.py); its contract is declared there.\nconst r = await runDist(remote(\"pymath::add\")({ a: 2, b: 3 }), {}, be);\n//        remote(\"pymath::add\") : (input: { a: number; b: number }) =\u003e Effect\u003c…, { sum: number }\u003e\n//        r.value is typed { sum: number } — derived from the Python worker, not hand-written.\n\nremote(\"pymath::add\")({ a: 1, b: \"two\" }); // ✗ compile error (b: number, from the Python contract)\n```\n\nThe engine is the IDL: a worker's contract flows to the consumer in *its* preferred typed\nlanguage. That's the polyglot-substrate property made concrete — you work in TypeScript and\nstill call anything in any language, type-safe. `npm run gen` regenerates against whatever's\nrunning. (`remote()` calls run under `runDist` only — there's no in-process impl for another\nworker's function.)\n\n## A real agent (Claude + a polyglot tool)\n\nThe point of all the machinery: a real LLM agent whose tools are iii workers — in any language —\nwith durable, preemptible turn semantics. `ex-agent.ts` runs Claude (`claude-opus-4-8`) in a ReAct\nloop whose `add` tool is the **Python** `pymath::add` worker, invoked through the typed `remote()`\nclient:\n\n```\nClaude → tool call `add` → remote(\"pymath::add\") → runDist → iii engine → Python worker → result\n```\n\n```bash\nnpm run agent          # \"what is 21 + 21, then add 100?\" → calls the Python tool twice → 142\nnpm run preempt-agent  # a new message mid-turn preempts the in-flight turn\n```\n\n`ex-preempt-agent.ts` answers the question this repo kept circling: **does preemption compose with\na real, multi-step LLM turn?** A new user message arrives mid-turn; the in-flight inference is\naborted (soft — saves tokens) and every tool call is gated on the epoch fence (hard). Result: only\nthe latest message's tool fires; the superseded turn is fenced out. The fence sits at the tool\nboundary, so it holds regardless of how long or variable the real inference is.\n\nNeeds `ANTHROPIC_API_KEY` (and optional `ANTHROPIC_BASE_URL`) plus the `pymath` worker running\n(`python pymath_worker.py`). The model call is non-streaming with adaptive thinking and an\n`AbortSignal` for preemption.\n\n## A durable agent loop (aios-style) — the loop IS the queue\n\n`ex-agent.ts` above runs the ReAct loop **in the driver** — a normal `for`-loop in one process.\nKill that process mid-turn and the turn is gone. [`src/harness/`](src/harness/) re-expresses it the\nway [aios](https://github.com/eumemic/aios) does: **there is no loop.** An append-only event log is\nthe source of truth, a step function is re-entered by durable wake jobs, and a driver crash mid-turn\nresumes from the log. The agent's reasoning loop itself is durable.\n\n| aios concept | here, on agentfx + iii |\n|---|---|\n| append-only session event log | per-event keys in file-backed `iii-state` ([`log.ts`](src/harness/log.ts)); monotonic seq via atomic `state::update increment` |\n| the \"loop\" is a job queue re-entering a step | `wake` jobs on the iii durable queue → `agentfx::harness::step` ([`worker.ts`](src/harness/worker.ts), [`step.ts`](src/harness/step.ts)) |\n| `reacting_to` watermark; status derived from the log | [`sweep.ts`](src/harness/sweep.ts): `needsInference` = ∃ stimulus with `seq \u003e` the max assistant `reacting_to`. No status column. |\n| every tool is async; the model stays responsive | tools are fire-and-forget durable-queue jobs ([`tools.ts`](src/harness/tools.ts)); the step returns without awaiting them |\n| no compaction — windowing, not summary | [`window.ts`](src/harness/window.ts): turn-aware, drop-from-front, cache-stable prefix |\n| tool result IS the idempotency record | dedup on `tool_result` existence + a per-worker in-flight guard |\n\nThe model is an injected `Model` interface, so the same harness runs a deterministic stub (for the\ncrash proof) or real Claude:\n\n```bash\n# start the engine first:  (cd ../quickstart \u0026\u0026 iii --config config.yaml)\nnpm run harness:durable    # spawns a worker, sends \"21+21 then +100\", HARD-KILLs it mid-turn\n                           #   (process-group kill, first tool in flight), spawns a second worker →\n                           #   the queue redelivers the in-flight job, the loop RESUMES from the log\n                           #   and finishes 142 on the new worker. PASS prints the w1→w2 hand-off.\nnpm run harness:claude     # the same durable loop driven by real Claude (claude-opus-4-8), whose\n                           #   `add` tool is the Python pymath::add worker. Needs ANTHROPIC_API_KEY\n                           #   (+ ANTHROPIC_BASE_URL) and pymath_worker.py running.\nnpm run harness:worker -- --model stub|claude   # run a standalone (killable) worker\n```\n\nThe crash proof's log makes the hand-off explicit — each event records which worker wrote it:\n\n```\n#1 [client] user: what is 21+21, then add 100?\n#2 [w1]     assistant tools=[add(21,21)]   ← w1 starts the turn, then is HARD-KILLED mid-tool\n#3 [w2]     tool_result add → 42           ← w2 resumes from the file-backed log…\n#4 [w2]     assistant tools=[add(42,100)]\n#5 [w2]     tool_result add → 142\n#6 [w2]     assistant \"142\"                 ← …and finishes. The loop is the queue.\n```\n\n**Scope** — a faithful core, not all of aios: single-session; the model is injected; no\nmulti-channel / memory-stores / sandbox / permissions. **Honest limits:** durability is across\n*worker* crashes (the in-memory `builtin` queue redelivers; an *engine* restart loses in-flight\nwakes — the file-backed log survives, but resuming would need a startup recovery sweep, not built).\nCross-worker concurrency on one session is a non-goal (the dedup guards are per-worker). At-least-once\nmeans a crash between a tool's side effect and its result event re-runs the tool — `ToolImpl` gets\n`ctx.idempotencyKey` (the `toolCallId`) so a real tool can dedupe. A model call that errors past a\nbounded retry records a durable error event instead of spinning. Append is two ops (claim seq, then\nwrite), so a crash between them leaves a benign seq *gap* — `readLog`/`sweep` tolerate gaps.\n\n## How `runDist` lowers to iii\n\n| node | `runMemory` | `runDist` (iii) |\n|---|---|---|\n| `task().effect()` standalone | local impl | **direct `w.trigger`** (a non-durable RPC) |\n| `forEachTask` / `Par` of tasks | promise pool, concurrency `n` | durable queue: wave-throttled to `n`, at-least-once, crash-surviving, failures surfaced |\n| `retry` | loop | loop (each attempt re-runs the child) |\n| `flatMap` / `map` / `catchAll` / `provide` | in-process | in-process (driver) |\n\n\u003e Preemption (`switchMap` → an atomic *epoch fence*, so only the latest input's tool fires) is a\n\u003e **separate stream-level lowering** in [`src/demo.ts`](src/demo.ts) + [`src/iii.ts`](src/iii.ts),\n\u003e not an `Effect` combinator. Run it with `npm run preempt`.\n\n## Run it\n\n```bash\nnpm install\n# start an iii engine with iii-state + iii-queue workers:  iii --config config.yaml\nnpm run executor       # terminal A: registers tasks + the batch subscriber (stays running)\nnpm run differential   # terminal B: happy path identical + failure path surfaced\nnpm run durability     # terminal B: fans out 12 tasks — now `pkill -9 -f ex-executor` in\n                       #             terminal A mid-run; redelivery still finishes all 12\nnpm run typecheck      # the type laws are the test\n```\n\n## Design notes\n\n- **Reified, not final.** `Effect\u003cR,E,A\u003e` is a tagged-union *data* tree, so interpreters can\n  walk it. TS has no GADTs, so the tree erases intermediate types (`FlatMap`/`Par`/`CatchAll`);\n  that erasure is contained to the constructors in `effect.ts`. (This is why Effect-TS chose\n  fibers/tagless-final — the trade-off is real and named.)\n- **Closures don't distribute.** `runDist` only fans out `task` effects (a registered `fnId` +\n  serializable args), never arbitrary closures — same reason RPC can't ship a lambda.\n- **Backend is pluggable.** `runDist` targets a `Backend` interface; `runtime-iii.ts` is one\n  implementation. The algebra doesn't know about iii.\n\n## Limits (honest)\n\n- Example tasks are pure; the \"LLM\" is a timer. Wire a real model into a `task` and it works.\n- **Durability is across consumer crashes** (the engine holds messages, redelivers on restart).\n  The default iii `builtin` broker is in-memory, so it is **not** durable across an *engine*\n  restart — that needs a persistent queue adapter.\n- **At-least-once, not exactly-once.** A crash *after* a side effect but *before* its result is\n  recorded re-runs the task on redelivery. `task` impls get `ctx.idempotencyKey` precisely so a\n  real side-effecting impl can dedupe at its provider; pure tasks need nothing.\n- A standalone `task().effect()` under `runDist` is a **direct (non-durable) call**; durability\n  applies to `forEachTask`/`Par` batches.\n- The preemption fence's correctness depends on the iii engine's `state::update` being\n  linearizable and returning a consistent atomic pre-image; demonstrated, not formally verified.\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Feumemic%2Fagentfx","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Feumemic%2Fagentfx","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Feumemic%2Fagentfx/lists"}