Psych Runtime
Reference

Reading a Run

Projections over the log. None is a second copy, so no two can disagree.

RunReport

model

class RunReport(*, run_id: psych_runtime.core.ids.RunId, scope: psych_runtime.core.scope.Scope, version_hash: psych_runtime.core.ids.VersionHash, spec_name: str, system_prompt: str, steps: tuple[psych_runtime.report.model.StepReport, ...], tool_calls: tuple[psych_runtime.report.model.ToolCallReport, ...], model_calls: tuple[psych_runtime.report.model.ModelCallReport, ...], suspensions: tuple[psych_runtime.report.model.SuspensionReport, ...], failure_streak_trips: tuple[psych_runtime.report.model.FailureStreakTrip, ...], terminal_state: psych_runtime.core.records.TerminalState | None, output: dict[str, typing.Any] | None, failure: psych_runtime.core.records.ToolFailure | None, orphaned_attempt_id: Optional[psych_runtime.core.ids.AttemptId], admitted_at: datetime.datetime, settled_at: datetime.datetime | None, totals: psych_runtime.report.model.TotalsReport, components: tuple[Component, ...] = (), subagents: tuple[psych_runtime.report.model.SubagentReport, ...] = (), subtree: psych_runtime.report.model.SubtreeTotals | None = None, tasks: tuple[psych_runtime.core.tasks.Task, ...] = (), compactions: tuple[psych_runtime.report.model.CompactionReport, ...] = (), continues_run_id: Optional[psych_runtime.core.ids.RunId] = None)

The whole report for one Run. DESIGN.md §13.4.

Attributes: version_hash: the Version this Run pinned at admission (DESIGN.md §4). spec_name: the pinned Spec's name, for a report that reads on its own. system_prompt: the system prompt as sent, read from the log. The most recent turn's, which is the one a reader means by "the prompt"; every turn's own is on its ModelCallReport, because a turn whose advisories changed was genuinely told something different.

This used to be reconstructed from the pinned Spec, which could not know what the runtime added at assembly time -- a withheld tool, an unreachable optional server, a deferred catalogue, what each connected system is for -- and so showed something the model never saw while looking authoritative. A Run recorded before the prompt was captured still falls back to that reconstruction, because there a guess beats an empty box.

Empty for a WorkflowSpec Run, which has no system prompt of its own; a nested agent step's own prompt is on its child report. steps: workflow steps, in the order each attempt started. Empty for a bare agent Run. tool_calls: every tool call, in the order each started. model_calls: every model call, in the order each started. suspensions: every suspend/resume cycle, in order. failure_streak_trips: every time a tool's consecutive-failure count crossed the guard's threshold, in order. terminal_state: None while the Run has not settled yet. orphaned_attempt_id: set only for a force_settled terminal state: the Attempt the supervisor gave up waiting on (DESIGN.md §8.4). Its dangling tool calls, if any, still show up in tool_calls with outcome=None; this names whose work was abandoned. totals: token, cost and latency totals over this Run alone. A child's totals are not folded in; sum them yourself if that is what a caller wants, since a workflow orchestrating several expensive children and one cheap one is a different report from a flat sum either way. continues_run_id: the Run this one continues, when it was admitted by psych_runtime.dispatch(continues=...) as a later message in the same conversation. None for a thread's first Run. Read straight off RunAdmitted.continues_run_id -- the same field psych_runtime.runtime.thread.load_thread_history walks to build the conversation a Worker actually sent -- so this report and that conversation can never disagree about which Runs make up the thread. Totals above still cover this Run alone; an ancestor's own cost is on its own report.

FieldTypeNotes
run_idRunId
scopeScope
version_hashVersionHash
spec_namestr
system_promptstr
stepstuple
tool_callstuple
model_callstuple
suspensionstuple
failure_streak_tripstuple
terminal_state`records.TerminalStateNone`
output`dict[str, Any]None`
failure`records.ToolFailureNone`
orphaned_attempt_idOptional
admitted_atdatetime
settled_at`datetime.datetimeNone`
totalsTotalsReport
componentstuple
subagentstuple
subtree`psych_runtime.report.model.SubtreeTotalsNone`
taskstuple
compactionstuple
continues_run_idOptional

RunStatus

model

