fabric

Fabric runs bounded, typed LLM agents whose runs can pause durably, wait for a human, survive a restart, and be cancelled at any point.

let runs = store.in_memory(process.new_name("runs"))  // or store.directory
let assert Ok(Nil) = store.start(runs)   // or store.supervised(runs)
let assert Ok(agent) =
  agent.new("desk", model, [weather_tool, transfer_tool], my_policy)
  |> agent.with_approvers(desk_approvers)
  |> agent.build
let assert Ok(handle) =
  fabric.start(runs, agent, id: run.new_id(), context:, prompt: "Pay Bob",
    correlation: None)
case fabric.await(handle, within: duration.seconds(5)) {
  Ok(run.Suspended([pending, ..], _)) -> {
    // `desk_approvers` verify the reviewer's token (`fabric/approvers`)
    let assert Ok(proof) =
      approvers.check(desk_approvers, token, pending.reference.requirement)
    fabric.approve(handle, pending.reference, proof:,
      context: current_context)
  }
  ...
}

A run ends with the agent’s answer: its model’s text, or the value its answer codec decodes (agent.with_answer), so Run, run.Status and run.Snapshot carry the answer type. await and every command return the run’s status.

Every function returns the one Error type. Branch on error_kind (NotFound, Refused, Retry, Unavailable, Incompatible) and log with describe_error; match a variant only where it decides something, such as AlreadyStarted.

A run’s record lives in a store, and a run is named by its run.RunId, which the caller chooses: run.new_id(), or run.parse_id of an application key (a job id) so that a retried start finds the run it started (AlreadyStarted). A string from outside becomes an id only through run.parse_id. A runner process exists only while a model call or a tool is in flight; a suspended or finished run has no process. Commands (approve, reject, cancel, reconcile) go to the live runner, or are applied to the stored record when there is none, and a runner is started if the command produced work.

Any process can hold a handle. open rebuilds one from a run id without taking anything over: the handle a request handler uses. recover takes over work whose runner is gone: on an unleased store, use it at boot, never on a run another process may still be driving; on a leased store (store.leased, several nodes sharing one database) it leaves a live foreign lease alone. It can also recover this store’s own run whose runner is gone, so it is safe at any time. Runners run under their store’s subtree (store.supervised). When the application stops, each drains and hands its run off, and recover goes on with it after the restart with nothing uncertain.

A delegation (agent.with_sub_agent) starts a sub-agent run in the same store, behind the same policy gate as a tool. The family is read together: a child’s pending approvals and uncertain effects are the parent’s (their references name the child run), approve, reject and reconcile route by the reference, child opens a child’s handle, cancelling a parent cancels its children, and recovering a parent recovers its children.

A run’s context is a live value, never stored: the one given to start, open or recover, held by the handle and its runner. An approved action is the exception: it runs with the context its answer was checked with (see approve).

After a restart, recover reopens a stored run under the same agent. If work was in flight when its runner was lost, recovery takes it over as a new incarnation: tools that had started become uncertain effects, which are never retried and must be reconciled; queued tools and a lost model call are started again, except approved ones, which ask for their approval again. “Never retried” holds as long as the store keeps every commit it acknowledged: the directory store does not flush the directory entry, so after an operating-system crash or power loss (not a process or VM crash) the latest revisions may be missing, and a tool whose start was among them could run again. The directory store is for development, tests, and one host; production uses a database backend (store.new).

Types

What await_with ended with.

pub type Awaited(answer, message) {
  Reached(run.Status(answer))
  Interrupted(message)
}

Constructors

  • Reached(run.Status(answer))

    The run’s status, as await returns it.

  • Interrupted(message)

    A message of the caller’s selector arrived first. The wait changed nothing: the run goes on, and the caller decides (for example cancel).

Why a call to Fabric failed: one type for every function of this module. Branch on error_kind and log with describe_error; this union may grow, so match a variant only where it decides something (keep a catch-all).

