Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

JSON Form

Every value type has one plain JSON form. The journal, to_jsonable, Pydantic (dump_json, model_dump(mode="json"), FastAPI) and the generated JSON Schema all agree on it.

ValueJSON
Ok(v) / Err(e){"ok": v} / {"err": e}
Some(v) / Nothingv / null
Valid(v) / Invalid([e, ...]){"valid": v} / {"invalid": [e, ...]} (never empty)
Success(v) / Failure(exc){"success": v} / {"failure": exc}
IList, NonEmptyList, IntList, FloatList, ISet, IntSeta list
IMapan object
an exception{"type": "module:Qualname", "message": "...", "args": [...], "attrs": {...}}
a @sealed case{"CaseName": {field: value, ...}}

Some(None) has no form because it would read back as Nothing; writing one is an error. A field declared init=False is part of a case’s form.

Type optional fields as Option[T] and default them to Nothing. With = None the attribute stays a raw None, because Pydantic does not validate defaults.

An unannotated @step result comes back as plain JSON. A persistent store writes it to JSON; the in-memory store keeps the live value, so exceptions nested inside a core type, bytes, and an IMap with non-string keys can fail or change shape only in a persistent store. Tool, sub-agent and model results are always written to JSON, on every store.

Annotate the return type and the value comes back as the declared type, on first run and on replay:

@sealed
class OrderError: ...
@case
class OutOfStock(OrderError):
sku: str
@step
def reserve(sku: str) -> Either[int, OrderError]:
return Err(OutOfStock(sku))
match await reserve("x"):
case Err(OutOfStock(sku=sku)):
...

On a @step, a sealed type whose cases share a name raises TypeError at decoration. A @tool return annotation or @agent output_type is checked on first use instead. A sealed case that holds instance state and is not a dataclass has no JSON form and fails on first write.

An exception is rebuilt as its class only if the class is already loaded, subclasses the declared error type, and rebuilds from args and attrs to an equal instance. Otherwise:

  • Under a declared Exception or BaseException, you get a RecordedError with the original type_path and message.
  • Under a narrower type, writing fails, and a @step fails with StepResultTypeError before anything is journaled as done.
  • Under Any, the value is the payload dict.

A module imported only inside a step body is rebuilt on first run but is a RecordedError on a fresh replay worker, so match on type_path:

match await attempt():
case Failure(RecordedError(type_path="botocore.exceptions:ClientError")):
...
case Failure(exc):
...

A FastAPI body typed Try[...] or Either[..., Exception] imports the exception class named in client JSON and runs its constructor. Type the error side of a request body as a @case error instead.

A case is named by its class name, so two cases of one sealed type cannot share a name. Exception and Pydantic-model hierarchies keep their own forms; a Pydantic model or dataclass cannot be a case of a plain @sealed type, so declare it with @case.

View this page as Markdown