Skip to content

symfonic.capabilities.human.resume

resume

The resume command contract โ€” one path for ask_user and generic interrupts.

The shipped engine had two resume methods with the same six steps in the same order and three differences between them, two of which were accidental. Here there is one, and the differences are read off the registration.

Consume comes last on purpose. Every step before it can be retried by an honest caller who mistyped an answer; the claim is the one step that cannot. Putting it earlier would mean a malformed payload burns a token the user then cannot re-answer โ€” which turns a validation error into "ask the question again".

HK2 (TA8.35) added a seventh step in the same position for the same reason: the paused turn's state is rebuilt from the checkpointer before the claim, so a resume that fails part way through consumes nothing and the token still redeems.

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
def __init__(
    self,
    *,
    tokens: PauseTokenService,
    registry: InteractionRegistry,
    payloads: PausePayloadStore,
    clock: Callable[[], float] = time.time,
    audit: Callable[[CrossScopeRedemption], None] | None = None,
    turns: TurnCheckpointStore | None = None,
) -> None:
    self._tokens = tokens
    self._registry = registry
    self._payloads = payloads
    self._clock = clock
    self._audit = audit
    self._turns = turns

resume async

resume(command: ResumeCommand, *, require_turn: bool = False) -> ResumeOutcome

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
async def resume(
    self, command: ResumeCommand, *, require_turn: bool = False
) -> ResumeOutcome:
    """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.
    """
    now = self._clock()
    # 1-3. Authenticity, then the claims, then expiry. Nothing stateful yet.
    authenticated = self._tokens.authenticate(command.envelope, at=now)
    claims = authenticated.claims

    # 4. The registration decides the scope posture, so it is resolved
    #    before the binding check rather than after it. Four axes since
    #    HK2 -- scope, session, run and call -- each separately refusable.
    registration = self._registry.get(claims.name)
    cross_scope = self._tokens.bind(
        claims,
        scope=command.scope,
        session_id=command.session_id,
        run_id=command.run_id,
        call_id=command.call_id,
        registration=registration,
    )
    if cross_scope:
        self._record_cross_scope(claims, command, at=now)

    # 5. The paused request, checked against the hash the token carries.
    payload = await self._payloads.load(claims)
    payload = self._validate_payload(registration, payload)

    # 6. The answer, against its own schema and then against the question.
    response = self._validate_response(registration, command.response, payload)

    # 7. The paused turn's state, rebuilt from the checkpointer (HK2).
    #    Before the claim, with everything else that can fail: a token burnt
    #    on a checkpoint read that timed out is a question the person has
    #    already answered and can no longer answer again.
    turn = await self._load_turn(claims, require_turn)

    # 8. The single-use claim. Last, and exactly once.
    await self._tokens.consume(claims)

    return ResumeOutcome(
        name=claims.name,
        thread_id=claims.thread_id,
        checkpoint_id=claims.checkpoint_id,
        payload=payload,
        response=response,
        run_id=claims.run_id,
        session_id=claims.session_id,
        tool_call_id=claims.tool_call_id,
        interrupt_id=claims.interrupt_id,
        cross_scope=cross_scope,
        time_to_resolve_seconds=max(0.0, now - claims.issued_at)
        if claims.issued_at
        else 0.0,
        turn=turn,
    )