pub type Error {
  AlreadyStarted(id: run.RunId, same_input: Bool)
  StartUnconfirmed(id: run.RunId, reason: String)
  FamilyBudgetUnsupported
  HistoryUnsupported
  RunNotFound
  StoreUnavailable(reason: String)
  UnsupportedVersion(found: Int)
  CorruptRecord(detail: String)
  IncompatibleAgent(List(run.Incompatibility))
  RunEnded
  RunNotFinished
  WrongReference
  StaleReference
  AlreadyAnswered
  ApprovalExpired
  RequirementChanged(run.PendingApproval)
  ProofRefused(approvers.ProofError)
  NotReconcilable
  RunUnattended
  RunnerBusy
  Contended
}

Constructors

  • AlreadyStarted(id: run.RunId, same_input: Bool)

    A run with this id is already stored: this start stored nothing. A start retried with the same id (by a job delivered again, say) gets this once the first start landed; open the run to read or command it, or recover it to take over its work. same_input says whether the stored run was started by the same agent with the same original input, so that a retry can tell its own run from another start that reused the id. Live context and retry correlation are not compared. When the id is taken but the stored run cannot be read, the start returns the read’s error instead: StoreUnavailable (start again to compare), or UnsupportedVersion or CorruptRecord (the id holds a record this Fabric cannot read).

  • StartUnconfirmed(id: run.RunId, reason: String)

    The store did not confirm the run’s first record, so its outcome is unknown: the backend may still store it later, as the run id with work in flight and no runner (await then reports Unattended). Starting again with the same id is safe: it is AlreadyStarted if the first start landed. cancel_stored(store, id) ends such a run instead.

  • FamilyBudgetUnsupported

    The agent declares a family budget (agent.with_family_budget) and the store writes agent records older than version 7, which cannot hold one. Nothing was stored.

  • HistoryUnsupported

    Imported history requires record writer version 8. Nothing was stored.

  • RunNotFound
  • StoreUnavailable(reason: String)

    The store failed. A write it reported unavailable has an unknown outcome: the backend may still perform it later.

  • UnsupportedVersion(found: Int)

    The record was written by a Fabric version this one cannot read.

  • CorruptRecord(detail: String)
  • IncompatibleAgent(List(run.Incompatibility))

    The run cannot continue under this agent.

  • RunEnded

    The run has finished (completed, failed, or cancelled); execution cannot continue. Terminal evidence can still be settled through reconcile_stored and settle_stored. A pending approval is void. Also returned for a sub-agent run one of whose ancestors is stopping or has ended: cancelling an ancestor wins over answering or reconciling its descendants.

  • RunNotFinished

    Terminal evidence commands require a finished run. Cancel or finish it first; these commands never stop or resume execution themselves.

  • WrongReference

    No approval request or eligible action of this run matches the reference (the current batch, or history for terminal settlement).

  • StaleReference

    The action’s approval request has another revision or requirement.

  • AlreadyAnswered

    This approval request was already answered.

  • ApprovalExpired

    The approval request’s deadline passed before this answer (agent.with_approval_expiry): it expired, its action was rejected, and the model sees that. The run goes on without the action.

  • RequirementChanged(run.PendingApproval)

    The current policy now requires another approval for the action; the answer was not applied. Answer the new request, with a proof checked for its requirement.

  • ProofRefused(approvers.ProofError)

    The answer’s proof is not one this run’s approvers accept for the request: the agent has none, they did not make it, it was checked for another requirement, or it is too old (see fabric/approvers). Nothing was changed.

  • NotReconcilable

    The action is not an uncertain effect, or the run is in a phase that accepts no reconciliation (for example while the model is called).

  • RunUnattended

    The command is valid for the stored record, but work is in flight and no runner known to this store drives it: its runner was lost, or the run is driven through another Store (possibly in another VM). Nothing was changed. cancel never needs a runner.

  • RunnerBusy

    The run’s runner did not take the command within the agent’s command timeout (agent.with_command_timeout): a synchronous telemetry handler holds it, or the command was sent from such a handler running in the run’s own runner. Nothing was changed, and the command will not be applied later. Try again, or route Fabric’s events through a forwarder (see fabric/telemetry). cancel and cancel_stored never return it: they commit the cancellation to the record instead.

  • Contended

    The command lost every retry against concurrent commits.

A stable classification of Error, for callers that decide by kind.

pub type ErrorKind {
  NotFound
  Refused
  Retry
  Unavailable
  Incompatible
}

