Skip to content

symfonic.agent.facade_args

facade_args

What the simple facade refuses at construction time.

Split out of :mod:symfonic.agent.facade for the same two reasons :mod:symfonic.agent.facade_model was: the facade module keeps to the 300-line budget, and these are rules rather than plumbing -- each one names an argument the constructor will not accept and says why accepting it would be worse than raising.

Every check here runs before anything is stored, because FAC-8 says construction validates and stores and does nothing else: a rejected argument must fail on the line that passed it, not on the first run().

receipt_capability

receipt_capability(capabilities: tuple[Any, ...]) -> Any

Detect already-imported receipt declarations without loading Mongo on default paths.

Source code in src/symfonic/agent/facade_args.py
def receipt_capability(capabilities: tuple[Any, ...]) -> Any:
    """Detect already-imported receipt declarations without loading Mongo on default paths."""
    module = sys.modules.get("symfonic.capabilities.memory.receipt_composition")
    if module is None:
        return None
    kind = module.ReceiptMemoryCapability
    found = tuple(item for item in capabilities if type(item) is kind)
    if not found:
        return None
    from symfonic.capabilities.delegation.capability import DelegationCapability
    from symfonic.capabilities.prompting.capability import PromptingCapability

    other = tuple(item for item in capabilities if item is not found[0])
    # Delegation is tools-only: it grants no memory authority and registers no
    # stage or lifecycle callback. Normal tool grants and the receipt carrier's
    # owner checks still bound every dispatch; child effects belong to the child.
    # Exact types deliberately exclude arbitrary capability subclasses.
    allowed = all(
        type(item) is DelegationCapability or (
            type(item) is PromptingCapability
            and type(item.sources) is tuple and not item.sources
            and item.priority == -900 and item.options == {}
        )
        for item in other
    )
    if (len(found) != 1 or not module.issued_receipt(found[0]) or not allowed
            or sum(type(item) is PromptingCapability for item in other) > 1
            or sum(type(item) is DelegationCapability for item in other) > 1):
        raise ConfigurationError(
            "receipt Agent permits only validated scoped memory, empty prompting "
            "and tool-only delegation"
        )
    return found[0]

require_capabilities

require_capabilities(capabilities: Iterable[object]) -> tuple[Any, tuple[object, ...]]

Reject declarations without a contribution method before folding.

Source code in src/symfonic/agent/facade_args.py
def require_capabilities(capabilities: Iterable[object]) -> tuple[Any, tuple[object, ...]]:
    """Reject declarations without a contribution method before folding."""
    capabilities = tuple(capabilities)
    for capability in capabilities:
        if not callable(getattr(capability, "contribute", None)):
            raise ConfigurationError(
                f"{type(capability).__name__} is not a CapabilityConfig: it "
                "has no contribute(request) -> CapabilityContribution. The "
                "seam takes a declaration plus the handlers that answer it, "
                "so a capability cannot register a stage nothing runs."
            )
    require_memory_scopes(capabilities)
    receipt = receipt_capability(capabilities)
    if receipt is None:
        return None, capabilities
    from symfonic.capabilities.prompting.capability import PromptingCapability

    return receipt, tuple(
        PromptingCapability() if type(item) is PromptingCapability else item
        for item in capabilities
    )

require_instructions

require_instructions(instructions: str | None) -> None

Refuse instructions that are neither a string nor absent.

None is a documented value and keeps FAC-5's meaning -- no system prompt of the framework's own invention -- so only a non-string that is not None is an error.

Source code in src/symfonic/agent/facade_args.py
def require_instructions(instructions: str | None) -> None:
    """Refuse instructions that are neither a string nor absent.

    ``None`` is a documented value and keeps FAC-5's meaning -- no system
    prompt of the framework's own invention -- so only a non-string that is
    not ``None`` is an error.
    """
    if instructions is not None and not isinstance(instructions, str):
        raise ConfigurationError(
            f"instructions must be a string or None, got "
            f"{type(instructions).__name__}."
        )

require_memory_scopes

require_memory_scopes(capabilities: tuple[object, ...]) -> None

Refuse a memory capability whose scope names no tenant.

Legacy rejects a blank tenant scope; MemoryScope refuses one at its own construction too โ€” but the capability is a dataclass, and nothing stops scope=None or a foreign object in the scope seat from reaching this line. A scope that names no tenant isolates nothing, so the refusal lands here, on the construction line, not on the first run() that reads another tenant's rows.

Subclasses are held to the same rule: platform composition recognises a memory capability by isinstance, so an exact-type check here would let class Sub(MemoryCapability) carry scope=None past the gate.

