Skip to content

symfonic.platform.continuation

continuation

Resuming a paused turn, as contracts (task-1-3-1).

A turn can stop to ask a person. The kernel already treats that as a terminal rather than an error -- InvocationPaused carries a PendingInterrupt and InvocationRunner.events turns it into the run's last event, deliberately not an error and not followed by done, because a pause reported as a failure tells an adopter's error handling the turn broke, and one reported as done hands a consumer a result the run never produced.

What is missing is the other half. symfonic.Agent exposes run, stream and close; there is no way to continue what a pause stopped. So a paused turn on the migrated route is created and unresumable, and the only working resume is the compatibility facade's -- which takes a scope per call, and cannot be what a scaffold uses if the scaffold is to stop naming that facade.

The stores already exist and are public. SessionService, CheckpointService, TranscriptService and their ports are all declared in :mod:symfonic.services.conversation. Nothing here re-declares them; what these contracts add is the seam that resumes a turn through the host, which is where the scope and the agent already live.

Where the scope comes from is the whole design. The facade's resume takes one per call. That cannot be copied here: the published facade decision forbids per-turn scope on Agent, and a host that took a scope on resume would have two answers to "which tenant is this" -- the one the token was issued under and the one the caller passed. A pause token is issued inside a scope, so the scope is recoverable from the token and the caller states only the token. A caller who could name a different one could resume another tenant's paused turn by holding its token.

ContinuationService

Bases: Protocol

Resumes what a pause stopped, over the host's own agents and stores.

Deliberately not a method on Agent. An agent is bound to one scope and does not know which of its turns are outstanding; resuming needs a token to be looked up, a scope to be recovered from it, and the agent for that scope to be found -- three things the host has and an agent does not.

resume async

resume(token: str, answer: Any) -> Any

Continue the paused turn with answer and return its result.

Idempotency is the contract's hardest clause and it belongs here rather than in a transport: a person answering twice, or a client retrying, must not run the rest of the turn twice. An implementation either completes once and replays the result, or refuses the second attempt by name -- and it must say which, because "the answer was accepted" and "the answer was accepted again" look identical to a caller otherwise. The supported human adapter chooses at most once: it prepares the scoped agent before redemption, then rejects a second use after the durable claim; it does not replay effects if the resumed invocation itself later fails.

Raises:

Type Description
UnknownPauseToken

if the token names no resumable turn.

Source code in src/symfonic/platform/continuation.py
async def resume(self, token: str, answer: Any) -> Any:
    """Continue the paused turn with ``answer`` and return its result.

    Idempotency is the contract's hardest clause and it belongs here
    rather than in a transport: a person answering twice, or a client
    retrying, must not run the rest of the turn twice. An implementation
    either completes once and replays the result, or refuses the second
    attempt by name -- and it must say which, because "the answer was
    accepted" and "the answer was accepted again" look identical to a
    caller otherwise. The supported human adapter chooses **at most once**:
    it prepares the scoped agent before redemption, then rejects a second
    use after the durable claim; it does not replay effects if the resumed
    invocation itself later fails.

    Raises:
        UnknownPauseToken: if the token names no resumable turn.
    """
    ...

ticket async

ticket(token: str) -> PauseTicket

The paused turn token names.

Raises:

Type Description
UnknownPauseToken

if it names none, or has expired.

Source code in src/symfonic/platform/continuation.py
async def ticket(self, token: str) -> PauseTicket:
    """The paused turn ``token`` names.

    Raises:
        UnknownPauseToken: if it names none, or has expired.
    """
    ...

ContinuationUnavailable

Bases: RuntimeError

Raised when a host was not composed with durable continuation wiring.

PauseTicket

Bases: Protocol

What a paused turn hands back, and what resuming it needs.

The token is the whole of the caller's half. Everything else on this protocol is recovered from it rather than supplied: the scope the pause was issued under, the session it belongs to, and what the turn was waiting for. A caller that supplied any of them could contradict the token, and the contradiction would have to be resolved by trusting one of them.

question property

question: Any

What the turn stopped to ask. Shaped by whoever registered the pause point; the platform does not interpret it.

scope property

scope: Any

The scope the pause was issued under. Recovered, never supplied.

token property

token: str

The opaque handle a transport gives back to whoever must answer.

UnknownPauseToken

Bases: LookupError

Raised when a token names no resumable turn.

A distinct type because the caller's correct response differs and cannot be derived from a message: an expired token is an ordinary outcome a transport turns into a 404 or a "this conversation moved on", while a malformed one is a bug in whoever minted it. Both are refusals, and only one is worth waking somebody for.