symfonic.agent.cutover.delegate¶
delegate ¶
The delegate: one legacy call, served by the compiler and the kernel.
This module is deliberately thin, and thinness is the whole point. It holds no
loop, no round budget, no event numbering and no result assembly — those live
once, in symfonic.kernel, and reaching them through
:class:~symfonic.agent.backend.AgentPlanFactory is what keeps the legacy
facade from becoming IPL-2's forbidden second pipeline. What is here is
translation: the legacy engine's construction inputs into a compile request,
the caller's arguments into a :class:~symfonic.kernel.contracts.TurnRequest,
and — through :mod:~symfonic.agent.cutover.projection — the kernel's result
and event stream back into AgentResponse and StreamChunk, which are the
shapes legacy callers already hold.
The outward projection is next door rather than here because the two
directions are separable and only one of them touches the kernel: this module
compiles and invokes, projection only reads a finished value. They were one
module until TA8.10 gave this one an inbound conversion to carry as well. Two
more neighbours followed for the same line-budget reason and are named where
they are used: rounds, authorised and delegation.
KernelDelegate ¶
KernelDelegate(*, model_provider: Any, instructions: str | None = None, tools: Sequence[Any] = (), recursion_limit: int | None = None, model: Any = None, role_models: Any = None, bundle: Any = None, max_conversation_messages: int | None = None, observability: ObservabilitySuite | None = None)
Serves one legacy run/stream call through the migrated path.
Bind one plan factory for the life of this agent.
bundle is the composition root's authorised
:class:~.bundle.RetrievalBundle, transported unchanged. The delegate
does not fold it and does not inspect what it contains: folding is the
host's decision about what it trusts, and a delegate that composed one
would be deciding that on the host's behalf. None is the ordinary
case — an agent that does not hydrate compiles the same plan it always
did, with no stages.
model is config.agent.model. Threading it is what lets
ALLOWED_AGENT_FIELDS name the field: an allowlist entry asserts the
migrated path honours a field, so admitting model without passing
it here would turn a refusal into a silent substitution — the agent
would be admitted to the kernel and then answered by whichever model the
provider happened to declare.
max_conversation_messages is config.agent.max_conversation_messages
— the cap replayed history is trimmed at. On the constructor rather than
the call because it is agent configuration, not a property of the turn,
the same reason recursion_limit is here. None means "not stated"
and falls back to the stock value, which is what an admitted turn always
carries anyway: the envelope refuses the field. Threading it is
nonetheless what makes the trim honour the configuration rather than a
constant, so admitting the field later is a decision about evidence
instead of a change of behaviour.
bundle.tools are merged into this agent's own tools rather than
kept beside them (TA8.12) -- one normalized set, because a second
collection only some of the four tool readers consult is how a
contributed tool becomes bindable and not callable. bundle.delegation
is held for the other half: :meth:_delegation_scope opens a run scope
on it, which is how the turn's agent_depth reaches the ceiling those
tools enforce. See :mod:~symfonic.agent.cutover.delegation.
observability is the agent-lifetime half of TA8.20's migration: the
config, the metrics_collector and the OTEL handles the engine
holds, folded into one object that can compose a per-run event sink.
None means "nobody is watching", which is both the default and the
state every agent was in before the migration, and it binds no sink at
all. Like bundle, it is transported rather than derived — deciding
what observability a deployment buys is the host's decision that
symfonic.services.observability.suite already owns.
Source code in src/symfonic/agent/cutover/delegate.py
kernel_typed_stream ¶
Enter the kernel's typed projection for one compiled plan.
The one door this module's typed route goes through, and the reason
it is here rather than in typed_route: IPL-1 declares the pipeline
heads that may enter the invocation kernel, and this delegate is one of
them. A second module reaching InvocationKernel directly would be a
second entry point into the single invocation path, which is exactly
what that rule exists to prevent.
Source code in src/symfonic/agent/cutover/delegate.py
run
async
¶
run(query: str, *, run_id: str, session_id: str = '', response_model: type[Any] | None = None, scope: Any = None, history: Sequence[Any] | None = None, attachments: Sequence[Any] | None = None, tenant_id: str | None = None, agent_depth: int | None = None) -> AgentResponse
One non-streaming turn, compiled once and run once.
scope rides on the request, never on the plan: the plan is
compiled once for the agent's life and a scope burned into it would
make one agent answer for one tenant. The bundle stays immutable
configuration and nothing shared is mutated per call.
history and attachments ride there for the same reason — see
:func:~.turn_request.turn_request.
agent_depth is the turn's delegation depth and
:meth:_delegation_scope is what reads it. Per-call for the reason
scope is: a depth fixed at construction would be a parent that could
only ever be a root.
tenant_id is telemetry identity and nothing else: the legacy
FrameworkTenantScope.tenant_id, from the same expression
_legacy_run_impl hands to _otel_run_span, so an admitted run is
attributed to the tenant the replaced path attributed it to. Separate
from scope, which the engine builds through a translation answering
None for a scope it cannot read — right for recall, wrong for
billing.
Source code in src/symfonic/agent/cutover/delegate.py
stream
async
¶
stream(query: str, *, run_id: str, session_id: str = '', response_model: type[Any] | None = None, scope: Any = None, history: Sequence[Any] | None = None, attachments: Sequence[Any] | None = None, tenant_id: str | None = None, agent_depth: int | None = None) -> AsyncIterator[StreamChunk]
The streaming projection of the same invocation, not a second one.
scope is threaded for the same reason it is on :meth:run, and its
absence here was the sharper bug of the two: streaming admission already
accepted a caller scope, so a multi-tenant stream was admitted to the
kernel and then hydrated from the bundle's default scope. "The same
invocation, projected" has to include what the invocation was for.
tenant_id is threaded for the reason given on :meth:run, and the
release below is the half that matters more here: a consumer who stops
iterating ends the run with no terminal event, which is precisely the
abandoned run ObservabilityBridge.aclose exists for.
Source code in src/symfonic/agent/cutover/delegate.py
stream_typed ¶
The typed projection of the same invocation -- ST2's kernel route.
The body, and the full keyword list options carries, live next door
in :func:~.typed_route.typed_route, for the line-budget reason
rounds, authorised and delegation already moved out. Named
here because this is the address a caller holds.
Source code in src/symfonic/agent/cutover/delegate.py
rounds_for_recursion_limit ¶
Translate the legacy loop budget into the kernel's round budget.
The legacy body bounds the ReAct loop with LangGraph's recursion_limit,
which counts graph super-steps; the kernel bounds it with
PlanLimits.max_model_rounds, which counts model rounds. Two steps per
round — one model node, one tool node — is the conversion, and the graph
spends _ENTRY_STEPS before the first of them, so the entry cost comes
off the budget before the division rather than after.
Both corrections point the same way: never more rounds than the plain
react_loop graph. A conversion that only divided would give
50 -> 25 where that graph reaches 24, and the extra round is a billed
model call the path being replaced would not have made. Integer division is
the floor for the remainder, for the same reason.
The invariant is stated against react_loop and not "legacy" in general
because the two constants above are read off that one wiring. presets
adds a super-step per round for elicitation, for the experimental interrupt
and for the precondition gate, so those graphs reach fewer rounds than
(recursion_limit - 1) // 2 and this conversion would over-budget them.
None of them can reach here: ask_user_enabled,
experimental_interrupt and procedural_enforce_preconditions are all
named refusals in :mod:~symfonic.agent.cutover.envelope, and a non-ReAct
graph_preset is refused there too. If one of those switches flips, this
conversion has to grow the extra step with it.
Leaving the constant in place instead would bound every migrated turn at ten rounds while the legacy body allowed twenty-four, which truncates a long tool loop and returns the empty answer as if it were the real one.