class RunStatus(*, run_id: psych_runtime.core.ids.RunId, scope: psych_runtime.core.scope.Scope, version_hash: psych_runtime.core.ids.VersionHash, lifecycle: psych_runtime.core.status.Lifecycle, terminal_state: psych_runtime.core.records.TerminalState | None, output: dict[str, typing.Any] | None, failure_message: str | None, failure_kind: str | None, suspend_reason: psych_runtime.core.records.SuspendReason | None, suspend_expires_at: datetime.datetime | None, pending_approval: psych_runtime.core.status.PendingApproval | None, pending_question: psych_runtime.core.status.PendingQuestion | None, components: tuple[Component, ...], tasks: tuple[psych_runtime.core.tasks.Task, ...], turn: int, tool_calls: int, model_calls: int, head_seq: int, attempt_count: int, current_attempt: Optional[psych_runtime.core.ids.AttemptId], deadline_at: datetime.datetime | None, continues_run_id: Optional[psych_runtime.core.ids.RunId], parent_run_id: Optional[psych_runtime.core.ids.RunId], has_dangling_tool_calls: bool)

One Run's current state, as a UI would render it.

FieldTypeNotes
run_idRunId
scopeScope
version_hashVersionHash
lifecycleLifecycle
terminal_state`records.TerminalStateNone`
output`dict[str, Any]None`
failure_message`strNone`
failure_kind`strNone`
suspend_reason`records.SuspendReasonNone`
suspend_expires_at`datetime.datetimeNone`
pending_approval`status.PendingApprovalNone`
pending_question`status.PendingQuestionNone`
componentstuple
taskstuple
turnint
tool_callsint
model_callsint
head_seqint
attempt_countint
current_attemptOptional
deadline_at`datetime.datetimeNone`
continues_run_idOptional
parent_run_idOptional
has_dangling_tool_callsbool

RunStateView

class

class RunStateView(run_id: 'RunId', scope: 'Scope', version_hash: 'VersionHash', head_seq: 'int' = 0, admitted: 'bool' = False, settled: 'bool' = False, terminal_state: 'TerminalState | None' = None, output: 'dict[str, Any] | None' = None, failure: 'ToolFailure | None' = None, deadline_at: 'datetime | None' = None, parent_run_id: 'RunId | None' = None, delegation_depth: 'int' = 0, continues_run_id: 'RunId | None' = None, idempotency_key: 'str | None' = None, run_input: 'dict[str, Any]' = <factory>, attempt_count: 'int' = 0, current_attempt: 'AttemptId | None' = None, turn: 'int' = 0, turn_open: 'bool' = False, model_call_open: 'bool' = False, abort_seq: 'int | None' = None, suspended: 'bool' = False, suspend_reason: 'SuspendReason | None' = None, suspend_expires_at: 'datetime | None' = None, suspended_at: 'datetime | None' = None, pending_approval_call_id: 'ToolCallId | None' = None, suspend_question: 'str | None' = None, suspend_questions: 'tuple[AskedQuestion, ...]' = (), resumed_since_attempt: 'bool' = False, suspended_seconds: 'float' = 0.0, resume_payloads: 'dict[ToolCallId, dict[str, Any]]' = <factory>, approval_decisions: 'dict[ToolCallId, bool]' = <factory>, open_tool_calls: 'dict[ToolCallId, OpenToolCall]' = <factory>, tool_results: 'list[ToolResult]' = <factory>, steps: 'dict[StepId, StepRecord]' = <factory>, pending_steer: 'list[PendingQueueEntry]' = <factory>, pending_follow_up: 'list[PendingQueueEntry]' = <factory>, pending_next_run: 'list[PendingQueueEntry]' = <factory>, tasks: 'tuple[Task, ...]' = (), components: 'tuple[Component, ...]' = (), usage: 'Usage' = <factory>, cost: 'Cost | None' = None, unpriced_model_calls: 'int' = 0, provider_reported_costs: 'int' = 0, model_calls: 'int' = 0, failed_model_calls: 'int' = 0, transient_retries_used: 'int' = 0, last_prompt_tokens: 'int' = 0, failure_streaks: 'dict[str, int]' = <factory>, repeat_counts: 'dict[str, RepeatTally]' = <factory>, compaction_boundary_seq: 'int' = 0, compaction_summaries: 'list[str]' = <factory>, compaction_calls: 'int' = 0, children: 'dict[RunId, ChildRun]' = <factory>, result_handles: 'dict[str, ToolCallId]' = <factory>)

Everything derivable from a Run's log.

Mutable during the fold and returned as-is: making it frozen would mean rebuilding it once per record, and the fold is the only thing that ever writes to it. Callers treat it as read-only.

AnswerView

model

class AnswerView(*, run_id: psych_runtime.core.ids.RunId, text: str = '', work: tuple[psych_runtime.core.answer.WorkTurn, ...] = (), finished: bool = False)