Constructors

  • NotFound

    The run or the action the reference names does not exist: RunNotFound, WrongReference.

  • Refused

    The run’s state refuses the request; trying it again unchanged does not help: AlreadyStarted, RunEnded, RunNotFinished, StaleReference, AlreadyAnswered, ApprovalExpired, RequirementChanged, ProofRefused, NotReconcilable.

  • Retry

    A transient conflict: the same call may succeed soon (RunnerBusy, Contended).

  • Unavailable

    The store or the run’s runner cannot be reached, and a write’s outcome may be unknown: StartUnconfirmed, StoreUnavailable, RunUnattended.

  • Incompatible

    The record and this Fabric, agent or store do not fit: UnsupportedVersion, CorruptRecord, IncompatibleAgent, FamilyBudgetUnsupported, HistoryUnsupported.

A handle on one run, for the agent and context it was started, opened or recovered with; answer is the agent’s answer type (agent.with_answer). It holds no process: it can be dropped and rebuilt with open.

pub opaque type Run(context, answer)

Values

pub fn approve(
  run: Run(context, answer),
  reference: run.ApprovalRef,
  proof proof: approvers.Proof,
  context context: context,
) -> Result(run.Status(answer), Error)

Approves an approval request of the run or of one of its sub-agents (the reference names the run). context is the application’s current context: the policy is checked again with it, and a current denial or policy failure wins over the approval; a policy that now requires another approval refuses it with RequirementChanged. The approved action runs with exactly this context, whether or not a runner was live: the tool body receives it, and an approved sub-agent start starts the child run with it. It does not become the run’s context: the run’s other actions keep the context it was started or recovered with. If the run’s runner is lost before the approved action starts, recovery asks again (see recover).

Works with no process holding the run: the answer is committed to the stored record with compare-and-set, so of concurrent answers exactly one wins and the others get AlreadyAnswered (or RunEnded after a cancel). The caller then emits the commit’s events before approve returns (see fabric/telemetry); the approved work has already started.

An answer to a sub-agent run whose ancestor is stopping or has ended is refused with RunEnded, even if the sub-agent’s own cancellation has not been committed yet. An answer checked just before an ancestor’s cancellation was committed may still commit, but nothing it approved starts: a sub-agent run checks its ancestors again before each tool or sub-agent start, and one that finds an ancestor stopping or ended starts nothing and cancels itself.

An answer after the request’s deadline (agent.with_approval_expiry) is refused with ApprovalExpired: the request expires instead, its action is rejected, and the run goes on.

proof says who answers: approvers.check made it for the request’s requirement (reference.requirement) with the approvers of the agent whose run issued the request (agent.with_approvers). Any other proof, or one older than the proof lifetime, is refused with ProofRefused before the request is checked. The reviewer it names and the approvers’ name are recorded with the answer (run.Approval.reviewer, run.Approval.verifier).

pub fn await(
  run: Run(context, answer),
  within within: duration.Duration,
) -> Result(run.Status(answer), Error)

Blocks until the run is no longer Working, or until within passes, and returns its status: Working when the time ran out, Suspended or Finished, or Unattended when work is in flight but no runner known to this store drives it. On an unleased store, Unattended means the runner was lost or handed the run off at shutdown, or the run is driven through another Store; only the application knows which, and recover takes the run over, so call it only when the previous owner is known to be gone. On a leased store a run whose lease is live elsewhere is Working. Unattended means its lease is free or expired, belongs to an earlier process of this store, or belongs to this store with no runner: recover takes it over.

It wakes on commits made through this run’s store and when a runner exits; on a leased store it also reads the run again at least every third of the lease (at most every second), since another node’s commits wake nothing here. If the store’s process stops meanwhile, it waits, until within runs out, for a supervisor to register the next one, and goes on through it; that process knows no runner, so work in flight then reads Unattended. With no store process running when it starts, or none registered again in time, it is StoreUnavailable. A run whose sub-agents work is working; one waiting only on paused sub-agents is suspended on their approvals. A suspended run with an approval request whose deadline passed is not returned as is: the request is rejected first, and the wait goes on with the run. A within of zero (or less) reads the status now; one longer than the runtime’s longest timer (2^32 - 1 ms) is waited in parts.

