Skip to content

symfonic.capabilities.delegation.declarations

declarations

What an adopter declares: a child they built, or a child they described.

Two forms, one validation. The shipped types validated the same three things twice, in two __post_init__ bodies that had already drifted (one checked the runner, one did not check tools at all), so the rules live on the base class here and each form adds only what is genuinely its own.

The forms differ in exactly one structural way, and it is the one that matters downstream: who owns the child's lifetime. A child handed over pre-built is the caller's; a child described by a spec is built by the compiler and must be released by whoever built it. :attr:ChildDeclaration.owned is that fact, read by the compiler and carried onto the roster entry, so no later stage has to re-derive it from the declaration's type.

ChildDeclaration dataclass

ChildDeclaration(*, name: str, description: str, when_to_use: str | None = None)

The vocabulary both declaration forms share.

Keyword-only on purpose. A positional (name, agent, description) and a positional (name, description, ...) in the same package is a transposition waiting to happen, and the two forms are meant to be interchangeable in a single list.

Attributes:

Name Type Description
name str

The routing key the parent's model passes to run_agent. Non-empty and whitespace-free — it is matched literally against what a model typed, so a name with a space in it is a name the model cannot reliably reproduce.

description str

The one-liner shown to the parent model. Required, because a roster entry nobody can choose between is not a roster.

when_to_use str | None

Optional longer guidance appended to the roster listing.

owned property

owned: bool

True when whoever compiles this declaration owns the child.

ChildSpec dataclass

ChildSpec(*, name: str, description: str, when_to_use: str | None = None, tools: Sequence[Any] = tuple(), domain_description: str | None = None, model_name: str | None = None, temperature: float | None = None, max_tokens: int | None = None, provider: Any | None = None, config: Any | None = None)

Bases: ChildDeclaration

A child described rather than built: the parent constructs it.

The declarative form. Everything unset is inherited from the parent — provider, behaviour flags, model settings — because a child that silently reverted to framework defaults would drift from its parent on every flag the adopter had deliberately changed. What is not inherited is the domain: the child gets a fresh one scoped to this spec, so its tool manifest derives from its own tools rather than leaking the parent's.

Attributes:

Name Type Description
tools Sequence[Any]

The child's whole palette. Normalised to a tuple so a caller's list cannot be appended to after the child is compiled.

domain_description str | None

The child's domain directive. Defaults to :attr:~ChildDeclaration.description — see :attr:effective_domain_description.

model_name str | None

Optional child model override; inherits the parent's.

temperature float | None

Optional child sampling override.

max_tokens int | None

Optional child output-ceiling override.

provider Any | None

Optional model provider. Unset inherits the parent's, which is safe because providers are stateless.

config Any | None

A complete child config, bypassing inheritance entirely. It is sanitised rather than trusted — this is the path an adopter reaches for precisely when they want something non-default, and a lock skipped on that path is decorative.

effective_domain_description property

effective_domain_description: str

The domain directive this spec actually resolves to.

Read here rather than at the compiler so the fallback is a property of the declaration — the thing the adopter reads — instead of a rule two construction branches each have to remember.

owned property

owned: bool

Always. Whoever compiles a spec built the child and must close it.

PrebuiltChild dataclass

PrebuiltChild(*, name: str, description: str, when_to_use: str | None = None, agent: Any)

Bases: ChildDeclaration

A child the caller constructed and hands over.

The escape hatch, and deliberately the unsanitisable one: the child is already built, its palette is already frozen, and there is nothing left to clear. Registration can therefore only accept or refuse it — see :func:~.lockdown.assert_no_write_surface.

Attributes:

Name Type Description
agent Any

Anything exposing async run(query, ...). Structural rather than nominal: a test double, a remote proxy, and an adapter around somebody else's agent are all legitimate children, and none of them can be expected to inherit from a class this package cannot import.

owned property

owned: bool

Never. The caller built this child and controls its lifetime.

ScopedChild dataclass

ScopedChild(*, name: str, description: str, when_to_use: str | None = None, build: Callable[[Any], Any])

Bases: ChildDeclaration

A child the composer builds for the scope it is serving.

The declaration that closes a leak PrebuiltChild cannot. A prebuilt child is a finished object, so a composition root has two places to build one and the convenient place is wrong: construct the children once beside the provider, close over them in compose(scope, shared), and every tenant shares them. Give such a child memory and it answers one tenant's delegation out of another tenant's recollections -- measured, not feared.

build is called once per scope, at composition time, with the scope the parent is being composed for. Deliberately not a per-call rebinding: a finished agent cannot be safely re-pointed at another tenant halfway through a turn, and a contract that pretended otherwise would be the same leak with more machinery.

Attributes:

Name Type Description
build Callable[[Any], Any]

scope -> agent. Whatever it returns must satisfy the same contract PrebuiltChild.agent does -- the ChildRunner protocol, or an Agent.

owned property

owned: bool

True: the composer built it, so the composer tears it down.

for_scope

for_scope(scope: Any) -> Any

Build this child for scope, refusing an absent one.

Building against None would produce exactly the process-wide child this declaration exists to prevent, and it would look like it worked.

Source code in src/symfonic/capabilities/delegation/declarations.py
def for_scope(self, scope: Any) -> Any:
    """Build this child for ``scope``, refusing an absent one.

    Building against ``None`` would produce exactly the process-wide
    child this declaration exists to prevent, and it would look like it
    worked.
    """
    if scope is None:
        raise ChildDeclarationError(
            f"ScopedChild {self.name!r} needs the scope its parent is "
            "composed for: pass scope= to delegated_children(). Building "
            "it without one produces a child shared by every tenant, "
            "which is the leak this declaration exists to close."
        )
    return self.build(scope)