Skip to content

symfonic.capabilities.human.factory

factory

Human interaction as something a composition root can compose.

HumanInteractionCapability.compile has always assembled the pieces, and it already carries contribute() -- so unlike governance, the kernel would have accepted this object all along. What was missing was the call that produces one: the constructor takes four collaborators and the classmethod takes a signer, a TTL policy, a consumption seam and a registration list, and an adopter who wanted ask_user had to know all of that first.

This is the one call, in the shape memory_capabilities(store, scope) established.

The signer has no default, and that is deliberate. It mints and verifies the pause tokens a paused turn is resumed with. A default one would ship every deployment the same key, and a token minted by one would be honoured by all of them. There is no safe placeholder, so the argument is required.

The consumption seam does have a default, and it is single-process. It is what records that a token was spent, so a second redemption can be refused. InMemoryConsumption keeps that record in one process: two replicas each keep their own, and a token spent on one is still unspent on the other. Fine for development, and :func:redemption_is_shared reports which one a deployment ended up with rather than leaving it to be discovered by a replay.

The factory refuses to return a capability that contributes nothing. contribute() answers with an empty contribution in three states -- nothing registered, no binding, no encode_token -- and that is the right answer for the capability: advertising ask_user when a pause could never be answered invites the model to stop a run nobody can resume. It is the wrong thing for a factory to hand back silently. The first version of this door did exactly that: it composed, an Agent accepted it, a turn ran, and the model was never offered the tool. So the check is here, where the deployment can still be told which argument is missing.

AskUserPayload

Bases: BaseModel

What the agent asks. One field, because one question is the whole tool.

AskUserResponse

Bases: BaseModel

What the person answers.

ask_user_registration

ask_user_registration(*, cross_scope_allowed: bool = False) -> InteractionRegistration

The ask_user interaction, with schemas that actually validate.

cross_scope_allowed stays false: a token minted for one scope being redeemable in another is the cross-scope redemption the ledger exists to record, and it is not something a default should hand out.

Source code in src/symfonic/capabilities/human/factory.py
def ask_user_registration(
    *, cross_scope_allowed: bool = False
) -> InteractionRegistration:
    """The ``ask_user`` interaction, with schemas that actually validate.

    ``cross_scope_allowed`` stays false: a token minted for one scope being
    redeemable in another is the cross-scope redemption the ledger exists to
    record, and it is not something a default should hand out.
    """
    return InteractionRegistration.ask_user(
        payload_schema=AskUserPayload,
        response_schema=AskUserResponse,
        cross_scope_allowed=cross_scope_allowed,
    )

human_interaction

human_interaction(*, signer: Any, binding: Callable[[], Any], encode_token: Callable[[Any], str], decode_token: Callable[[str], Any] | None = None, registrations: Iterable[InteractionRegistration] | None = None, ttl: TTLPolicy | None = None, consumption: Any = None, **options: Any) -> HumanInteractionCapability

Build the capability that lets a turn pause and be resumed.

Agent(provider, capabilities=[human_interaction(
    signer=my_signer,
    binding=lambda: PauseBinding(run_id=..., session_id=...),
    encode_token=my_encoder,
)])

Three arguments are required because only the deployment can supply them, and because the capability contributes nothing without them.

Parameters:

Name Type Description Default
signer Any

mints and verifies pause tokens. No default: a shared one would make every deployment honour every other's tokens.

required
binding Callable[[], Any]

resolves what this run is, per run. No default: a PauseBinding() with empty fields is a token bound to nothing.

required
encode_token Callable[[Any], str]

renders a minted envelope as the opaque string a person answers with. No default: the token format is a deployment's choice, not the framework's.

required
decode_token Callable[[str], Any] | None

the inverse. Optional, and its absence has a cost -- without it a pause taken on the kernel route has no public reader, so the run can be stopped and not continued.

None
registrations Iterable[InteractionRegistration] | None

which interactions this agent offers. Defaults to ask_user alone -- the one every deployment wants and the only one whose schema is obvious.

None
ttl TTLPolicy | None

how long a pause may stay open. Defaults to :data:DEFAULT_TTL.

None
consumption Any

where spending a token is recorded. Defaults to a single-process store; see :func:redemption_is_shared.

None
**options Any

forwarded to HumanInteractionCapability.compile -- ledger, checkpoints, clock and audit.

{}

Raises:

Type Description
InteractionConfigurationError

if the composed capability would contribute nothing, naming what is missing.

Source code in src/symfonic/capabilities/human/factory.py
def human_interaction(
    *,
    signer: Any,
    binding: Callable[[], Any],
    encode_token: Callable[[Any], str],
    decode_token: Callable[[str], Any] | None = None,
    registrations: Iterable[InteractionRegistration] | None = None,
    ttl: TTLPolicy | None = None,
    consumption: Any = None,
    **options: Any,
) -> HumanInteractionCapability:
    """Build the capability that lets a turn pause and be resumed.

        Agent(provider, capabilities=[human_interaction(
            signer=my_signer,
            binding=lambda: PauseBinding(run_id=..., session_id=...),
            encode_token=my_encoder,
        )])

    Three arguments are required because only the deployment can supply them,
    and because the capability contributes nothing without them.

    Args:
        signer: mints and verifies pause tokens. No default: a shared one would
            make every deployment honour every other's tokens.
        binding: resolves what *this run* is, per run. No default: a
            ``PauseBinding()`` with empty fields is a token bound to nothing.
        encode_token: renders a minted envelope as the opaque string a person
            answers with. No default: the token format is a deployment's
            choice, not the framework's.
        decode_token: the inverse. Optional, and its absence has a cost --
            without it a pause taken on the kernel route has no public reader,
            so the run can be stopped and not continued.
        registrations: which interactions this agent offers. Defaults to
            ``ask_user`` alone -- the one every deployment wants and the only
            one whose schema is obvious.
        ttl: how long a pause may stay open. Defaults to :data:`DEFAULT_TTL`.
        consumption: where spending a token is recorded. Defaults to a
            single-process store; see :func:`redemption_is_shared`.
        **options: forwarded to ``HumanInteractionCapability.compile`` --
            ledger, checkpoints, clock and audit.

    Raises:
        InteractionConfigurationError: if the composed capability would
            contribute nothing, naming what is missing.
    """
    declared = tuple(registrations) if registrations is not None else (
        ask_user_registration(),
    )
    if not declared:
        raise InteractionConfigurationError(
            "no interaction is registered, so this capability would advertise "
            "no tool and never pause. Pass registrations, or leave the argument "
            "out to get ask_user."
        )
    seam = consumption if consumption is not None else InMemoryConsumption()
    return HumanInteractionCapability.compile(
        declared,
        signer=signer,
        ttl=ttl if ttl is not None else DEFAULT_TTL,
        consumption=seam,
        binding=binding,
        encode_token=encode_token,
        decode_token=decode_token,
        **options,
    )

redemption_is_shared

redemption_is_shared(consumption: Any) -> bool

Whether two replicas would agree that a token has been spent.

False means each replica holds its own record, so a token redeemed on one can be redeemed again on another. That is a legitimate choice for a single-process deployment and an unpleasant surprise for any other, which is the reason it is reported rather than assumed.

Source code in src/symfonic/capabilities/human/factory.py
def redemption_is_shared(consumption: Any) -> bool:
    """Whether two replicas would agree that a token has been spent.

    ``False`` means each replica holds its own record, so a token redeemed on
    one can be redeemed again on another. That is a legitimate choice for a
    single-process deployment and an unpleasant surprise for any other, which
    is the reason it is reported rather than assumed.
    """
    return not isinstance(consumption, InMemoryConsumption)