Engine-agnostic multi-agent workflow orchestration with journaled replay/resume
Dependencies
moon add moonbitlang/workflowimport {
"moonbitlang/workflow",
"moonbitlang/async",
}
supported_targets = "native+wasm"| Package | Use it for |
|---|---|
| workflow | Calls, typed outcomes, concurrency, retries, replay, and accounting. |
| workflow/spawn | Running a process that speaks the child contract. |
| workflow/hosted | Running under a host that supplies child ids, launch limits, and journal paths. |
| workflow/shim | Building adapters from a foreign CLI's output to the child contract. |
| workflow/shim/claude | Executable adapter for Claude Code. |
| workflow/shim/codex | Executable adapter for Codex. |
| workflow/viz | Rendering a journal as a standalone HTML report. |
| workflow/examples/scout | A small executable demonstrating process calls and journal replay. |
flowchart TD
Program["Your async program"] --> Core["workflow<br/>calls and policies"]
Core <--> Journal["Journal<br/>resolved outcomes"]
Core --> Runner["Runner"]
Runner --> Fake["In-process engine<br/>or test fake"]
Runner --> Spawn["spawn<br/>child contract"]
Host["Host handoff"] --> Hosted["hosted<br/>ids and sidecar"]
Hosted --> Runner
Spawn --> Engine["Contract-speaking<br/>engine"]
Spawn --> Shim["shim/claude<br/>or shim/codex"]
Shim --> CLI["Agent CLI"]
Journal --> Viz["viz: HTML report"]| Setting or method | Meaning |
|---|---|
| max_concurrent | Maximum simultaneous runner invocations; default 8, minimum 1. |
| max_calls | Maximum live runner invocations in this workflow; omitted means unlimited, 0 allows replay only. |
| replay_scope | Extra identity string, default empty. Set it when work depends on a repository revision or configuration absent from the input. |
| agent(prompt~, kind~) | Sends {query, hints?}; the label defaults to a shortened prompt. |
| agent_call(kind~, input~, label~) | Sends the exact JSON shape a kind requires. |
| agent_as[T : FromJson](...) | Calls agent, then decodes the complete report as T. |
| calls_made() / calls_replayed() | Live runner invocations / outcomes served from prior journal entries. |
| tokens_spent() / cost_usd() | Usage from resolved live outcomes in this run. Historical replay usage is excluded. |
///|
async fn verdict_runner(call : @workflow.AgentCall) -> @workflow.AgentOutcome {
@async.pause()
let verdict : Json = if call.input is { "query": String(q), .. } &&
q.contains("lens=repro") {
{ "confirmed": false }
} else {
{ "confirmed": true }
}
Finished(
value=verdict,
attempt=@workflow.AgentAttempt(
attempt_id="sr-\{call.label}",
steps_used=3,
prompt_tokens=70,
completion_tokens=30,
),
)
}
///|
async test "fan out three lenses, gate on a 2-of-3 quorum" {
let wf = @workflow.Workflow(
runner=@workflow.Runner(verdict_runner),
max_concurrent=4,
max_calls=16,
)
wf.phase("Verify")
let results = @workflow.fan_out(["correctness", "security", "repro"], lens => {
@workflow.attempt(() => {
wf.agent(
kind="judge",
prompt="Judge the finding through lens=\{lens}: real?",
label="verify:\{lens}",
)
})
})
let confirmed = @workflow.quorum(results, need=2)
.filter(v => v is { "confirmed": True, .. })
.length()
assert_eq(confirmed, 2)
assert_eq(wf.calls_made(), 3)
assert_eq(wf.tokens_spent(), 300)
}| Policy | When to use it |
|---|---|
| parallel / fan_out with attempt | Keep a Result for each input, in input order. Typed workflow failures stay in their own slots. |
| parallel_all | Every branch is required; a raise cancels siblings still running. |
| all_ok | Require all already-collected results to succeed; raises the first error in array order. |
| collect_ok(min_ok=0) | Keep successes, optionally requiring a minimum count. |
| quorum(need~) | Require a count of successful calls; it does not compare answers for agreement. |
///|
async test "retry until the engine actually answers" {
let flaky : Ref[Int] = { val: 0, }
let wf = @workflow.Workflow(
runner=@workflow.Runner(call => {
@async.pause()
flaky.val 1
if flaky.val < 3 {
DidNotFinish(failure=NoReport, attempt=None)
} else {
Finished(
value={ "attempt": flaky.val },
attempt=@workflow.AgentAttempt(
attempt_id="sr-\{call.label}",
steps_used=1,
prompt_tokens=40,
completion_tokens=10,
),
)
}
}),
)
let report = @workflow.retry(
() => wf.agent(prompt="Name the worst bug in spawn/", kind="judge"),
max_retry=2,
)
assert_eq(report, { "attempt": 3 })
assert_eq(wf.calls_made(), 3)
}///|
async fn slice_runner(call : @workflow.AgentCall) -> @workflow.AgentOutcome {
@async.pause()
guard call.input is { "task": String(task), .. } else {
return DidNotFinish(failure=Failed("not a worker input"), attempt=None)
}
Finished(
value={ "status": "done", "task": task },
attempt=@workflow.AgentAttempt(
attempt_id="sr-\{call.label}",
steps_used=1,
prompt_tokens=10,
completion_tokens=5,
),
)
}
///|
async test "fan a worker kind out through its own input shape" {
let wf = @workflow.Workflow(runner=@workflow.Runner(slice_runner))
let slices = ["rename the seam", "widen the ceiling"]
let done = @workflow.fan_out(slices, task => {
@workflow.attempt(() => {
wf.agent_call(
kind="worker",
input={ "task": task, "worker_root": "/w" },
label="worker:\{task}",
)
})
})
assert_eq(@workflow.all_ok(done).length(), 2)
}///|
struct Confirmation {
confirmed : Bool
} derive(FromJson)
///|
async test "decode the report at the boundary" {
let wf = @workflow.Workflow(runner=@workflow.Runner(verdict_runner))
let verdict : Confirmation = wf.agent_as(
prompt="Judge the finding through lens=security: real?",
kind="judge",
schema={
"type": "object",
"properties": { "confirmed": { "type": "boolean" } },
"required": ["confirmed"],
},
)
assert_true(verdict.confirmed)
}///|
async test "find then verify, with no barrier between the stages" {
let wf = @workflow.Workflow(runner=@workflow.Runner(verdict_runner))
let verified = @workflow.fan_out(["pkg/a", "pkg/b"], target => {
let finding = @workflow.attempt(() => {
wf.agent(prompt="Find the worst bug in \{target}", kind="judge")
})
match finding {
// Each finding proceeds to verification the moment ITS finder
// returns — b's finder may still be running while a verifies.
Ok(_) =>
@workflow.attempt(() => {
wf.agent(
prompt="Adversarially verify the finding in \{target}",
kind="judge",
)
})
Err(error) => Err(error)
}
})
assert_eq(@workflow.all_ok(verified).length(), 2)
}///|
async test "the second generation replays instead of re-paying" {
let journal = @workflow.Journal::in_memory()
let wf1 = @workflow.Workflow(
runner=@workflow.Runner(verdict_runner),
journal~,
)
let first = wf1.agent(
prompt="Judge the finding through lens=security: real?",
kind="judge",
)
assert_eq(wf1.tokens_spent(), 100)
// Same program, next generation: served from the journal — no launch,
// no slot, no fresh spend.
let wf2 = @workflow.Workflow(
runner=@workflow.Runner(verdict_runner),
journal=@workflow.Journal::in_memory(prior=journal.recorded()),
)
assert_eq(
wf2.agent(
prompt="Judge the finding through lens=security: real?",
kind="judge",
),
first,
)
assert_eq(wf2.calls_made(), 0)
assert_eq(wf2.calls_replayed(), 1)
assert_eq(wf2.tokens_spent(), 0)
}flowchart TD
Call["Call identity: kind, input, max_steps, scope, schema"] --> Candidate{"Prior replay candidate?"}
Candidate -->|Success first, then Skipped| Validate{"Runner accepts candidate?"}
Validate -->|Serve| Replay["Return or raise without a launch"]
Validate -->|Rerun| Candidate
Candidate -->|None left| Slot["Acquire concurrency slot and check call budget"]
Slot --> Live["Invoke runner"]
Live -->|Resolved outcome| Record["Account usage and append journal entry"]
Live -->|Cancellation or infrastructure error| Raise["Re-raise; no outcome entry"]
Record --> Result["Return report or raise AgentFailed"]| Adapter | Default tool policy | Step definition | Report |
|---|---|---|---|
| shim/claude | Disallows listed editing and shell tools. | Distinct assistant message ids. | {answer, engine, session_id?, num_turns?, cost_usd?} |
| shim/codex | Requests the read-only sandbox. | Completed items excluding reasoning and notices. | {answer, engine, thread_id?, notices?} |
just check
just test
just tidypub suberror JournalCorrupted {
JournalCorrupted(path~ : String, line~ : Int)
}impl Show for JournalCorruptedpub suberror WorkflowError {
AgentFailed(label~ : String, failure~ : AgentFailure)
CallBudgetExhausted(label~ : String)
QuorumNotReached(need~ : Int, ok~ : Int, of~ : Int)
}impl Show for WorkflowErrorfn AgentAttempt::AgentAttempt(attempt_id~ : String, steps_used~ : Int, prompt_tokens~ : Int, completion_tokens~ : Int, cost_usd? : Double) -> AgentAttemptimpl Show for AgentFailurepub(all) enum AgentOutcome {
Finished(value~ : Json, attempt~ : AgentAttempt)
DidNotFinish(failure~ : AgentFailure, attempt~ : AgentAttempt?)
} derive(Eq, ToJson, FromJson)type Journalpub struct JournalEntry {
call : AgentCall
outcome : AgentOutcome
phase : String?
started_at : Int64?
finished_at : Int64?
} derive(Eq)impl ToJson for JournalEntryimpl FromJson for JournalEntryfn JournalEntry::JournalEntry(call~ : AgentCall, outcome~ : AgentOutcome, phase? : String, started_at? : Int64, finished_at? : Int64) -> JournalEntrytype Runnerfn Runner::Runner(run : async (AgentCall) -> AgentOutcome, validate_replay? : async (AgentCall, AgentOutcome) -> ReplayDecision) -> Runnertype Workflowfn Workflow::Workflow(runner~ : Runner, max_concurrent? : Int, max_calls? : Int, journal? : Journal, replay_scope? : String, on_event? : (WorkflowEvent) -> Unit) -> Workflow raisepub enum WorkflowEvent {
PhaseStarted(String)
Log(String)
AgentStarted(label~ : String, kind~ : String, phase~ : String?)
AgentFinished(label~ : String, disposition~ : AgentDisposition)
AgentReplayed(label~ : String, disposition~ : AgentDisposition)
}fn[T] collect_ok(results : Array[Result[T, WorkflowError]], min_ok? : Int) -> Array[T] raise WorkflowErrorasync fn[A, B] fan_out(items : Array[A], run : async (A) -> Result[B, WorkflowError]) -> Array[Result[B, WorkflowError]]async fn[T] parallel(thunks : Array[async () -> Result[T, WorkflowError]]) -> Array[Result[T, WorkflowError]]fn[T] quorum(results : Array[Result[T, WorkflowError]], need~ : Int) -> Array[T] raise WorkflowErrorasync fn[T] retry(attempt : async () -> T, max_retry? : Int, backoff? : RetryMethod, retriable? : (WorkflowError) -> Bool) -> TInstall
Download zipEngine-agnostic multi-agent workflow orchestration with journaled replay/resume
Dependencies