symfonic.capabilities.human¶
human ¶
Human-interaction and pause-token capability (T3.4.3).
Pausing a run for a human used to be spread across seven places: a tool module, a graph node, two contract modules, a token facade with a process-wide default manager, three consumption stores, and two nearly-identical resume methods on the agent class — each with its own scope check and its own metadata key. The rules were only readable by reading all seven, and two of them could only be reached by constructing an agent.
Everything a pause decides now lives here:
- Registrations — :mod:
.registration.ask_useris a registration with three facts on it, not a second subsystem. - Binding — :mod:
.binding. The scope, session, and request checks, and the two legacy hash formulas they are checked against. - Tokens — :mod:
.tokens. Mint, authenticate, bind, consume — one ordering, in one place, with the claim last. - Consumption — :mod:
.consumption. Single-winner redemption as one atomic conditional write on the adopter's backend (LIB-TL-2), plus the in-process reference implementation and the SEC-PTK-7 durability check. - The ledger — :mod:
.ledger. The operated platform's authoritative issuance/consumption record, which is also its linearization point, over the issuance tables of :mod:.issuanceand with the verdict of :mod:.drain. - Checkpoint commands — :mod:
.checkpoints. Recording and recovering the paused request under the two legacy metadata keys, unchanged. - The checkpointer role — :mod:
.turnstateand :mod:.threads(HK2). The paused turn -- its prompt and its messages -- recorded under a third, additive key on the same port, and thetenant:sub:sessionthread key both routes now derive from one place. - Pausing — :mod:
.pausing. Thepre-toolhandler that stops a run, and the ordering that makesresumableearned rather than announced. - Resume — :mod:
.resume. One command contract for both families.
Authenticity is consumed, never redefined. The signed envelope, the signing
key lifecycle (including library mode's locally derived key), and the pin-less
legacy-artifact policy are T1.2.6/T2.3.6's, reached through
:class:~.contracts.EnvelopeSignerPort. This package imports nothing but
itself — the T1.2.1 matrix puts capability → runtime-service at no — and a
suite asserts it contains no hmac, no secrets, and no key material.
One winner, in both deployment modes. Operated platforms bind the issuance
ledger, whose atomic claim is the linearization point; library deployments
bind a conditional-write store over the adopter's persistence backend. Nothing
above :mod:.consumption can tell which it got, and the operated-only purposes
are reachable only through the object that has them.
AskUserPayload ¶
Bases: BaseModel
What the agent asks. One field, because one question is the whole tool.
AskUserResponse ¶
Bases: BaseModel
What the person answers.
BackendIssuanceRecords ¶
The same three tables on the operator's persistence backend.
Source code in src/symfonic/capabilities/human/issuance.py
CallBindingError ¶
Bases: PauseTokenError
The token was minted for another call of the same run (HK2).
ask_user correlates on tool_call_id and a registered interrupt on
interrupt_id; this is the refusal for both. A run that paused twice --
routine for an onboarding agent still working a checklist -- has two live
tokens whose run, session and scope are all identical, so the call id is the
only axis that separates them. Verifying the other three and not this one
would let the second question's answer be recorded against the first.
CheckpointCommandPort ¶
Bases: Protocol
The four checkpoint verbs a pause needs, separated from any saver.
The legacy engine reached into a LangGraph saver from three call sites with four differently-shaped config dicts. This is that surface, named.
CheckpointLostError ¶
ConditionalWriteConsumption ¶
ConditionalWriteConsumption(backend: Any, *, durable: bool | None = None, clock: Callable[[], float] = time.time)
LIB-TL-2 — one atomic conditional write on the adopter's backend.
The port has exactly one verb because a port that also offered a read would
invite the read-then-write sequence the contract forbids. claim is a
single await: there is no branch in this method that could become two
round-trips later.
Source code in src/symfonic/capabilities/human/consumption.py
ConditionalWritePort ¶
Bases: Protocol
The adopter persistence backend's atomic conditional insert (LIB-TL-2).
INSERT ... ON CONFLICT DO NOTHING RETURNING jti in SQL, a unique-_id
insert in Mongo, a single-lock section in the in-memory reference. One
method on purpose: a port that also offered a read would invite the
read-then-write sequence the contract forbids.
ConsumptionDurabilityError ¶
ConsumptionPort ¶
Bases: Protocol
Single-winner redemption of one jti (SEC-PTK-3).
One method, and its contract is the whole of the acceptance criterion:
concurrent callers competing for one jti see exactly one True, and
the decision is reached by a single atomic operation rather than by a read
followed by a write.
ConsumptionRecord
dataclass
¶
What a redemption writes. Ids only — no payload, no answer.
CrossScopeRedemption
dataclass
¶
CrossScopeRedemption(name: str, interrupt_id: str, jti: str, expected_scope_hash: str, presented_scope_hash: str, at: float)
An audit record for the one relaxation the contract permits.
DrainProof
dataclass
¶
DrainProof(drained: bool, outstanding: int, reason: str, horizon: float | None = None, deadline: float | None = None, scope: str = PROCESS)
Whether every legacy-pinned token has drained, and why not if not.
DuplicateInteractionError ¶
Bases: InteractionRegistrationError
Two registrations claim one name; there is no defensible winner.
EnvelopeSignerPort ¶
Bases: Protocol
T2.3.6's EnvelopeSigner: mint an authenticated envelope, verify one.
verify returns the invocation pin and raises on tampering, on an
unsupported layout, and on an unavailable keyset. This package never
inspects a signature, holds key material, or decides what "authentic"
means; it only decides what a token binds.
HumanInteractionCapability ¶
HumanInteractionCapability(*, registry: InteractionRegistry, tokens: PauseTokenService, payloads: PausePayloadStore, resumes: ResumeService, binding: Callable[[], Any] | None = None, encode_token: Callable[[Any], str] | None = None, decode_token: Callable[[str], Any] | None = None, turns: TurnCheckpointStore | None = None)
Registrations, tokens, checkpoint commands, and resume, wired once.
Source code in src/symfonic/capabilities/human/capability.py
active
property
¶
True when at least one interaction is registered.
A deployment that registered none must not advertise ask_user: a
tool whose only possible outcome is a refusal is an invitation to pause
a run that can never be resumed.
compile
classmethod
¶
compile(registrations: Iterable[InteractionRegistration], *, signer: Any, ttl: TTLPolicy, consumption: Any = None, ledger: Any = None, checkpoints: Any = None, clock: Callable[[], float] = time.time, pinless_policy: Any = None, audit: Callable[[CrossScopeRedemption], None] | None = None, require_durable: bool = True, binding: Callable[[], Any] | None = None, encode_token: Callable[[Any], str] | None = None, decode_token: Callable[[str], Any] | None = None) -> HumanInteractionCapability
Wire the capability. Every argument is a port or a policy.
Source code in src/symfonic/capabilities/human/capability.py
contribute ¶
Offer the pause point to the turn being compiled (HK1, TA8.34).
A stage and a tool, and :mod:~symfonic.capabilities.human.contribution
holds the reasoning for why both: the tool is how the model asks, the
pre-tool stage is where the run actually stops, and only the stage
sees the reserved call id a pause has to be bound to.
Contributing nothing is a real answer, the same one
DelegationCapability gives for a parent with no children. Three
states produce it, and each is a deployment that cannot serve a pause:
nothing registered, no per-run :class:PauseBinding resolver, or no
way to render a minted envelope as the opaque token a consumer answers
with. Advertising ask_user in any of them would invite the model to
stop a run that could never be resumed -- and a run stopped by a pause
nobody can answer is strictly worse than one that never stopped. The
contribution still carries the capability's name, so a plan records
that human interaction was folded and found nothing rather than that it
was never folded.
request is read for its grants and found to need none. Minting a
token, recording its payload and writing the issuance row all go
through this capability's own ports, which the composition root wired;
none of them is an :class:~symfonic.kernel.contracts.effects.EffectFamily
the plan grants, and declaring one it was not granted is refused at
fold rather than at the point of the effect (STG-8).
Source code in src/symfonic/capabilities/human/capability.py
pause
async
¶
pause(*, pin: Any, scope: Any, run_id: str, session_id: str, thread_id: str, payload: Any, root_run_id: str = '', name: str = ASK_USER, tool_call_id: str = '', checkpoint_id: str | None = None, ttl_seconds: float | None = None, legacy_pinned: bool = False, turn_state: TurnCheckpoint | None = None) -> InteractionEvent
Mint a pause, record its payload, and record the turn it stopped.
turn_state is what makes the pause redeemable (HK2). It is optional
because a caller may have nothing continuable to record -- a transport
minting a pause outside a kernel turn, for instance -- and the returned
event then says resumable=False rather than pretending otherwise.
Source code in src/symfonic/capabilities/human/capability.py
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 | |
HumanInteractionError ¶
Bases: Exception
Base for everything this capability raises.
InMemoryConsumption ¶
The in-process reference implementation (LIB-TL-2's single-lock case).
Correct within one process and honest about the rest: durable is
False, and :func:require_durable_consumption is what stops a
deployment with durable checkpoints from quietly using it.
Source code in src/symfonic/capabilities/human/consumption.py
InProcessIssuanceRecords ¶
Three dicts. Correct for one process, and unwilling to claim more.
Source code in src/symfonic/capabilities/human/issuance.py
InteractionConfigurationError ¶
Bases: HumanInteractionError, ValueError
The deployment wired something that cannot work, before any run starts.
InteractionEvent
dataclass
¶
InteractionEvent(name: str, payload: Any, pause: MintedPause, run_id: str, session_id: str, interrupt_id: str = '', tool_call_id: str = '', resumable: bool = False)
The pause a transport serialises: it owns the wire format, this the facts.
InteractionRegistration
dataclass
¶
InteractionRegistration(name: str, payload_schema: Any, response_schema: Any, validate_response: Callable[[Any, Any], None] | None = None, cross_scope_allowed: bool = False, built_in: bool = False, metadata: Mapping[str, Any] = dict())
One registered pause point, and everything both ends of it need.
ask_user
classmethod
¶
ask_user(*, payload_schema: Any, response_schema: Any, validate_response: Callable[[Any, Any], None] | None = None, cross_scope_allowed: bool = False, metadata: Mapping[str, Any] | None = None) -> InteractionRegistration
The built-in, in the one shape it is allowed to have.
Source code in src/symfonic/capabilities/human/registration.py
InteractionRegistrationError ¶
Bases: HumanInteractionError, ValueError
A named interaction is not describable, so nothing may register it.
InteractionRegistry ¶
The one place a name is resolved, on both ends of a pause.
Source code in src/symfonic/capabilities/human/registration.py
names ¶
InteractionSchema ¶
Bases: Protocol
A payload or response schema: anything that validates and returns.
InteractionToolSpec
dataclass
¶
InteractionToolSpec(name: str, description: str, coroutine: Callable[..., Any], parameters: tuple[str, ...] = ())
One interaction, described as a tool the composition root can bind.
A description and not a runtime tool object, for the reason
:class:~symfonic.capabilities.delegation.contracts.DelegationToolSpec is
one: a capability that constructed a StructuredTool would put a
third-party tool library on the import path of a package with no other use
for it. symfonic.agent.cutover.delegation.bind_contributed_tool is the
step that wraps it, at the composition root, which is where the runtime's
tool type is known.
IssuanceLedgerPort ¶
Bases: ConsumptionPort, Protocol
The operated platform's authoritative record, which is also the winner.
A ledger is a consumption port: making issuance and consumption the same object is what gives operated mode one linearization point rather than two places that each believe they decide. The extra verbs — issuance, drain proof, retirement horizon — are the operated-only purposes (LIB-TL-4), and a library deployment reaches them by not having this object at all.
IssuanceRecordBackendPort ¶
Bases: ConditionalWritePort, Protocol
Where an operated platform keeps its issuance table (SEC-PTK-7, SCP-FRZ-2).
Wider than :class:ConditionalWritePort, and for a reason that does not
apply there: redemption must never read before it writes, but the issuance
record and the retirement horizon exist precisely to be read back — by the
next worker, and by this one after a restart. A ledger given only the
conditional write keeps them in memory, and says so rather than reporting a
reach it does not have.
IssuedToken
dataclass
¶
IssuedToken(jti: str, scope_hash: str, name: str, issued_at: float, expires_at: float, legacy_pinned: bool = False, vector_hash: str = '')
One issuance row. Ids, times, and the one bit the drain gate reads.
MintedPause
dataclass
¶
A signed envelope and the claims inside it, together.
OperatedPlatformOnlyError ¶
PauseBinding
dataclass
¶
PauseBinding(pin: Any = None, scope: Any = None, run_id: str = '', root_run_id: str = '', session_id: str = '', thread_id: str = '', checkpoint_id: str | None = None)
What a pause has to be bound to, resolved per run by the composition root.
Every field is a fact about this run and none of them is known when the
plan is compiled, which is why this arrives through a callable rather than
on the capability: a bundle folded once at construction would bind every
turn to the first turn's tenant, run and thread. That is the same defect
TurnRequest.scope exists to correct, one capability over.
checkpoint_id may be None, in which case the capability resolves it
through its checkpoint port. A deployment with neither cannot pause, and
:meth:HumanInteractionCapability.pause refuses rather than minting a
token bound to no checkpoint.
PauseCheckpointNotFoundError ¶
PauseClaims
dataclass
¶
PauseClaims(run_id: str, session_id: str, scope_hash: str, thread_id: str, checkpoint_id: str, request_hash: str, exp: int, jti: str, root_run_id: str = '', tool_call_id: str = '', name: str = 'ask_user', interrupt_id: str = '', issued_at: float = 0.0, legacy_pinned: bool = False)
What the token binds: who, which run, which thread, which request.
as_legacy_dict ¶
Exactly the legacy claim names, for a reader that predates this.
decode
classmethod
¶
Read a verified body. Called only after the envelope verified.
Source code in src/symfonic/capabilities/human/values.py
encode ¶
The canonical wire body the envelope signs over.
Source code in src/symfonic/capabilities/human/values.py
expired ¶
from_legacy_dict
classmethod
¶
Build from a claim mapping, refusing anything it cannot account for.
Source code in src/symfonic/capabilities/human/values.py
PausePayloadStore ¶
Records and recovers the paused request through one checkpoint port.
A deployment with no checkpointer is supported for recording — the write is best-effort, exactly as it was, because not every saver takes pending writes and a failed metadata write must never take down a healthy pause. Recovery is not best-effort: without the recorded payload there is nothing to check the request hash against, and guessing is the failure SEC-PTK-5 exists to prevent.
Source code in src/symfonic/capabilities/human/checkpoints.py
load
async
¶
Recover the paused request and check it against the token's hash.
Source code in src/symfonic/capabilities/human/checkpoints.py
record
async
¶
Best effort, and it reports which effort it made.
Source code in src/symfonic/capabilities/human/checkpoints.py
PauseTokenError ¶
PauseTokenExpiredError ¶
PauseTokenReplayedError ¶
PauseTokenService ¶
PauseTokenService(*, signer: Any, ttl: TTLPolicy, consumption: Any = None, ledger: Any = None, clock: Callable[[], float] = time.time, pinless_policy: Any = None, binder: ScopeBinder | None = None)
Mint, validate, and consume pause tokens against exactly one winner-seam.
Source code in src/symfonic/capabilities/human/tokens.py
authenticate ¶
Steps 1–3: verify, decode, expire. No state is touched.
Verification precedes decoding so a forged envelope cannot choose how it is read; expiry is checked after decoding because the expiry claim is part of what the signature covers, so trusting it earlier would let a forgery declare itself fresh.
Source code in src/symfonic/capabilities/human/tokens.py
bind ¶
bind(claims: PauseClaims, *, scope: Any, session_id: str | None = None, run_id: str | None = None, call_id: str | None = None, registration: Any = None) -> bool
Step 4 — the four axes (HK2). Returns whether scopes were crossed.
None is "not stated" for the last three; the binder says why each is
separately refusable. The registration is the only thing that can open
cross-scope redemption, and it is passed in -- rather than looked up, or
reduced to a boolean a caller could pass -- so a caller cannot bind
against a different posture than the one it validates the answer with.
Source code in src/symfonic/capabilities/human/tokens.py
consume
async
¶
Step 5 — the single atomic claim. Losing it is a replay, not a fault.
Source code in src/symfonic/capabilities/human/tokens.py
drain_proof
async
¶
CUT-AIR-3 — proof that legacy-pinned tokens have drained.
mint
async
¶
mint(*, pin: Any, scope: Any, run_id: str, session_id: str, thread_id: str, checkpoint_id: str, payload: Any, root_run_id: str = '', name: str = ASK_USER, tool_call_id: str = '', interrupt_id: str | None = None, ttl_seconds: float | None = None, legacy_pinned: bool = False) -> MintedPause
Bind a pause to this scope, session, and request, and sign it.
Source code in src/symfonic/capabilities/human/tokens.py
record_retirement_horizon
async
¶
SCP-FRZ-2 — the date past which nothing may be extended.
Source code in src/symfonic/capabilities/human/tokens.py
scope_hash ¶
validate
async
¶
validate(envelope: Any, *, scope: Any, session_id: str | None = None, run_id: str | None = None, call_id: str | None = None, at: float | None = None, registration: Any = None) -> ValidatedPause
Authenticate, then bind. Still consumes nothing.
Source code in src/symfonic/capabilities/human/tokens.py
validate_and_consume
async
¶
validate_and_consume(envelope: Any, *, scope: Any, session_id: str | None = None, run_id: str | None = None, call_id: str | None = None, at: float | None = None, registration: Any = None) -> ValidatedPause
The whole ordering, for a caller with nothing to check in between.
Source code in src/symfonic/capabilities/human/tokens.py
PauseTokenUnauthorizedError ¶
Bases: PauseTokenError
The token does not authenticate: forged, tampered, or unreadable.
One refusal that reaches here is not about the token at all: when the
verification keyset is unavailable the signer must deny, and denying is all
it can do — but telling the holder "your token is bad" while the secret
manager is down is a lie, and one their retry logic acts on. So the reason
may override the code (EMAP-6): the transport reads code and answers
503 + Retry-After instead of 401. The class is unchanged because the
security decision is unchanged; only the explanation is now honest.
Source code in src/symfonic/capabilities/human/errors.py
from_signer_refusal
classmethod
¶
The denial a failed verify becomes, carrying the reason's code.
Lives beside the class rather than at the call site so there is one place where "which refusals are about the token?" is answered, and so the token service does not have to know that a signer's dependency has a taxonomy at all.
Source code in src/symfonic/capabilities/human/errors.py
PayloadBindingError ¶
ResponseValidationError ¶
ResumeCommand
dataclass
¶
ResumeCommand(envelope: Any, response: Any, scope: Any, session_id: str | None = None, run_id: str | None = None, call_id: str | None = None)
One redemption attempt, in the shape a transport can build from a request.
session_id, run_id and call_id are the three axes beside scope
that a redemption is checked against (HK2). Each defaults to None,
meaning "this transport did not state it", and an unstated axis is not
checked -- which is why :mod:~symfonic.agent.cutover.kernel_resume builds
this value with all four and refuses to build one without them. The default
belongs to a caller that genuinely has no such fact; it must not be how a
caller that should have had one silently opts out of the check.
ResumeOutcome
dataclass
¶
ResumeOutcome(name: str, thread_id: str, checkpoint_id: str, payload: Any, response: Any, run_id: str, session_id: str, tool_call_id: str = '', interrupt_id: str = '', cross_scope: bool = False, time_to_resolve_seconds: float = 0.0, turn: Any = None)
What a graph runner needs to continue, and what telemetry needs to record.
as_configurable ¶
The run-config shape the resume executes against.
ResumeService ¶
ResumeService(*, tokens: PauseTokenService, registry: InteractionRegistry, payloads: PausePayloadStore, clock: Callable[[], float] = time.time, audit: Callable[[CrossScopeRedemption], None] | None = None, turns: TurnCheckpointStore | None = None)
Turns one redemption attempt into either an outcome or a named refusal.
Source code in src/symfonic/capabilities/human/resume.py
resume
async
¶
Redeem one token. require_turn is the caller's demand, not the
request's.
A route that will continue the run needs the recorded turn state and must be refused, by name, when there is none. A route that only needs the answer validated -- the legacy body, which continues through its own saver -- must not be, or every token minted before this build stops resolving the moment the deployment upgrades. Same six checks, same order, one decision about what counts as missing.
Source code in src/symfonic/capabilities/human/resume.py
RetirementHorizon
dataclass
¶
SCP-FRZ-2: the date, and the operator's reason for it.
RetirementHorizonError ¶
RunBindingError ¶
Bases: PauseTokenError
The token was minted for another run of the same session (HK2).
Its own class rather than a SessionBindingError, because the two say
different things to an operator. A session mismatch is a caller answering
from the wrong conversation; a run mismatch is a caller answering the right
conversation's previous question -- a stale browser tab, a retried
request, a queue that replayed. Both refuse; only one of them suggests
somebody is looking at an old page.
ScopeBinder ¶
Checks the bindings a pause token carries. Holds no state.
call_id_of
staticmethod
¶
Which id this pause correlates on -- the split, in one expression.
ask_user joins on the reserved tool_call_id and a registered
interrupt on its own interrupt_id; a claim carries whichever its
family minted. Derived from the claims rather than from which subsystem
is asking, exactly as
:func:~symfonic.capabilities.human.checkpoints.payload_key derives the
metadata key.
Source code in src/symfonic/capabilities/human/binding.py
check ¶
check(claims: PauseClaims, *, scope: Any, session_id: str | None = None, run_id: str | None = None, call_id: str | None = None, cross_scope_allowed: bool = False) -> bool
Return whether this redemption crossed scopes. Raise when it may not.
Four axes, checked one at a time (HK2). Scope says which tenant, session says which conversation, run says which turn of it, and the call id says which of that turn's questions. They are four independent facts and each is separately refusable, because a redemption that satisfies three of them is a real, reachable mistake rather than a hypothetical: an operator with two paused runs open answers the wrong tab (run), an onboarding agent that asked twice gets the second answer filed against the first question (call), and a shared-inbox admin answers a colleague's session (session). Only the tenancy axis has ever had a bypass.
None means "not stated" for the last three, and a caller that does
not state them gets no check for them. That is why the resume route
states all four rather than trusting this default: the default is what a
transport left out, and a transport that forgets an axis must not be
the thing that decides the axis does not matter.
The session, run and call checks run regardless of
cross_scope_allowed: crossing scopes is about who redeems (a
tenant admin approving a sub-tenant's action), never about which run
gets resumed. Relaxing them all at once would let one opt-in reach every
paused run in the deployment.
Source code in src/symfonic/capabilities/human/binding.py
check_payload ¶
SEC-PTK-5 — the paused request is the one the token was minted for.
Source code in src/symfonic/capabilities/human/binding.py
payload_body_matches ¶
Whether a recorded body hashes to the token's request_hash.
request_body ¶
ScopeBindingError ¶
SessionBindingError ¶
TTLPolicy
dataclass
¶
The lifetime a pause may have, and the bound nothing may exceed.
The maximum is the same number the operated ledger uses to bound its drain proof, which is why refusing is the only correct answer to a request past it: a clamped token would tell the caller they have a window they do not, and would make the drain deadline a guess.
TokenConsumption
dataclass
¶
One redemption row: who won, and when. Losers are not recorded here.
TokenIssuanceLedger ¶
TokenIssuanceLedger(*, maximum_ttl_seconds: float, clock: Callable[[], float] = time.time, store: Any = None, records: Any = None)
Issuance, consumption, the maximum-TTL bound, and the retirement horizon.
Source code in src/symfonic/capabilities/human/ledger.py
deployment_wide
property
¶
Whether both tables are shared, rather than this worker's memory.
drain_deadline
async
¶
The horizon plus the maximum TTL: the last moment anything can live.
Source code in src/symfonic/capabilities/human/ledger.py
outstanding
async
¶
Issued, unconsumed, and not yet expired.
Source code in src/symfonic/capabilities/human/ledger.py
record_retirement_horizon
async
¶
SCP-FRZ-2 — the date after which nothing legacy-pinned may outlive.
Source code in src/symfonic/capabilities/human/ledger.py
TokenLedgerError ¶
TokenTTLError ¶
Bases: HumanInteractionError, ValueError
A lifetime outside the configured bound. Never clamped, always refused.
TurnCheckpointStore ¶
Records a paused turn's continuable state, and reads it back.
The whole of the checkpointer role, in one object with two verbs. It holds no state of its own: everything it knows it asks the port for, which is what makes a different process asking the same port get the same answer.
Source code in src/symfonic/capabilities/human/turnstate.py
find
async
¶
The recorded state, or None when there simply is not one.
The half of :meth:load a route-agnostic resume needs. The legacy
route continues a paused run through LangGraph's own saver and never
wanted this record, so a token minted for it has none -- and answering
that with a refusal would break the one thing HK2 must not break, which
is that a pause in flight when a deployment upgrades still resolves.
Only the three absences are folded into None. A recorded state
that is unreadable, or that describes another turn, still raises: those
are not "there is nothing here", they are "there is something here and
it is wrong", and continuing past either is how a resume answers into a
transcript nobody checked.
Source code in src/symfonic/capabilities/human/turnstate.py
load
async
¶
Rebuild the paused turn's state from the port. Never from memory.
Every fact returned came out of the checkpoint store on this call, which is what makes a process that did not pause the run able to continue it. Nothing is cached here and nothing is remembered between calls; a store that lost the row answers the same way for the process that wrote it as for any other.
Source code in src/symfonic/capabilities/human/turnstate.py
record
async
¶
Write the turn state. Reports whether it landed; never raises for it.
False is a real answer with a caller that acts on it: the pause is
still minted, still published and still shows the person the question --
it simply declares resumable=False, because nothing on the other end
could rebuild the turn. Raising instead would end a turn that a human
can still usefully be asked, and returning True regardless is the
lie HK1 shipped resumable=False to prevent, with the sign flipped.
Source code in src/symfonic/capabilities/human/turnstate.py
TurnStateNotRecordedError ¶
Bases: HumanInteractionError
A recorded turn state does not describe the turn the token binds (HK2).
UnissuedTokenError ¶
Bases: PauseTokenError
The authoritative ledger never issued this token, so it does not exist.
A separate class from :class:PauseTokenUnauthorizedError even though both
deny: this one means the envelope was fine and the ledger still says no,
which is a deployment/routing question, not a forgery.
UnknownInteractionError ¶
Bases: HumanInteractionError, KeyError
Nothing is registered under that name.
Also a :class:KeyError because the shipped registry was a dict and an
adopter's except KeyError around a lookup is a reasonable thing to have.
ValidatedPause
dataclass
¶
What survived authentication, expiry, and binding.
ask_user_registration ¶
The ask_user interaction, with schemas that actually validate.
cross_scope_allowed stays false: a token minted for one scope being
redeemable in another is the cross-scope redemption the ledger exists to
record, and it is not something a default should hand out.
Source code in src/symfonic/capabilities/human/factory.py
hash_payload ¶
hash_scope ¶
The legacy PauseToken.hash_scope formula, unchanged.
Read structurally — the tenancy scope type lives outside this layer and must
not be imported into it. An object with no tenant_id is refused rather
than hashed as the empty tenant, which would make every unauthenticated
caller share one scope.
Source code in src/symfonic/capabilities/human/binding.py
human_interaction ¶
human_interaction(*, signer: Any, binding: Callable[[], Any], encode_token: Callable[[Any], str], decode_token: Callable[[str], Any] | None = None, registrations: Iterable[InteractionRegistration] | None = None, ttl: TTLPolicy | None = None, consumption: Any = None, **options: Any) -> HumanInteractionCapability
Build the capability that lets a turn pause and be resumed.
Agent(provider, capabilities=[human_interaction(
signer=my_signer,
binding=lambda: PauseBinding(run_id=..., session_id=...),
encode_token=my_encoder,
)])
Three arguments are required because only the deployment can supply them, and because the capability contributes nothing without them.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
signer
|
Any
|
mints and verifies pause tokens. No default: a shared one would make every deployment honour every other's tokens. |
required |
binding
|
Callable[[], Any]
|
resolves what this run is, per run. No default: a
|
required |
encode_token
|
Callable[[Any], str]
|
renders a minted envelope as the opaque string a person answers with. No default: the token format is a deployment's choice, not the framework's. |
required |
decode_token
|
Callable[[str], Any] | None
|
the inverse. Optional, and its absence has a cost -- without it a pause taken on the kernel route has no public reader, so the run can be stopped and not continued. |
None
|
registrations
|
Iterable[InteractionRegistration] | None
|
which interactions this agent offers. Defaults to
|
None
|
ttl
|
TTLPolicy | None
|
how long a pause may stay open. Defaults to :data: |
None
|
consumption
|
Any
|
where spending a token is recorded. Defaults to a
single-process store; see :func: |
None
|
**options
|
Any
|
forwarded to |
{}
|
Raises:
| Type | Description |
|---|---|
InteractionConfigurationError
|
if the composed capability would contribute nothing, naming what is missing. |
Source code in src/symfonic/capabilities/human/factory.py
payload_key ¶
Where this pause's payload lives in checkpoint metadata.
Source code in src/symfonic/capabilities/human/checkpoints.py
redemption_is_shared ¶
Whether two replicas would agree that a token has been spent.
False means each replica holds its own record, so a token redeemed on
one can be redeemed again on another. That is a legitimate choice for a
single-process deployment and an unpleasant surprise for any other, which
is the reason it is reported rather than assumed.
Source code in src/symfonic/capabilities/human/factory.py
require_durable_consumption ¶
SEC-PTK-7 — durable checkpoints must not get a volatile jti store.
A deployment whose checkpoints survive a restart and whose consumed set does not has single-use enforcement only until the next deploy: every token minted before the restart becomes redeemable a second time, and nothing in the logs says so.
A fully volatile deployment is supported, not an error — that is the developer laptop, and its tokens die with the process anyway.
Source code in src/symfonic/capabilities/human/consumption.py
thread_id_for ¶
tenant:sub:session -- the legacy formula, character for character.
Read structurally, like every other scope reader in this package: the tenancy scope type lives outside this layer and importing it would make the capability depend on the engine it is being extracted from.
A missing tenant_id or session_id is refused rather than folded into
an empty string. ":_:" and "acme:_:" are perfectly valid-looking
keys that every unattributed caller would share, which is the same failure
:func:~symfonic.capabilities.human.binding.hash_scope refuses one layer
over -- and here it would be a durable one, since the checkpoints filed
under such a key outlive the run that wrote them.
Source code in src/symfonic/capabilities/human/threads.py
turn_state_key ¶
Where this pause's turn state lives.
Keyed by jti rather than by tool_call_id or interrupt_id, which
is the one place this module deliberately departs from
:func:~symfonic.capabilities.human.checkpoints.payload_key. Those two keys
are frozen because tokens in flight point at them; this key is new, so it is
free to be keyed by the thing that is unique per mint. A round that paused
twice on the same reserved call -- a resumed run that pauses again on a
retry of the same call id -- would otherwise overwrite the first pause's
state with the second's, and the first token would then continue from a
transcript that already contains its own answer.