A Run split into what it concluded and how it got there.

FieldTypeNotes
run_idRunId
textstr
worktuple
finishedbool

ThreadView

model

class ThreadView(*, run_ids: tuple[psych_runtime.core.ids.RunId, ...], messages: tuple[psych_runtime.core.thread_view.MessageView, ...])

One conversation, across every Run in its chain.

FieldTypeNotes
run_idstuple
messagestuple

MessageView

model

class MessageView(*, role: Literal['user', 'assistant', 'tool'], content: str, run_id: psych_runtime.core.ids.RunId, seq: int, at: datetime.datetime, tool_name: str | None = None, tool_call_id: Optional[psych_runtime.core.ids.ToolCallId] = None, is_error: bool = False)

One message, and where in the log it came from.

FieldTypeNotes
roleLiteral
contentstr
run_idRunId
seqint
atdatetime
tool_name`strNone`
tool_call_idOptional
is_errorbool

WorkTurn

model

class WorkTurn(*, turn: int, text: str = '', tool_calls: tuple[psych_runtime.core.answer.ToolCallView, ...] = (), failure_message: str | None = None, at: datetime.datetime)

One turn of working: what the model said on the way, and what it ran.

FieldTypeNotes
turnint
textstr
tool_callstuple
failure_message`strNone`
atdatetime

ToolCallView

model

class ToolCallView(*, call_id: psych_runtime.core.ids.ToolCallId, tool: str, arguments: dict[str, typing.Any], outcome: psych_runtime.core.records.ToolOutcome | None = None, result: Any = None, failure_message: str | None = None, duration_seconds: float | None = None, started_at: datetime.datetime, finished_at: datetime.datetime | None = None)

One tool call, as a reader opening the work section sees it.

FieldTypeNotes
call_idToolCallId
toolstr
argumentsdict
outcome`records.ToolOutcomeNone`
resultAny
failure_message`strNone`
duration_seconds`floatNone`
started_atdatetime
finished_at`datetime.datetimeNone`

TotalsReport

model

class TotalsReport(*, usage: psych_runtime.core.usage.Usage, cost: psych_runtime.core.usage.Cost | None, unpriced_model_calls: int, cost_is_incomplete: bool, provider_reported_costs: int = 0, model_calls: int, failed_model_calls: int, tool_calls: int, latency: psych_runtime.report.model.LatencyReport, compaction_calls: int = 0)

Run-wide totals. DESIGN.md §13.4: "totals for tokens, cost and duration".

Attributes: usage: every model call's usage summed with Usage.__add__, which keeps cache_read and cache_write disjoint from input and keeps cache_write_1h a subset of cache_write rather than a second addend. Never re-derive this by hand from model_calls. cost: the sum of every priced call's cost. None only when not one call in the Run had a known price. Its source says where the figures came from, and is "mixed" when the Run used both a provider-reported cost and a locally computed one. unpriced_model_calls: how many finished calls had no known price and so are absent from cost. The one number that keeps "this run cost $0.00" and "I could not price N of these calls" from looking the same (DESIGN.md §13.2). cost_is_incomplete: unpriced_model_calls > 0, spelled out as its own field so a consumer does not have to remember which comparison means "the total is honest but partial".

FieldTypeNotes
usageUsage
cost`usage.CostNone`
unpriced_model_callsint
cost_is_incompletebool
provider_reported_costsint
model_callsint
failed_model_callsint
tool_callsint
latencyLatencyReport
compaction_callsint

LatencyReport

model

class LatencyReport(*, wall_clock_seconds: float, model_seconds: float, tool_seconds: float, compaction_seconds: float = 0.0, unaccounted_seconds: float)

DESIGN.md §13.3: wall-clock, the sum of the measured parts, and the gap.

Attributes: wall_clock_seconds: from the Run's first record to its last. Includes everything, including a suspension's wait: the gap is meant to be visible, not hidden because it happened to be long. model_seconds: summed queue_wait_seconds + stream_duration_seconds over every model call that finished. A failed or dangling call carries no measured duration and contributes nothing here. tool_seconds: summed duration_seconds over every tool call that finished. A dangling call contributes nothing. compaction_seconds: the same sum over every summarising call. Counted apart from model_seconds for the reason TotalsReport.compaction_calls is counted apart from model_calls: it is time the Run spent fitting itself into the window rather than doing the work, and a breakdown that blended the two could not tell anyone whether compaction was worth it. It was in neither before, so it landed in the gap below and read as time nobody could explain. unaccounted_seconds: wall_clock_seconds - model_seconds - tool_seconds - compaction_seconds, computed and reported rather than silently dropped. Ordinarily positive: time between Turns, suspension waits, and anything else the runtime does not attribute to a model or a tool all land here. Not clamped at zero, because a negative value is a real signal too (clock skew between Workers, say) and clamping would hide exactly what this field exists to show.