To wait for the run or for something else at once (a caller’s cancellation, a shutdown message), use await_with.

pub fn await_with(
  run: Run(context, answer),
  within within: duration.Duration,
  or interrupt: process.Selector(message),
) -> Result(Awaited(answer, message), Error)

await, ending early when or receives a message: one receive waits for the run and for the caller’s own messages, so a handler needs no helper process. The message is returned as Interrupted(message) and is consumed; the run is left as it is. A request handler that must stop the run when its caller goes away selects on that signal:

let cancelled = relay_tool.cancelled(call)   // a process.Selector(Nil)
case fabric.await_with(handle, within: duration.seconds(30), or: cancelled) {
  Ok(fabric.Interrupted(Nil)) -> {
    let _ = fabric.cancel(handle)
    Error(Cancelled)
  }
  Ok(fabric.Reached(run.Finished(outcome))) -> Ok(outcome)
  Ok(fabric.Reached(_)) -> Error(NotFinished)
  Error(error) -> Error(Unreadable(error))
}

The selector is checked whenever the wait blocks, and a message already queued for it wins over a status read in the same moment only if it arrived first.

pub fn cancel(
  run: Run(context, answer),
) -> Result(run.Status(answer), Error)

Cancels a run that is active, suspended, or whose runner was lost, and its sub-agent runs through the store. A stopped tool bound with tool.bind_settling is waited for, up to its bound, until its late settlement records what happened. A sub-agent’s cancellation that fails on a store error is tried again with a bounded backoff; one that still fails makes the delegation an uncertain effect, and the sub-agent then accepts no answer or reconciliation (see RunEnded). Cancelling a run that is still stopping asks its sub-agents to cancel again. A sub-agent run that was never stored is stored as cancelled before it started, so a start or a recovery racing the cancellation never runs it. Running tools are stopped and recorded as uncertain effects, never retried; queued actions and pending approvals are recorded as not started. Returns the status right after the cancellation was committed: Working while tools are being stopped or sub-agents cancelled, then Finished(Cancelled) (see await).

Through a Store that does not drive the run (another Store over the same backend), the cancellation is committed to the record at once and reported Finished(Cancelled), but the owner’s tool bodies keep running until its runner next tries to commit and stops; they are recorded as uncertain effects. A runner that does not take the cancellation within the command timeout (a synchronous handler holds it) has its work abandoned in the record the same way, and is then killed, with its model call and running tool bodies (recorded as uncertain effects): it calls no model, starts no tool body or sub-agent, and commits nothing after the cancellation. A handler that cancels the run whose runner it runs in cannot kill that runner; the runner calls no model and starts no sub-agent once its record moved on, and commits nothing more. A sub-agent is cancelled the same way; its delegation becomes an uncertain effect only when the store keeps failing.

pub fn cancel_stored(
  store: store.Store,
  id: run.RunId,
) -> Result(run.Status(String), Error)

Cancels the stored run id with no agent: for a run that cannot be recovered because its agent changed (another identity, or a pending tool that no longer exists). Cancelling a sub-agent run this way does not apply its end to its parent, which needs its agent to map it: recover the parent to apply it (await on the parent reports Unattended until then). A run whose runner is live in this store is cancelled through that runner, as cancel would, which must take it within 5 seconds (there is no agent to configure it); otherwise (a lost runner, or one a handler holds) the work of a lost runner is abandoned (running tools become uncertain effects) and the run ends Cancelled in one commit. Active sub-agent runs are cancelled first, the same way; their delegations are recorded as uncertain effects, since no agent maps their outcome. A sub-agent that is still stopping (it waits for a stopped tool’s settlement) is recorded as such, and ends on its own. A sub-agent run that was never stored is stored as cancelled before it started (naming no agent), and its delegation is recorded as not started.

pub fn cancel_when_down(
  run: Run(context, answer),
  owner owner: process.Pid,
) -> Nil

Cancels the run when owner stops, for a run that must not outlive the process that asked for it: a request handler, a connection, a Relay tool call. A watcher process monitors owner and cancels the run as soon as owner exits, for any reason (also when it had already exited); it then stops. It also stops, cancelling nothing, once the run has finished. A suspended run is checked for its end every 5 seconds.

