Skip to content

symfonic.capabilities.human.contribution

contribution

The compiler seam: how a pause point reaches a compiled turn (HK1, TA8.34).

The seam is the one TA8.12 built for delegation and TA8.21 built for extensions, used rather than re-invented: :func:~symfonic.kernel.contracts.contributions.fold_contributions folds a :class:~symfonic.kernel.contracts.contributions.CapabilityContribution, the composition root carries the folded pieces in its bundle, and the plan factory compiles them. Nothing new is introduced for the third capability to do the thing the first two already do.

Both halves, because a pause needs both. Delegation contributes tools and no stage; extensions contribute a stage and no tool. Human interaction is the first capability that needs one of each, and they are not two designs:

  • the tool is the door -- how the model asks. Without one the model has no way to reach a registered interaction at all;
  • the stage is the stop -- pre-tool, after the kernel reserved the round's calls and before any of them is dispatched. It is the only place in a turn that sees the reserved call id the pause has to be bound to, which is the id a resume later joins on. A tool body cannot see its own call id, so a pause minted there would carry no tool_call_id and its payload would have nowhere to live (checkpoints.payload_key).

So the tool body is not dead code. It is reached whenever the stage declines to pause, which happens two ways: the deployment wired no binding for this run; or the capability refused the pause -- the payload the model wrote does not fit the registration's schema, or a checkpoint port had a bad minute. Both decline rather than raise: a raise from pre-tool becomes a FAILED trace and require_no_crashed_stage turns that into a ContractViolationError that ends the turn. Declining hands the model the refusal back as the tool's observation and the turn carries on -- the shape delegation uses when a hand-off is refused at the depth ceiling.

The behaviour lives next door in :mod:~symfonic.capabilities.human.pausing (HK2, TA8.35), which is where PauseBinding and pause_handler are defined and from where they are re-exported here at the addresses importers already hold. This module is the declaration -- what the capability offers a compiler, and why each half of it exists -- and that one is the behaviour the declaration routes to. They were one module while the behaviour was "mint a token and raise"; giving the pause a turn state to record put it over the module-size budget, and the split follows the line typed_interrupts follows next door to typed_projection: one module per shape.

InteractionToolSpec dataclass

InteractionToolSpec(name: str, description: str, coroutine: Callable[..., Any], parameters: tuple[str, ...] = ())

One interaction, described as a tool the composition root can bind.

A description and not a runtime tool object, for the reason :class:~symfonic.capabilities.delegation.contracts.DelegationToolSpec is one: a capability that constructed a StructuredTool would put a third-party tool library on the import path of a package with no other use for it. symfonic.agent.cutover.delegation.bind_contributed_tool is the step that wraps it, at the composition root, which is where the runtime's tool type is known.

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.

ask_user_tool_spec

ask_user_tool_spec(refusal: str = REFUSAL) -> InteractionToolSpec

The built-in's door, and only the built-in's.

ask_user is the one interaction whose payload shape this package knows -- a set of questions -- so it is the one whose tool signature this package can write. A deployment's own registered interaction carries a schema only that deployment knows, so it binds its own tool under the registered name and the stage below pauses on it exactly the same way. Guessing a signature for a schema we cannot read would advertise a tool the model could not call correctly.

Source code in src/symfonic/capabilities/human/contribution.py
def ask_user_tool_spec(refusal: str = REFUSAL) -> InteractionToolSpec:
    """The built-in's door, and only the built-in's.

    ``ask_user`` is the one interaction whose payload shape this package knows
    -- a set of questions -- so it is the one whose tool signature this package
    can write. A deployment's own registered interaction carries a schema only
    that deployment knows, so it binds its own tool under the registered name
    and the stage below pauses on it exactly the same way. Guessing a signature
    for a schema we cannot read would advertise a tool the model could not call
    correctly.
    """

    async def ask_user(questions: list[dict[str, Any]]) -> str:
        """Ask the person driving this run a question and wait for the answer."""
        return refusal

    return InteractionToolSpec(
        name=ASK_USER,
        description=(
            "Ask the person driving this run one or more questions and stop "
            "until they answer. Use it when the run cannot proceed correctly "
            "without a decision only they can make."
        ),
        coroutine=ask_user,
        parameters=("questions",),
    )

build_contribution

build_contribution(capability: Any, request: CapabilityRequest, *, binding: Callable[[], PauseBinding | None] | None, encode_token: Callable[[Any], str] | None) -> CapabilityContribution

Fold one human-interaction capability into what a compiler needs.

Source code in src/symfonic/capabilities/human/contribution.py
def build_contribution(
    capability: Any,
    request: CapabilityRequest,
    *,
    binding: Callable[[], PauseBinding | None] | None,
    encode_token: Callable[[Any], str] | None,
) -> CapabilityContribution:
    """Fold one human-interaction capability into what a compiler needs."""
    del request  # No grant is read: see HumanInteractionCapability.contribute.
    if not capability.active or binding is None or encode_token is None:
        # The name still travels, so a plan records that human interaction was
        # folded and found nothing to offer rather than that it was never
        # folded -- the distinction delegation's contribution makes for the
        # same reason.
        return CapabilityContribution(capability=CAPABILITY_NAME)
    stage = pause_stage()
    return CapabilityContribution(
        capability=CAPABILITY_NAME,
        stages=(stage,),
        handlers={stage.stage_id: pause_handler(capability, binding, encode_token)},
        tools=(ask_user_tool_spec(),) if ASK_USER in capability.names() else (),
    )

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

pause_stage

pause_stage() -> StageDescriptor

The pre-tool stage a pause happens in.

pre-tool and not post-model: the pause has to be bound to the call ids the transcript will join on, and those are minted by the kernel's own reservation, which happens between the two. It declares no effect family: minting a token and recording its payload are the capability's own ports, not an :class:~symfonic.kernel.contracts.effects.EffectFamily the plan grants, and declaring one it was not granted would be refused at fold.

Source code in src/symfonic/capabilities/human/contribution.py
def pause_stage() -> StageDescriptor:
    """The ``pre-tool`` stage a pause happens in.

    ``pre-tool`` and not ``post-model``: the pause has to be bound to the call
    ids the transcript will join on, and those are minted by the kernel's own
    reservation, which happens between the two. It declares no effect family:
    minting a token and recording its payload are the capability's own ports,
    not an :class:`~symfonic.kernel.contracts.effects.EffectFamily` the plan
    grants, and declaring one it was not granted would be refused at fold.
    """
    return StageDescriptor(
        stage_id=PAUSE_STAGE_ID,
        phase=Phase.PRE_TOOL,
        capability=CAPABILITY_NAME,
    )