FieldTypeNotes
wall_clock_secondsfloat
model_secondsfloat
tool_secondsfloat
compaction_secondsfloat
unaccounted_secondsfloat

SubtreeTotals

model

class SubtreeTotals(*, usage: psych_runtime.core.usage.Usage, cost: psych_runtime.core.usage.Cost | None, unpriced_model_calls: int, cost_is_incomplete: bool, runs: int, complete: bool)

One Run plus every descendant it spawned, added up.

RunReport.totals deliberately covers one Run alone and says so, because a workflow orchestrating several expensive children and one cheap one is a different report from a flat sum. This is the flat sum, kept beside it rather than folded into it, so a reader picks the number they meant instead of discovering which one they got.

Attributes: complete: whether every descendant was actually counted. False when a child was still running, or when child_depth stopped the walk before the bottom of the tree. A total that could not say this would look authoritative and be partial, which is the same failure cost_is_incomplete exists to prevent one level down.

FieldTypeNotes
usageUsage
cost`usage.CostNone`
unpriced_model_callsint
cost_is_incompletebool
runsint
completebool

SubagentReport

model

class SubagentReport(*, name: str, child_run_id: psych_runtime.core.ids.RunId, call_id: psych_runtime.core.ids.ToolCallId, child_version_hash: psych_runtime.core.ids.VersionHash, purpose: str, task: str, deliverable: str, tools: tuple[str, ...], model: str, delegation_depth: int, background: bool, spawned_at: datetime.datetime, finished_at: datetime.datetime | None, terminal_state: psych_runtime.core.records.TerminalState | None, output: dict[str, typing.Any] | None, failure: psych_runtime.core.records.ToolFailure | None, messages_sent: int, usage: psych_runtime.core.usage.Usage, cost: psych_runtime.core.usage.Cost | None, unpriced_model_calls: int, report: psych_runtime.report.model.RunReport | None = None, subtree: psych_runtime.report.model.SubtreeTotals | None = None)

One subagent a Run composed and started (DESIGN.md §17).

The composed path only. A SubagentRef delegated to inline is a nested Run reached through StepReport.child_run_id and stays there: the two are different mechanisms with different lifetimes, and a report that merged them would have to lie about one of them to describe the other.

Attributes: child_version_hash: the Version the child pinned. The composed Spec is published like any other, so a reader can fetch it and see exactly the agent the model wrote. tools: what the child actually held after narrowing, not what was asked for. terminal_state: None while it is still running -- or while its ending has not reached this parent's log, which from the parent's side is the same thing and is why the field is read from the log rather than from the child. report: the child's own report, present only when child_depth allowed the walk. None does not mean there is nothing there. subtree: this child and its own descendants, added up.

FieldTypeNotes
namestr
child_run_idRunId
call_idToolCallId
child_version_hashVersionHash
purposestr
taskstr
deliverablestr
toolstuple
modelstr
delegation_depthint
backgroundbool
spawned_atdatetime
finished_at`datetime.datetimeNone`
terminal_state`records.TerminalStateNone`
output`dict[str, Any]None`
failure`records.ToolFailureNone`
messages_sentint
usageUsage
cost`usage.CostNone`
unpriced_model_callsint
report`psych_runtime.report.model.RunReportNone`
subtree`psych_runtime.report.model.SubtreeTotalsNone`

StepReport

model

class StepReport(*, step_id: psych_runtime.core.ids.StepId, name: str, kind: str, attempt_number: int, input: dict[str, typing.Any], completed: bool, output: dict[str, typing.Any] | None, failure: psych_runtime.core.records.ToolFailure | None, child_run_id: Optional[psych_runtime.core.ids.RunId], child: psych_runtime.report.model.RunReport | None = None)

One workflow step: one checkpointed, memoised unit of work.

Populated from StepStarted/StepCompleted pairs, which only exist for Steps a workflow wraps. A bare agent Run's Turns and tool calls are reported through model_calls and tool_calls directly and never appear here, because nothing wrapped them in a Step.

A step_id retried after a failure appears once per attempt, in the order each attempt started, so a report shows the whole story rather than only the attempt that stuck.