The watcher is not linked to the caller and is not supervised: a VM that stops takes it with it, and the run is then recovered like any other. Call it once per owner; each call adds one watcher.

pub fn child(
  run: Run(context, answer),
  id: run.RunId,
) -> Result(Run(context, String), Error)

Opens the sub-agent run id, a descendant of run, with run’s agent and context: its snapshot, reconciling its uncertain effects, answering or cancelling it. Its end still reaches its parent. Its answer reads as the text it stored (its delegation reads it with the child’s own answer codec).

pub fn describe_error(error: Error) -> String

One line for logs.

pub fn error_kind(error: Error) -> ErrorKind
pub fn generated_messages(
  handle: Run(context, answer),
) -> Result(List(model.Message), Error)

Messages appended by this run, in exact stored order, including tool calls, results, answer corrections and opaque provider replay data.

pub fn id(run: Run(context, answer)) -> run.RunId
pub fn matches_initial(
  handle: Run(context, answer),
  input: input.Input,
) -> Result(Bool, Error)

Whether this run’s complete original input equals input. Generated messages, live context and retry correlation do not participate.

pub fn open(
  store: store.Store,
  agent: agent.Agent(context, answer),
  context: context,
  id: run.RunId,
) -> Result(Run(context, answer), Error)

Opens the stored run id under agent and context, for a caller that did not start it: a request handler reading, awaiting, answering, reconciling or cancelling a run. It only reads the record and checks that the run can continue under agent (IncompatibleAgent otherwise); it never takes the run over and starts nothing, so a live runner, in this store or behind another Store, keeps its work.

Commands through the handle behave exactly as through the handle start returned: each is checked against the stored record, one that produces work with no runner live in this store starts a runner, and one that needs the live runner of another Store is RunUnattended. A sub-agent run is reached through its root’s handle (child).

recover takes over work in flight. Leased stores permit it at any time; on an unleased store the previous owner must be known to be gone.

pub fn pending(
  run: Run(context, answer),
) -> Result(List(run.PendingApproval), Error)

The approval requests waiting for an answer: the run’s own, oldest first, then its sub-agents’. They can be answered while other work still runs.

pub fn reconcile(
  run: Run(context, answer),
  effect: run.ActionRef,
  content: String,
) -> Result(run.Status(answer), Error)

Records what actually happened for an uncertain effect of the run or of one of its sub-agents (UncertainAction.reference names both the run and the action; it is routed through the family like approve). content is what the model will see as that call’s result. When nothing else is pending the run continues with its next model turn. Reconciliation does not consume a turn.

An effect of a run outside this handle’s family, or an action that the run’s current tool batch does not have, is refused with WrongReference; an action that is not uncertain, or a run whose model is being called, with NotReconcilable. A sub-agent run whose ancestor is stopping or has ended accepts none (RunEnded). After a run has finished, use reconcile_stored to retain evidence without resuming work, then settle_stored for its finished ancestors.

pub fn reconcile_stored(
  store: store.Store,
  effect: run.ActionRef,
  content: String,
) -> Result(run.Snapshot(String), Error)

Records evidence for an uncertain tool of a finished run, without an agent definition or any execution callbacks. The outcome, transcript, usage and counters stay unchanged. The same content is an idempotent acknowledgement; different content cannot overwrite a saved result. Active runs return RunNotFinished; use reconcile to continue them. A delegation cannot be reconciled by supplying content: settle its child and then use settle_stored on the parent. Returns this run’s updated snapshot, including any remaining uncertain actions.

pub fn recover(
  store: store.Store,
  agent: agent.Agent(context, answer),
  context: context,
  id: run.RunId,
) -> Result(Run(context, answer), Error)

Opens the stored run id under agent and context. When work was in flight and no runner in this store drives it, recovery takes the work over as a new incarnation (committed with compare-and-set, so concurrent recoveries have one winner): running tools become uncertain effects (replayable ones, tool.with_replay, are started again while they have attempts left), queued tools are dispatched again, and a lost model call is issued again against the turn budget. A suspended or finished run is opened unchanged, except that an approval request whose deadline passed is rejected (the model sees it, and the run goes on). Calling it again is harmless.

