Skip to content

symfonic.kernel.contracts.interrupts

interrupts

A run that stopped to ask a person, and the vocabulary it stops in (HK1).

Until TA8.34 the kernel had no way to say "this run is waiting for a human". Its event vocabulary held seven kinds -- thinking, text_delta, tool_call, tool_result, done, error, cancelled -- and none of them is a pause: done says the run answered, error says it broke, cancelled says somebody stopped it. A run that is waiting is none of those, so the information never reached the typed loop at all and there was nothing there to translate. That is the gap ST2 recorded as a blocker on ST3's flip, and this module is the half of the fix that lives in the kernel.

Two things live here and nothing else.

:class:PendingInterrupt is the fact: which registered interaction the run stopped at, the payload the caller has to answer, and the token they answer with. It is a kernel-contracts value -- plain stdlib, no capability imported -- so the capability that mints one and the projection that publishes one can both name it without either naming the other.

:class:InvocationPaused is the signal. It derives from BaseException on purpose, for the reason asyncio.CancelledError does: a pause is not a failure, and every except Exception between the pause point and the runner would otherwise convert it into one. Two of them are directly in the path and both are correct as they stand -- symfonic.agent.backend.tools.execute_tool_call turns a raising tool into a ToolCall.error the model reads as an observation, and StageDispatcher._invoke turns a raising stage into a FAILED trace. A pause caught by either would be reported to the model as a broken tool or to the turn as a crashed stage, and the run would carry on without the answer it stopped for. Deriving from BaseException is what makes that impossible to get wrong by writing ordinary error handling.

InvocationPaused

InvocationPaused(interrupt: PendingInterrupt)

Bases: BaseException

The run stopped at a registered pause point. Not an error (HK1).

Raised by whatever reaches the pause point -- a capability's pre-tool stage today -- and caught in exactly one place: symfonic.kernel.runner.InvocationRunner.events, which turns it into the run's terminal event. It is never converted into an error terminal and never re-raised at the consumer, because a paused run did not fail.

Source code in src/symfonic/kernel/contracts/interrupts.py
def __init__(self, interrupt: PendingInterrupt) -> None:
    super().__init__(f"the run paused at {interrupt.name!r}")
    self.interrupt = interrupt

PendingInterrupt dataclass

PendingInterrupt(name: str, kind: str = 'interrupt', payload: Any = None, token: str = '', tool_call_id: str = '', interrupt_id: str = '', run_id: str = '', session_id: str = '', expires_at: float | None = None, resumable: bool = False)

One pause: what was asked, who may answer, and with which token.

payload is carried, never interpreted. The kernel does not know what an ask_user request looks like and must not learn: the capability that registered the interaction validated the payload against its own schema before minting, and the projection that publishes it is the layer that knows which public event shape it becomes.

resumable is a declaration, not a wish. TA8.34 mints a pause and publishes it; nothing in this build redeems one on the kernel route -- resume, rehydration and the checkpointer a resume needs are TA8.35. A pause that cannot be resumed has to say so, because the alternative is a consumer holding a token that looks live and answering into nothing.