Attributes: completed: False when the step started and the log ends before it completed, the workflow analogue of a dangling tool call. child_run_id: set when this step was a nested Run (DESIGN.md §5: a workflow step may itself be an agent or a nested workflow). See child for how it is walked. child: the nested Run's own report, present only when the caller asked build_report to include children and the depth bound allowed it. None here does not mean there is no child; check child_run_id for that.

FieldTypeNotes
step_idStepId
namestr
kindstr
attempt_numberint
inputdict
completedbool
output`dict[str, Any]None`
failure`records.ToolFailureNone`
child_run_idOptional
child`psych_runtime.report.model.RunReportNone`

ToolCallReport

model

class ToolCallReport(*, call_id: psych_runtime.core.ids.ToolCallId, tool: str, turn: int, step_id: Optional[psych_runtime.core.ids.StepId], arguments: dict[str, typing.Any], outcome: psych_runtime.core.records.ToolOutcome | None, result: Any, failure: psych_runtime.core.records.ToolFailure | None, duration_seconds: float | None, result_handle: str | None, preview: str | None, result_bytes: int, started_at: datetime.datetime, finished_at: datetime.datetime | None, interruptible: bool, safe_to_retry: bool)

One tool call: what it was asked to do, and how it ended.

Attributes: outcome: None only when the call is dangling: started, and the log ends before a result was recorded (the same crash window the reducer's has_dangling_tool_calls reports on the live Run). result: the recorded result, whole. A large result was elided for the model at call time (DESIGN.md §10.8); the log, and so this report, always holds the full value. duration_seconds: None for a dangling call, since none was measured.

FieldTypeNotes
call_idToolCallId
toolstr
turnint
step_idOptional
argumentsdict
outcome`records.ToolOutcomeNone`
resultAny
failure`records.ToolFailureNone`
duration_seconds`floatNone`
result_handle`strNone`
preview`strNone`
result_bytesint
started_atdatetime
finished_at`datetime.datetimeNone`
interruptiblebool
safe_to_retrybool

ModelCallReport

model

class ModelCallReport(*, turn: int, step_id: Optional[psych_runtime.core.ids.StepId], model: str, system_prompt: str, tool_names: tuple[str, ...], usage: psych_runtime.core.usage.Usage | None, cost: psych_runtime.core.usage.Cost | None, timings: psych_runtime.core.records.ModelTimings | None, finish_reason: str | None, text: str, tool_call_ids: tuple[psych_runtime.core.ids.ToolCallId, ...], started_at: datetime.datetime, finished_at: datetime.datetime | None, failure: psych_runtime.core.records.ToolFailure | None, will_retry: bool, dangling: bool)

One model call: what was asked, what came back, what it cost.

Attributes: turn: the Turn this call belongs to. Unique within a Run: DESIGN.md §5's agent loop opens a fresh Turn for every model call, retries included, so this is a stable key rather than a display counter. step_id: set when the Turn ran inside a workflow step. usage: None only when the call never finished, either because it failed (see failure) or because the Attempt that made it died mid-stream (see dangling). A finished call always carries usage, even when its cost could not be computed. cost: the cost recorded at call time, or None when the model had no known price. Never a silent zero (DESIGN.md §13.2): read RunReport.totals.cost_is_incomplete before summing these. will_retry: whether the transient budget allowed another attempt after this one failed. Only meaningful when failure is set. dangling: the call started and the log ends before it settled. The Attempt that made it died mid-call; a later Attempt does not retry the model call itself, only the turn.

FieldTypeNotes
turnint
step_idOptional
modelstr
system_promptstr
tool_namestuple
usage`usage.UsageNone`
cost`usage.CostNone`
timings`records.ModelTimingsNone`
finish_reason`strNone`
textstr
tool_call_idstuple
started_atdatetime
finished_at`datetime.datetimeNone`
failure`records.ToolFailureNone`
will_retrybool
danglingbool

SuspensionReport

model

class SuspensionReport(*, reason: psych_runtime.core.records.SuspendReason, question: str | None, pending_call_id: Optional[psych_runtime.core.ids.ToolCallId], suspended_at: datetime.datetime, expires_at: datetime.datetime, resumed_at: datetime.datetime | None, approved: bool | None, payload: dict[str, typing.Any] | None)

One wait: why the Run paused, and how it ended.

Attributes: resumed_at: None while still waiting, or when the log ends before a resumed record arrived (an abandoned suspension past its expiry settles the Run directly; see RunReport.terminal_state). approved: set only for an approval suspension. False denies the pending call, which DESIGN.md §11 treats as a settlement rather than a failure.

FieldTypeNotes
reasonSuspendReason
question`strNone`
pending_call_idOptional
suspended_atdatetime
expires_atdatetime
resumed_at`datetime.datetimeNone`
approved`boolNone`
payload`dict[str, Any]None`

CompactionReport

model

class CompactionReport(*, seq: int, reason: Literal['threshold', 'manual', 'overflow'], replaced_from_seq: int, replaced_to_seq: int, summary: str, model: str, usage: psych_runtime.core.usage.Usage, cost: psych_runtime.core.usage.Cost | None, at: datetime.datetime)

One compaction: what it replaced, what it said, and what it cost.

The replaced Records are still in the log and still in this report's own tool_calls and model_calls. Compaction changes what the model sees next, never what happened, so this row says which stretch of the conversation stopped being sent rather than which stretch stopped existing.

Attributes: seq: where the compaction itself sits in the log, so a reader can place it against the records above and below it. reason: threshold when the Spec's trigger was reached, overflow when the provider refused the prompt for being too long, manual when something outside the loop asked. model: which model wrote the summary, which is not necessarily the agent's own (CompactionPolicy.model). Empty for a compaction recorded before this was written down. usage: what the summarising call spent. Already inside TotalsReport.usage; here so the spend is attributable to the compaction that caused it rather than only visible in the total. cost: None when that model had no known price. Never zero (DESIGN.md §13.2).

FieldTypeNotes
seqint
reasonLiteral
replaced_from_seqint
replaced_to_seqint
summarystr
modelstr
usageUsage
cost`usage.CostNone`
atdatetime

FailureStreakTrip

model

class FailureStreakTrip(*, tool: str, call_id: psych_runtime.core.ids.ToolCallId, streak: int, threshold: int, at: datetime.datetime)

The moment one tool's consecutive-failure count crossed the guard's threshold (DESIGN.md §10.6, §23 item 10).

Recorded once per streak, at the call that tipped it over, rather than once per failure afterwards: the guard changes the model's advisory exactly once per streak, and that is the event worth naming. The next success resets the streak; a later run of failures on the same tool trips again.

FieldTypeNotes
toolstr
call_idToolCallId
streakint
thresholdint
atdatetime

PendingApproval

model

class PendingApproval(*, call_id: psych_runtime.core.ids.ToolCallId, tool: str, arguments: dict[str, typing.Any], question: str | None, expires_at: datetime.datetime)

The call a suspended Run is waiting on a decision for.

Everything an approval prompt needs, so a UI does not parse it back out of the suspension's question text: the tool, the exact arguments that will run if approved, and when waiting stops being useful.

FieldTypeNotes
call_idToolCallId
toolstr
argumentsdict
question`strNone`
expires_atdatetime

PendingQuestion

model

class PendingQuestion(*, call_id: psych_runtime.core.ids.ToolCallId, questions: tuple[psych_runtime.core.questions.AskedQuestion, ...], summary: str, expires_at: datetime.datetime)

A Run stopped because the agent asked something.

questions carries the structure a console renders: labels to click, and a description of what each one means. summary is the same thing as one line, for a notification or a log that will not render options.

The options are suggestions and nothing validates against them. A person may answer in their own words, which is what makes offering them safe.

FieldTypeNotes
call_idToolCallId
questionstuple
summarystr
expires_atdatetime

AskedQuestion

model

class AskedQuestion(*, question: Annotated[str, MinLen(min_length=1), MaxLen(max_length=2000)], header: Annotated[str, MaxLen(max_length=24)] = '', options: Annotated[tuple[psych_runtime.core.questions.QuestionOption, ...], MaxLen(max_length=4)] = (), multi_select: bool = False)

One question, with the answers the model expects to be useful.

FieldTypeNotes
questionstr
headerstr
optionstuple
multi_selectbool

QuestionOption

model

class QuestionOption(*, label: Annotated[str, MinLen(min_length=1), MaxLen(max_length=80)], description: Annotated[str, MaxLen(max_length=400)] = '')

One answer a person can pick without typing.

FieldTypeNotes
labelstr
descriptionstr

Component

value

Type alias.

Type aliases are created through the type statement::

type Alias = int

In this example, Alias and int will be treated equivalently by static type checkers.

At runtime, Alias is an instance of TypeAliasType. The name attribute holds the name of the type alias. The value of the type alias is stored in the value attribute. It is evaluated lazily, so the value is computed only if the attribute is accessed.

Type aliases can also be generic::

type ListOrSet[T] = list[T] | set[T]

In this case, the type parameters of the alias are stored in the type_params attribute.

See PEP 695 for more information.

Task

model

class Task(*, title: Annotated[str, MinLen(min_length=1), MaxLen(max_length=200)], active_form: Annotated[str, MaxLen(max_length=200)] = '', description: Annotated[str, MaxLen(max_length=2000)] = '', status: Literal['pending', 'in_progress', 'completed'] = 'pending')

One thing the model plans to do, is doing, or has done.

FieldTypeNotes
titlestr
active_formstr
descriptionstr
statusLiteral

TaskStatus

value

def TaskStatus(*args, **kwargs)

No docstring. That is a bug in the library rather than in this page: the reader who found this symbol in their editor sees the same gap.

Record

value

Type alias.

Type aliases are created through the type statement::

type Alias = int

In this example, Alias and int will be treated equivalently by static type checkers.

At runtime, Alias is an instance of TypeAliasType. The name attribute holds the name of the type alias. The value of the type alias is stored in the value attribute. It is evaluated lazily, so the value is computed only if the attribute is accessed.

Type aliases can also be generic::

type ListOrSet[T] = list[T] | set[T]

In this case, the type parameters of the alias are stored in the type_params attribute.

See PEP 695 for more information.

Usage

model

class Usage(*, input: Annotated[int, Ge(ge=0)] = 0, output: Annotated[int, Ge(ge=0)] = 0, cache_read: Annotated[int, Ge(ge=0)] = 0, cache_write: Annotated[int, Ge(ge=0)] = 0, cache_write_1h: Annotated[int, Ge(ge=0)] = 0, reasoning: Annotated[int, Ge(ge=0)] = 0)

Token counters for one model call.

Recorded per call and never aggregated at write time. Aggregation is a projection over the log (DESIGN.md §13.4), so a pricing correction can be recomputed while the recorded cost of each call stays as it was.

Attributes: input: uncached input tokens, billed at the input rate. output: generated tokens, billed at the output rate. cache_read: tokens read from the prompt cache, billed at the read rate. These are not counted in input; the two are disjoint. cache_write: tokens written to the prompt cache, billed at the write rate. cache_write_1h: the subset of cache_write held at one-hour retention rather than the default five minutes. This is a subset and not an addition: it is never larger than cache_write, and the two are not summed. reasoning: reasoning tokens where the provider reports them separately. Providers that bill these at the output rate already include them in output; this field is for visibility, not for a second charge.

FieldTypeNotes
inputint
outputint
cache_readint
cache_writeint
cache_write_1hint
reasoningint

Cost

model

class Cost(*, amount: Annotated[decimal.Decimal, Ge(ge=Decimal('0'))], currency: Annotated[str, MinLen(min_length=3), MaxLen(max_length=3), _PydanticGeneralMetadata(pattern='^[A-Z]{3}$')] = 'USD', model: Annotated[str, MinLen(min_length=1)], source: Literal['computed', 'provider', 'mixed'] = 'computed')

What one model call cost, computed at the time of the call.

Written into the Record so it never changes retroactively when the price table is updated (DESIGN.md §13.2). A report six months from now reports what was actually billed, not what the current table would say.

Decimal rather than float: money summed across thousands of calls accumulates binary rounding error, and a metering system that is off by a cent per million calls is a metering system nobody trusts.

Attributes: amount: the cost in currency. currency: ISO 4217, uppercase. model: the model id the rates were resolved for, recorded so a later reader can see which table entry applied. source: where the number came from. "computed" means Psych derived it from a PriceResolver; "provider" means the provider or gateway reported it with the response; "mixed" appears only on a sum of costs that did not agree.

Recorded because the two are not interchangeable when somebody is reconciling against an invoice. A provider-reported figure comes from the party doing the billing and is authoritative; a computed one is as good as whatever table was in force, which Psych's own docstrings admit goes stale. A total that blended them without saying so would look like one number and be two.

Defaulted to "computed" so every Record written before this field existed still loads, and still means what it said.

FieldTypeNotes
amountDecimal
currencystr
modelstr
sourceLiteral

Lifecycle

enum

class Lifecycle(*values)

Where a Run is, in the words a person would use.

Coarser than TerminalState and finer than the store's RunState: those answer "how did it end" and "is it claimable", and this answers "what should the screen say".

MemberValue
QUEUEDqueued
RUNNINGrunning
WAITINGwaiting
STOPPINGstopping
DONEdone
FAILEDfailed
STOPPEDstopped

TerminalState

enum

class TerminalState(*values)

How a Run ended. Every Run reaches exactly one of these.

MemberValue
COMPLETEDcompleted
FAILEDfailed
ABORTEDaborted
ABANDONEDabandoned
FORCE_SETTLEDforce_settled

SuspendReason

enum

class SuspendReason(*values)

Why a Run is waiting (DESIGN.md §11).

MemberValue
APPROVALapproval
QUESTIONquestion
EXTERNALexternal
CHILDRENchildren

ToolOutcome

enum

class ToolOutcome(*values)

How a tool call ended.

MemberValue
OKok
ERRORerror
ABORTEDaborted
UNKNOWNunknown

QueueKind

enum

class QueueKind(*values)

Where input that arrived mid-Run is parked (DESIGN.md §9).

Three queues rather than one flag, because "stop mid-response and immediately send another request" is three different intentions and collapsing them produces the race that breaks streaming after an interrupt.

MemberValue
STEERsteer
FOLLOW_UPfollow_up
NEXT_RUNnext_run

ToolFailure

model

class ToolFailure(*, kind: Annotated[str, MinLen(min_length=1), MaxLen(max_length=128)], message: Annotated[str, MaxLen(max_length=8192)], traceback: Annotated[str | None, MaxLen(max_length=65536)] = None, transient: bool = False, traceback_is_for_the_model: bool = False)

A tool failure, as data the model can read.

DESIGN.md §18 states this for code execution and it holds for every tool: a traceback is returned so the model can fix its approach, not raised so it kills the turn.

FieldTypeNotes
kindstr
messagestr
traceback`strNone`
transientbool
traceback_is_for_the_modelbool

RunId

value

def RunId(x, /)

NewType creates simple unique types with almost zero runtime overhead.

NewType(name, tp) is considered a subtype of tp by static type checkers. At runtime, NewType(name, tp) returns a dummy callable that simply returns its argument.

Usage::

UserId = NewType('UserId', int)

def name_by_id(user_id: UserId) -> str: ...

UserId('user') # Fails type check

name_by_id(42) # Fails type check name_by_id(UserId(42)) # OK

num = UserId(5) + 1 # type: int

VersionHash

value

def VersionHash(x, /)

NewType creates simple unique types with almost zero runtime overhead.

NewType(name, tp) is considered a subtype of tp by static type checkers. At runtime, NewType(name, tp) returns a dummy callable that simply returns its argument.

Usage::

UserId = NewType('UserId', int)

def name_by_id(user_id: UserId) -> str: ...

UserId('user') # Fails type check

name_by_id(42) # Fails type check name_by_id(UserId(42)) # OK

num = UserId(5) + 1 # type: int

AttemptId

value

def AttemptId(x, /)

NewType creates simple unique types with almost zero runtime overhead.

NewType(name, tp) is considered a subtype of tp by static type checkers. At runtime, NewType(name, tp) returns a dummy callable that simply returns its argument.

Usage::

UserId = NewType('UserId', int)

def name_by_id(user_id: UserId) -> str: ...

UserId('user') # Fails type check

name_by_id(42) # Fails type check name_by_id(UserId(42)) # OK

num = UserId(5) + 1 # type: int

StepId

value

def StepId(x, /)

NewType creates simple unique types with almost zero runtime overhead.

NewType(name, tp) is considered a subtype of tp by static type checkers. At runtime, NewType(name, tp) returns a dummy callable that simply returns its argument.

Usage::

UserId = NewType('UserId', int)

def name_by_id(user_id: UserId) -> str: ...

UserId('user') # Fails type check

name_by_id(42) # Fails type check name_by_id(UserId(42)) # OK

num = UserId(5) + 1 # type: int

ToolCallId

value

def ToolCallId(x, /)

NewType creates simple unique types with almost zero runtime overhead.

NewType(name, tp) is considered a subtype of tp by static type checkers. At runtime, NewType(name, tp) returns a dummy callable that simply returns its argument.

Usage::

UserId = NewType('UserId', int)

def name_by_id(user_id: UserId) -> str: ...

UserId('user') # Fails type check

name_by_id(42) # Fails type check name_by_id(UserId(42)) # OK

num = UserId(5) + 1 # type: int

WorkerId

value

def WorkerId(x, /)

NewType creates simple unique types with almost zero runtime overhead.

NewType(name, tp) is considered a subtype of tp by static type checkers. At runtime, NewType(name, tp) returns a dummy callable that simply returns its argument.

Usage::

UserId = NewType('UserId', int)

def name_by_id(user_id: UserId) -> str: ...

UserId('user') # Fails type check

name_by_id(42) # Fails type check name_by_id(UserId(42)) # OK

num = UserId(5) + 1 # type: int

On this page