Source code in src/symfonic/agent/facade_args.py
def require_memory_scopes(capabilities: tuple[object, ...]) -> None:
    """Refuse a memory capability whose scope names no tenant.

    Legacy rejects a blank tenant scope; ``MemoryScope`` refuses one at its
    own construction too โ€” but the capability is a dataclass, and nothing
    stops ``scope=None`` or a foreign object in the scope seat from reaching
    this line. A scope that names no tenant isolates nothing, so the refusal
    lands here, on the construction line, not on the first ``run()`` that
    reads another tenant's rows.

    Subclasses are held to the same rule: platform composition recognises a
    memory capability by ``isinstance``, so an exact-type check here would
    let ``class Sub(MemoryCapability)`` carry ``scope=None`` past the gate.
    """
    module = sys.modules.get("symfonic.capabilities.memory.capability")
    if module is None:
        return
    kind = module.MemoryCapability
    for capability in capabilities:
        if not isinstance(capability, kind):
            continue
        scope = getattr(capability, "scope", None)
        tenant = getattr(scope, "tenant", None)
        if not isinstance(tenant, str) or not tenant.strip():
            raise ConfigurationError(
                "memory capability scope must name a tenant: got "
                f"{scope!r}. Pass scope=MemoryScope(tenant=...) "
                "so every read and write is isolated to one tenant (SEC-TEN-5)."
            )

require_native_result_policy

require_native_result_policy(settings: ResultPolicySettings | None) -> None

Refuse settings the native compact-only binding does not consume.

Source code in src/symfonic/agent/facade_args.py
def require_native_result_policy(settings: ResultPolicySettings | None) -> None:
    """Refuse settings the native compact-only binding does not consume."""
    if settings is None:
        return
    if type(settings) is not ResultPolicySettings:
        raise ConfigurationError("invalid native result_policy settings")
    if settings.size_chars < 0 or settings.keep_last_n < 0:
        raise ConfigurationError("invalid native result_policy settings")
    defaults = ResultPolicySettings()
    for field in fields(ResultPolicySettings):
        name = field.name
        if (name not in _NATIVE_RESULT_FIELDS
                and getattr(settings, name) != getattr(defaults, name)):
            raise ConfigurationError(f"invalid native result_policy settings: {name} unsupported")

require_provider

require_provider(model_provider: Any) -> None

Refuse a missing or non-conforming provider.

The facade takes the caller's own provider object rather than a name (FAC-4), so the only thing it can check is the one method it will call. Checking it here turns a construction-time typo into a construction-time error instead of an AttributeError inside the first invocation.

Source code in src/symfonic/agent/facade_args.py
def require_provider(model_provider: Any) -> None:
    """Refuse a missing or non-conforming provider.

    The facade takes the caller's own provider object rather than a name
    (FAC-4), so the only thing it can check is the one method it will call.
    Checking it here turns a construction-time typo into a construction-time
    error instead of an ``AttributeError`` inside the first invocation.
    """
    if model_provider is None:
        raise ConfigurationError(
            "model_provider is required: pass an object implementing "
            "symfonic.core.ModelProvider (e.g. AnthropicProvider())."
        )
    if not callable(getattr(model_provider, "get_chat_model", None)):
        raise ConfigurationError(
            "model_provider must implement ModelProvider.get_chat_model; "
            f"got {type(model_provider).__name__}."
        )

require_round_budget

require_round_budget(max_model_rounds: int | None) -> None

Refuse a round budget that cannot bound a loop.

bool is an int subclass, so max_model_rounds=True would pass an unguarded integer check and silently mean one round -- a tool-using agent would return after its first provider call with a partial answer presented as a whole one. Zero and negatives have no reading at all: the kernel would stop before the first round. None is the documented way to ask for the facade default, and is the only non-integer accepted.

Source code in src/symfonic/agent/facade_args.py
def require_round_budget(max_model_rounds: int | None) -> None:
    """Refuse a round budget that cannot bound a loop.

    ``bool`` is an ``int`` subclass, so ``max_model_rounds=True`` would pass
    an unguarded integer check and silently mean one round -- a tool-using
    agent would return after its first provider call with a partial answer
    presented as a whole one. Zero and negatives have no reading at all: the
    kernel would stop before the first round. ``None`` is the documented way
    to ask for the facade default, and is the only non-integer accepted.
    """
    if max_model_rounds is None:
        return
    if (
        isinstance(max_model_rounds, bool)
        or not isinstance(max_model_rounds, int)
        or max_model_rounds < 1
    ):
        raise ConfigurationError(
            "max_model_rounds must be a positive integer or None, got "
            f"{max_model_rounds!r}."
        )