Reading and Comparing Runs¶
satay.inspect hands you back what a run recorded — every durable call, its arguments,
its result — without forking it and without running anything again.
import satay
inspection = await satay.inspect(run_id)
for call in inspection.calls:
print(call.identity, call.status, call.args, "->", call.output)
It is core, like satay.fork and satay.run_app. No satay[studio] extra, no server, no
account.
What comes back¶
RunInspection describes the run; RecordedCall describes one durable call.
inspection.run_id # the run
inspection.workflow_name
inspection.status # a satay.RunStatus
inspection.output # the run's recorded output, or None
inspection.usage # self-reported totals, e.g. {"input_tokens": 3211, "usd": 0.0192}
inspection.error # {"type", "message", "traceback"} for a failed run, else None
inspection.calls # every durable call, in the order it was scheduled
inspection.call("research:0") # one call by identity, or None
call.identity # "research:0", or "resize:key:a.png" for a fan-out item
call.task_name
call.status # "completed" | "failed" | "running"
call.args # the recorded positional arguments
call.output # the recorded return value
call.attempts # how many physical attempts it took
call.duration_seconds
call.ordinal # set for an ordinary call...
call.key # ...or key, for a keyed fan-out item
call.map_group # which `satay.map` a keyed item belongs to
Driving a two-task workflow and reading it back:
@satay.workflow
async def dossier(brief: dict) -> str:
findings = await research(brief["topic"])
return await synthesize(findings, brief["style"])
dossier completed
output: '[sceptical] 2 findings'
research:0 completed attempts=1
args ('acme corp',)
output ['finding about acme corp', 'a second finding']
synthesize:0 completed attempts=1
args (['finding about acme corp', 'a second finding'], 'sceptical')
output '[sceptical] 2 findings'
A failed run is described, not raised¶
await handle.result() raises WorkflowFailedError, which is what you want when you are
driving a run. When you are reading one, being interrupted by the answer is unhelpful,
so inspect reports it:
inspection = await satay.inspect(run_id)
if inspection.error:
print(inspection.error["type"], inspection.error["message"])
failed = [c for c in inspection.calls if c.status == "failed"]
What a run cost¶
A task that calls ctx.record_model_usage(model=..., input_tokens=..., output_tokens=...,
**extra) (ADR-0008) writes a schemaless usage entry onto its outcome event. inspection.usage
is that data summed: every numeric field, across every attempt in the run, under its own key.
inspection = await satay.inspect(run_id)
inspection.usage # {"input_tokens": 3211, "output_tokens": 640, "usd": 0.0192}
Nothing here is a pricing table — Satay ships no model adapters, so it has no idea what a
token costs. usd above is only there because a task chose to report it; sum whatever field
names your own tasks self-report. A run whose tasks never called record_model_usage gets
{}, not zeroes for keys nobody reported.
Failed attempts count. A task retried twice before succeeding was billed for the two answers
it threw away, same as ReadAPI's own task-detail total (KAN-479) — there is no flag to narrow
this to successful work only, because nothing has needed that yet; drop to
journal.timeline.model_usage(events, include_failed_attempts=False) if you do.
usage goes through the same redactor as everything else. A field a caller's own pattern set
happens to match — usage=Redactor(patterns=["cost"]) against a self-reported cost field,
say — is left out of the total rather than reported as 0: a number the redactor could not see
is unknown, not zero. This is per-run, not per-call: usage rides on the physical attempt, not
the logical call, so it does not appear on RecordedCall. One call's own usage is
ReadAPI.task_detail's job, not this one's.
Reading is not forking¶
A read makes no journal entry. Nothing is appended, no run row is created, and no recorded call runs again — so it is safe on a workflow whose tasks send email or charge cards, which a fork is not.
It also works on a run that has not finished: an unfinished run reports the calls it has
recorded so far and a status of running or waiting. (satay.fork accepts terminal
runs only, because forking writes.)
An unknown run id raises LookupError, so catching it needs no import.
Secrets are redacted¶
Every read is redacted, exactly as the read API behind Studio is. A field whose name
looks like a secret comes back as ***REDACTED***:
The default patterns cover password, secret, token, api_key, private_key,
credential, authorization and a few more. Pass your own to widen or narrow it:
from satay.redaction import Redactor
inspection = await satay.inspect(run_id, redactor=Redactor(patterns=["email", "ssn"]))
There is deliberately no way to ask for unredacted output — the absence of that path is the guarantee.
Positional arguments cannot be redacted
Redaction matches field names, and a positional argument does not have one. A secret
passed positionally to a task — await charge(card_number) — is recorded in the clear
and comes back in the clear, here and in Studio alike. If that matters, either pass it
inside a named field so the redactor can see it, or turn on
write-time redaction so it never reaches the journal at all.
Values come back untyped¶
inspect gives you decoded JSON-compatible values — dicts, lists, strings, numbers — not
rehydrated dataclasses or Pydantic models, even when the task declares a return type.
That is deliberate. Typed rehydration needs the task still imported in the reading process to find its annotation, which would make the type you get back depend on what your script happened to import; and it cannot be combined with redaction, because redaction is a walk over plain JSON structures. Reads stay predictable instead.
If you want typed values, await handle.result() on the run gives you the workflow's own
return value, rehydrated.
Comparing two runs¶
satay.diff aligns two runs by durable-call identity and tells you where their values
differ — not merely that they do.
forked = await satay.fork(run_id, before_task="synthesize", workflow_input=sharper_brief)
await forked.result()
result = await satay.diff(run_id, forked.run_id)
for call in result.changed:
print(call.identity)
if call.args and call.args.changed:
print(" args differ at ", call.args.paths)
if call.output and call.output.changed:
print(" output differs at", call.output.paths)
That is the loop the debugger exists for: fork a run at the call that went wrong, drive the
fork, and read off exactly which field of which call the change moved. research:0 above
does not appear — it was replayed off the journal and is identical.
Paths are jq-shaped: .style, [1].topic, and . when the difference is not localisable
to any field inside the value (a scalar, or two sides of different shapes). For a call's
arguments the top-level index is the positional argument index — [1] is the second
argument — because keyword arguments are never journaled.
result.calls is every aligned identity; result.changed is the subset that differs. A
call only one run made has aligned = False and no value diff, since there is nothing to
compare it against. Timing is reported as duration_changed but never counts as changed:
duration varies between runs for reasons that are not a divergence.
Secrets are compared correctly, not just hidden¶
The comparison runs before redaction, and emits only paths. So two different secrets are correctly reported as differing, even though both come back masked:
call.output.paths # ('.session_token',) — they differ
call.a.output["session_token"] # '***REDACTED***' — but you cannot read either
A diff computed after redaction would have compared two identical ***REDACTED***
sentinels and told you the runs agreed, which would be worse than telling you nothing.
The one case that cannot be rescued is write-time redaction: the journal itself holds the sentinel, the cleartext is gone at every layer, and no comparison is possible. That is reported honestly rather than guessed:
ValueDiff.truncated says the same thing about size: paths are capped, so a diff of two
enormous and wholly different values gives you a prefix of the truth rather than megabytes.