def admit_invocation(
config: Any,
*,
scope: Any = None,
session_id: str | None = None,
history: Sequence[Any] | None = None,
attachments: Sequence[Any] | None = None,
callbacks: Sequence[Any] | None = None,
extra_metadata: Any = None,
state_overrides: Any = None,
agent_depth: int | None = None,
response_model: Any = None,
sub_agents: Sequence[Any] = (),
plugins: Sequence[Any] = (),
human: Any = None,
observability: Sequence[Any] | None = None,
topology: str | None = None,
bundle: Any = None,
) -> EnvelopeVerdict:
"""Decide whether the compiler-and-kernel path may serve this invocation.
``topology`` is the agent's compiled graph preset. It is *not* readable
from ``config`` — ``graph_preset`` is a constructor argument that lands on
the ``AgentGraph`` — so it has to be threaded in by the caller. Passing
``None`` means "not stated", which is treated as the migrated topology;
every caller inside the engine states it.
``history`` and ``attachments`` are still *accepted* here and no longer
refuse. They keep their parameters rather than losing them, because the
sole caller passes every per-call argument by name and a signature that
dropped two of them would turn an admission into a ``TypeError``; and
because the record of which arguments this envelope has considered is the
signature. What lifted them is stated at the loop below.
``response_model`` is deliberately *not* a refusal: structured output is
part of the response capability T3.1.4 verified, and the delegate compiles
it the same way the simple facade does. It is an argument rather than a
config field, so it needs no allowlist entry.
That non-refusal is about ``run``, and TA8.41 made the distinction explicit
rather than leaving this paragraph to imply the wider claim. Structured
output is a blocking-turn contract: the two streaming entry points refuse
the argument by name, above the dispatch, in
:func:`~symfonic.agent.cutover.lifecycle_refusals.refuse_streaming_structured_output`.
They refuse there and not here for the reason the retired-configuration
guard gives -- a verdict-driven refusal would be route-conditional, and this
envelope is consulted on neither route when the switch is rolled back.
``human`` is the pause transport a composition root set on the agent
(``_human_interaction``), threaded in for the reason ``topology`` is: it is
readable from neither ``config`` nor the call, so a check that did not
receive it could only admit it by silence. It is guarded against the same
``bundle`` that will serve the turn -- see :func:`_human_refusal`.
``bundle`` is the composition root's authorised
:class:`~.bundle.RetrievalBundle`. It is the only thing that lifts the
``auto_hydrate=False`` pin, and it lifts it on evidence rather than on a
flag: both memory segments must be present *and* answer their operative
method. ``None`` means "nothing was authorised", which keeps the pin.
``observability`` carries the handlers the *constructor* injected — the
``metrics_collector`` and the OTEL callback bridge that
``SymfonicAgent._with_metrics_callbacks`` prepends onto every legacy
runtime call. They are separate from the per-call ``callbacks`` argument
and have to be looked at separately: an agent built with a metrics
collector passes no ``callbacks`` at all, so a check that read only the
argument would admit the turn and the collector would simply stop
receiving events. That is why the parameter exists; since TA8.20 it no
longer refuses, because :data:`ADMITTED_INJECTIONS` names the consumer that
keeps those same two objects fed from the kernel event stream. The
parameter and its branch stay: the record of what this envelope has
considered is what is *written here*, and an injection admitted by silence
leaves none.
"""
hydration = _hydration_refusal(config, bundle)
if hydration is not None:
return _refuse(hydration)
prompting = _prompting_refusal(config, bundle)
if prompting is not None:
return _refuse(prompting)
delegation = _delegation_refusal(sub_agents, bundle)
if delegation is not None:
return _refuse(delegation)
extensions = _extensions_refusal(plugins, bundle)
if extensions is not None:
return _refuse(extensions)
human_interaction = _human_refusal(human, bundle)
if human_interaction is not None:
return _refuse(human_interaction)
if topology is not None and topology != MIGRATED_TOPOLOGY:
return _refuse(
f"graph_preset={topology!r} is not the {MIGRATED_TOPOLOGY!r} "
"topology the migrated path compiles; serving it on the kernel "
"would replace the agent's chosen pipeline"
)
config_refusal = _config_refusal(config, bundle)
if config_refusal is not None:
return _refuse(config_refusal)
# Constructor-injected handler sets. Default-DENY through
# :data:`ADMITTED_INJECTIONS`, the same way the per-call loop below reads
# :data:`ADMITTED_ARGUMENTS` -- an injection with no named consumer refuses.
#
# ``observability`` is the one entry, and TA8.20 replaced a flat refusal
# with it rather than deleting the branch. The refusal said "the migrated
# path emits no callback events", which was true and is no longer: the
# kernel event stream now reaches the metrics collector and the OTEL bridge
# through ``ServiceBindings.event_sink``, bound at the compile seam by
# ``symfonic.agent.cutover.observability.ObservabilitySuite``. Deleting the
# branch would have admitted the injection by *absence*, which is the
# unreadable outcome ``policy.py`` argues against for arguments and which
# here would also have admitted any future injection that was never
# considered.
if observability and "observability" not in ADMITTED_INJECTIONS:
return _refuse(
"a metrics_collector or OTEL callback bridge is attached to this "
"agent and no consumer on the migrated path is named for it; the "
"observability capability's cutover switch is not flipped"
)
# Per-call arguments. Default-DENY, same as the config half: an argument
# refuses by name unless :data:`ADMITTED_ARGUMENTS` carries an entry naming
# the consumer that honours it. Adding a keyword to ``run``/``stream`` and
# forgetting it here refuses the turn rather than serving it half-read.
#
# ``scope`` is the one entry today, and it took three steps to earn it. It
# was refused outright while nothing on the migrated path consumed a scope
# -- a turn that named a tenant would have been served from whatever scope
# the plan was compiled with. TA8.8 lifted it *conditionally*, on a bundle
# that serves hydration or the system prompt, because a scope propagated to
# a capability that cannot read it changes nothing and hides that it
# changed nothing.
#
# TA8.11 dropped the condition, and dropped it by removing what made it
# necessary rather than by weakening the rule. The condition existed
# because a bundle-less turn had no reader for the scope; it now has two
# that do not depend on a bundle at all -- the tenant budget breaker and
# AGENT_IDENTITY seeding, both in ``SymfonicAgent._apply_scope_effects``,
# which the kernel branch calls in the position the legacy body runs it.
# Leaving the condition in place would have sent every bundle-less
# multi-tenant turn to legacy for a reason that had stopped being true.
# ``history`` and ``attachments`` leave the loop below unconditionally, and
# unlike ``scope`` they need no predicate — there is nothing for one to be
# conditional on. Both are per-call arguments that reach the same
# ``PromptAssembly`` and the same ``build_request`` call on every admitted
# turn; there is no configuration under which one of them is carried and
# the other dropped, so a condition here could only ever be true.
#
# The allowlist statement each of them is lifted on, stated once:
#
# * ``history`` — ``KernelDelegate`` trims it with
# ``symfonic.agent.engine._pair_aware_history_slice``, the *same function*
# ``_legacy_run_impl`` calls, at the same
# ``config.agent.max_conversation_messages``, then carries it to
# ``TurnRequest.history`` -> ``PromptAssembly.history`` ->
# ``ConversationAdapter.open_turn`` -> ``build_request``, which replays it
# between the system message and the user turn. That field itself stays
# refused below the loop: honouring the cap is what a future allowlist
# entry for it would need, not the entry itself.
# * ``attachments`` — carried unconverted to ``TurnRequest.attachments`` and
# consumed by ``symfonic.agent._content_blocks._build_human_content``,
# again the same function the legacy body calls, on a family computed by
# the same ``_detect_provider_family`` against the same provider object.
#
# Neither is lifted on the existence of a *field*: TA8.10 files a separate
# A/B for each, because they have different consumers and different failure
# modes and an aggregate pass would hide one of them failing.
#
# ``session_id`` leaves the loop in TA8.19, on its own entry and its own
# A/B, never aggregated with another argument's. What earned it is
# ``SymfonicAgent._resolve_session``: one derivation site that both routes
# call in the position the three legacy bodies inlined it, so issuance,
# cross-tenant collision refusal and activity tracking happen on the kernel
# route too and the *resolved* id -- not the caller's -- is what reaches
# ``KernelDelegate.run``.
#
# Read the direction of that fix carefully, because it is the opposite of
# what a guard can do. The loop below tests ``if value``, so it only ever
# saw a *truthy* session id, and that case was already safe: it refused,
# loudly, and fell back. The broken case was the falsy one -- an admitted
# stateless turn returned ``AgentResponse.session_id == ""`` and registered
# nothing, with zero recorded fallbacks, because there was no value to
# refuse. No refusal reaches that; only a consumer does. Admitting the
# argument is what closes it.
# ``callbacks``, ``extra_metadata`` and ``state_overrides`` are a third
# answer since TA8.18: not admitted, and not waiting on a switch either --
# retired on the 11.0 line, with :data:`RETIRED_ARGUMENTS` naming what the
# adopter does instead. All three stay in the loop below rather than
# leaving it, for the reason ``policy.py`` gives about admission by
# absence: an argument that vanishes from the tuple leaves no record of
# why, and "retired by absence" is as unreadable as "admitted by absence".
#
# ``agent_depth`` and ``sub_agents`` leave the loop in TA8.12, on two
# separate entries and two separate A/Bs. They are two arguments, not one,
# and an aggregate pass would hide either of them failing -- which is the
# rule TA8.10 set for ``history``/``attachments`` and it is not relaxed
# because these two happen to migrate together.
#
# ``agent_depth`` refused until this task for a stated reason: lifting it
# needed "a named consumer on the migrated side that reads the depth", and
# ``KernelDelegate`` had none. It has one now --
# ``symfonic.agent.cutover.delegate.KernelDelegate._delegation_scope``,
# which opens ``DelegationCapability.run_scope`` at the turn's depth so the
# ceiling check in ``DelegationTools.delegate`` sees the tree the turn is
# actually in. The engine's ``_active_agent_depth`` context variable is
# still set by ``_with_active_scope`` on both routes and is still not that
# consumer: it keeps the identity-seed guard honest and is read by the
# *legacy* graph's delegation tool, which an admitted turn never reaches.
#
# Read the direction of that fix the way TA8.19 read ``session_id``'s,
# because it is the same one. The loop tests ``if value``, so it only ever
# saw a depth of ``1`` or more; ``0`` and ``None`` are falsy and never
# reached the branch on either spelling. A guard cannot close the falsy
# case, and a value threaded to nothing is not migrated however loudly the
# truthy case refuses. Only a consumer closes it, and admitting the
# argument is what makes the consumer reachable.
#
# ``sub_agents`` is *conditional*, the way ``scope`` was in TA8.8 and for
# the same reason: a child roster admitted onto a path with no way to reach
# a child changes nothing and hides that it changed nothing. The condition
# is ``bundle.serves_delegation()`` and it is checked above the loop, by
# name, so the refusal says which half is missing rather than "sub_agents".
#
# ``plugins`` leaves the loop in TA8.21, *conditionally*, the way
# ``sub_agents`` did and for the same reason: a plugin admitted onto a path
# that harvests nothing changes nothing and hides that it changed nothing.
# The condition is ``bundle.serves_extensions()`` and it is checked above
# the loop, by name, so the refusal says which half is missing rather than
# "plugins". What earned it is
# ``symfonic.agent.cutover.extensions.ExtensionsCapability`` -- the
# ``contribute()`` the RCH-1 waiver said nothing supplied -- whose
# resolution stage harvests each plugin's hook per turn into the snapshot
# the prompt compiler reads, and
# ``symfonic.agent.cutover.extensions.enforce_guardrails``, which is the
# single site ``SymfonicAgent.validate_action`` asks a contributed policy
# from on *both* routes.
#
# It is admitted with its behaviour change written down first rather than
# inferred afterwards: the tier/layer remap a bridged fragment undergoes is
# ``evidence/RET-PREP/decision-plugin-tier-layer.md``, and it means the two
# routes' prompt text is deliberately *not* equal. An A/B asserting byte
# equality there would be asserting that decision was never taken.
#
# It is also **not** the reachability probe's next out-of-envelope driver,
# and that is a decision rather than an oversight. The probe's ``out``
# dimension is
# refuse-*then*-degrade by definition, so whatever drives it produces a
# legacy reach and the reported count only ever measures which argument was
# chosen to stay unmigrated. TA8.12 retired the dimension instead of
# repointing it a fourth time; the synthetic fixture that keeps the probe's
# sensitivity under test lives in
# ``tests/agent/cutover/synthetic_out_of_envelope.py`` and is a test
# construct that no shipped code path can reach.
# This function still *returns* for them. It is a router, and a router that
# can raise makes every one of its callers a potential raiser; the
# ``test_per_call_legacy_arguments_are_named`` case depends on that too.
# The raise lives at the entry points instead
# (``SymfonicAgent._refuse_retired_arguments``), which is why there is no
# third field on :class:`EnvelopeVerdict`: the engine refuses before it
# ever asks for a verdict, so a "retired" disposition here would be a state
# nothing on the dispatch path could read. What this branch gives a *direct*
# caller of ``admit_invocation`` is the reason, in the retirement's own
# words rather than the switch's.
for name, value in (
("scope", scope),
("session_id", session_id),
("history", history),
("attachments", attachments),
("callbacks", callbacks),
("extra_metadata", extra_metadata),
("state_overrides", state_overrides),
("agent_depth", agent_depth),
("sub_agents", sub_agents),
("plugins", plugins),
):
if not value or name in ADMITTED_ARGUMENTS:
continue
if name in RETIRED_ARGUMENTS:
return _refuse(
f"{name} is not a legacy-engine argument waiting on a switch: "
f"it was retired on the {LEVER_RETIREMENT_LINE} line. "
f"{RETIRED_ARGUMENTS[name].instead}"
)
return _refuse(
f"{name} is a legacy-engine argument; the capability that "
"consumes it has not been flipped"
)
return EnvelopeVerdict(admitted=True)