Skip to content

symfonic.agent.fastapi.shared_resume

shared_resume

Selected-root POST /resume/{pause_token} over guarded durable resume.

Mounted only when GuardedResume verified the composition. Refusals happen before the token is spent and in this order: the verified principal must match the request scope; the token must authenticate (unknown 404, tampered 401, expired 410); its signed scope must be the request scope exactly; and the conversation authority must hold a pending pause this root recorded for this principal, tenant, session and claim, still at its turn's revision. The claim is re-checked inside the host's guarded redemption, so a pause that went stale between the check and the claim refuses too.

The body is the ticket's registered response schema as raw JSON (for example {"answer": "yes"}). Frames are the frozen typed wire. A completion is published to the paused conversation before its ResponseCompleteEvent frame; a second pause is recorded against the same turn before its frame. A token is single-use: a resumed turn that then fails is an at-most-once outcome and publishes nothing, and its pause row is settled as abandoned (shared_resume_turn). The final is read from the kernel result, so a resumed turn at the tool limit or with an empty answer is refused as the typed stream refuses it.

Deltas are framed as the turn streams, before publication. If publication then refuses -- for example a 409 because another worker published to the conversation after the claim-time check -- the client has already received text that is not, and will not be, in the transcript.

create_shared_resume_router

create_shared_resume_router(guarded: Any) -> APIRouter

Mount /resume/{pause_token}; the caller owns prefix and app.

Source code in src/symfonic/agent/fastapi/shared_resume.py
def create_shared_resume_router(guarded: Any) -> APIRouter:
    """Mount ``/resume/{pause_token}``; the caller owns prefix and app."""
    host, conversation = guarded.host, guarded.conversation
    router = APIRouter()

    @router.post("/resume/{pause_token}")
    async def resume(
        pause_token: str,
        raw_request: Request,
        scope: FrameworkTenantScope = Depends(get_tenant_scope),  # noqa: B008
    ) -> Any:
        try:
            principal = principal_of(raw_request, scope)
            ticket = await host.ticket(pause_token)
            session_id = await _pending_session(guarded, principal, pause_token, scope, ticket)
        except Exception as exc:
            return _error(ERROR_MAPPER.map(_native_error(exc), RESUME_POLICY))
        claim_id = ticket.claim_id
        try:
            body = await raw_request.json()
        except Exception:
            return _error(ErrorFrame(status=400, detail="Invalid JSON body"))
        try:
            answer = ticket.response_schema.model_validate(body)
        except ValidationError:
            return _error(ErrorFrame(status=400, detail="Invalid resume payload"))
        except Exception:
            return _error(ErrorFrame(status=500, detail="Resume payload validation failed"))
        budgeted = get_budget_tracker() is not None
        if budgeted:
            await check_budget_for_tenant(raw_request, str(scope.tenant_id))
        resumed = ResumedTurn(guarded, principal, session_id, scope, claim_id)

        async def owner_guard(
            issuer_scope: Any, signed_session: str, signed_claim: str, claiming: bool
        ) -> None:
            if (
                issuer_scope.scope_path != scope.scope_path
                or signed_session != session_id
                or signed_claim != claim_id
            ):
                raise SymfonicAgentError(
                    "continuation belongs to another conversation", code="forbidden"
                )
            if claiming:
                await conversation.pauses.pending(principal, session_id, claim_id)

        async def events() -> Any:
            source = host.resume_stream(
                pause_token,
                answer,
                scope=scope,
                owner_guard=owner_guard,
                admission=resumed.admission(budgeted=budgeted),
                on_claimed=resumed.mark_claimed,
            )
            try:
                async with aclosing(source):
                    with bind_conversation_turn(ConversationTurn(session_id, scope)):
                        async for event in source:
                            await resumed.settle(event)
                            yield event
            except Exception as exc:
                raise _native_error(exc) from exc
            finally:
                await resumed.release()

        return EventSourceResponse(
            SseFrameAdapter(RESUME_POLICY).frames(events(), encode_typed_event),
        )

    return router