Skip to content

symfonic.capabilities.human.pausing

pausing

The handler that turns a reserved interaction call into a stopped run.

Split out of :mod:~symfonic.capabilities.human.contribution when HK2 gave the pause a second thing to record. contribution is the declaration -- what this capability offers a compiler, and why each half of it exists -- and this module is the one behaviour that declaration routes to. They were one module while the behaviour was "mint a token and raise"; the reasoning for the split is the same line-budget rule that put typed_interrupts next door to typed_projection rather than inside it.

The ordering below is the whole of HK2's "earned, not announced" property, and it is an ordering rather than a flag: the handler asks the capability to pause, the capability writes the turn state and reports whether the write landed, and only that report becomes PendingInterrupt.resumable. Nothing in this module can set it to True on its own, which is what makes the property structural instead of a rule somebody has to remember.

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.

pause_handler

pause_handler(capability: Any, binding: Callable[[], PauseBinding | None], encode_token: Callable[[Any], str]) -> Callable[[Any], Any]

Build the handler that turns a reserved interaction call into a pause.

Source code in src/symfonic/capabilities/human/pausing.py
def pause_handler(
    capability: Any,
    binding: Callable[[], PauseBinding | None],
    encode_token: Callable[[Any], str],
) -> Callable[[Any], Any]:
    """Build the handler that turns a reserved interaction call into a pause."""

    async def handle(context: Any) -> StageResult[Any]:
        requests: Sequence[Any] = tuple(getattr(context, "requests", ()) or ())
        registered = frozenset(capability.names())
        pending = [
            request
            for request in requests
            if getattr(request, "name", "") in registered
        ]
        if not pending:
            return no_change("this round asked for no registered interaction")

        resolved = binding()
        if resolved is None:
            # Declining, not failing. The tool body answers the model with a
            # refusal it can act on; a raise here would end the turn on a
            # deployment gap the model could have worked around.
            return no_change(
                "no pause binding was resolved for this run, so nothing can be "
                "bound to a scope, a thread, or a checkpoint"
            )

        # The first one. A round asking for two interactions at once stops at
        # the first: the run is stopping either way, and minting a second token
        # would issue a pause nothing will ever consume -- an outstanding row
        # in the issuance ledger for a question that is never asked.
        request = pending[0]
        name = request.name
        try:
            event = await capability.pause(
                pin=resolved.pin,
                scope=resolved.scope,
                run_id=resolved.run_id,
                root_run_id=(
                    resolved.root_run_id
                    or str(getattr(getattr(context, "request", None), "root_run_id", "") or "")
                    or resolved.run_id
                ),
                session_id=resolved.session_id,
                thread_id=resolved.thread_id,
                payload=_stamped(capability, name, _payload_of(request)),
                name=name,
                tool_call_id=getattr(request, "call_id", ""),
                checkpoint_id=resolved.checkpoint_id,
                turn_state=_turn_state(context, requests),
            )
        except HumanInteractionError as refused:
            # The second decline (module docstring). ``pause`` validates
            # whatever the *model* wrote -- the bound signature takes any
            # ``list[dict]`` -- so propagating would end the turn on an LLM
            # typo. Narrow: a miswired port's ``TypeError`` still crashes here.
            return no_change(
                f"the {name!r} pause was refused and no token was minted, so "
                f"nothing can be bound to this call: {refused}"
            )
        raise InvocationPaused(
            PendingInterrupt(
                name=name,
                kind="ask_user" if name == ASK_USER else "interrupt",
                payload=event.payload,
                token=encode_token(event.pause),
                tool_call_id=event.tool_call_id,
                interrupt_id=event.interrupt_id,
                run_id=event.run_id,
                session_id=event.session_id,
                expires_at=float(event.pause.claims.exp),
                # HK2: read off the capability's report, never asserted here.
                # ``True`` means the turn's state reached the checkpointer and
                # the write said so; a deployment with no checkpointer, or one
                # whose saver refused the write, still publishes the pause and
                # still says ``False`` -- which is the same promise HK1 made,
                # now made by measurement instead of by hard-coding.
                resumable=event.resumable,
            )
        )

    return handle