An approved tool or sub-agent start that had not started is not run with context: the context its answer was checked with is gone, and a stored approval alone does not authorize a later incarnation. It asks for its approval again, as a new request under the requirement last answered (the old reference is then stale).

Sub-agent runs are recovered with their parent: a child that was never stored is started, a child whose end the parent missed is applied to it, an active child is recovered in turn, and a child that cannot be read or continued becomes an uncertain effect of the delegation.

A run handed off by a drained shutdown (store.supervised) has nothing running: its results are committed, a model call it never issued is issued as the same turn, and its queued tools are dispatched, except approved ones, which ask for their approval again (the handoff already asked).

On a leased store (store.leased) it is safe to call at any time: a run whose lease another node holds live is left exactly as it is (the handle is returned, and the run reads Working), and only a free or expired lease, one of an earlier process of this store, or this store’s own lease whose runner is gone is taken over. This node reads that last case as Unattended; another node reads it as Working until expiry. Of several nodes recovering one run at once, exactly one takes it over. A live foreign parent keeps its runner; eligible children can recover independently, and that parent reads their committed outcomes.

An unleased store knows only the runners it started. Recovering through another unleased Store (for example in another VM) while the run’s runner is still alive takes the run over: the older runner can no longer commit and stops, and its running tools become uncertain effects. On an unleased store, recover only when the previous owner is known to be gone (for example at boot); to read or command a run someone else may be driving, open it.

pub fn reject(
  run: Run(context, answer),
  reference: run.ApprovalRef,
  proof proof: approvers.Proof,
  reason reason: String,
) -> Result(run.Status(answer), Error)

Rejects an approval request of the run or of one of its sub-agents (the reference names the run): the action does not run, and the model sees reason. A rejection is not checked again, so it takes no context and never runs the policy; the run continues with its own context. It is committed like an approval (see approve), with the same refusals except RequirementChanged; a rejection after the request’s deadline is ApprovalExpired. proof is checked and recorded as for approve.

pub fn settle_stored(
  store: store.Store,
  id: run.RunId,
) -> Result(run.Snapshot(String), Error)

Settles uncertain delegations of a finished agent family from their saved child outcomes. Descendants must name the exact parent action. Missing or unreadable children return an error; active or uncertain children leave the delegation uncertain. No agent code runs and no work resumes. The returned root snapshot retains every unresolved action; successful observation does not imply that every effect has been settled.

Each record commits independently, from the leaves outward. A failed parent write may follow successful descendant writes; repeat this command to finish propagation. Repeating an unchanged walk performs no writes. For a graph-owned family, settle this agent root, then recover its graph parent to observe the saved outcome. The graph remains cancelled.

pub fn snapshot(
  run: Run(context, answer),
) -> Result(run.Snapshot(answer), Error)

The run’s own record, with the status of the run and its sub-agents (see await).

pub fn start(
  store: store.Store,
  agent: agent.Agent(context, answer),
  id id: run.RunId,
  context context: context,
  prompt prompt: String,
  correlation correlation: option.Option(correlation.Correlation),
) -> Result(Run(context, answer), Error)

Starts a run of agent in store under id: stores its first record and hands the first model call to a new runner. id is the caller’s: run.new_id() for a fresh run, or an id derived from the work that starts it (run.id_from_parts("job", [job_id])), so that starting again finds the run (AlreadyStarted) instead of starting a second one. A retry that must start afresh rather than continue (a job’s next attempt after a business failure) includes its attempt among the parts. An agent with a family budget (agent.with_family_budget) declares it for this root run.

correlation is carried in every event of the run, its sub-agent runs included, in every model.Request (which fabric/llm puts on its HTTP requests) and in every tool’s tool.Call; it is stored with the run, so a recovered run keeps it. None derives it from the id (correlation.from_key(run.id_to_string(id))); pass the correlation of the request or job that starts the run to join their events.

pub fn start_with_input(
  store: store.Store,
  agent: agent.Agent(context, answer),
  id id: run.RunId,
  context context: context,
  input input: input.Input,
  correlation correlation: option.Option(correlation.Correlation),
) -> Result(Run(context, answer), Error)

Starts one run from validated history and a current prompt. Historical calls are context only. The writer must retain the original boundary; unsupported history is refused before any write or execution.

✨ Search Document