symfonic.core.force_resolver¶
force_resolver ¶
Force-resolution capability — v7.10 lifecycle-correct ownership.
Background¶
v7.8.3 introduced procedural_force_first_action_tool (Lever 1) as
an assemble-time stamp on the LangGraph state. The stamp ran ONCE
before self._runtime.run(...) and was read on every react
iteration via state["forced_tool_choice"]. The resolver's
idempotency clause (release when a matching ToolMessage is in
history) was therefore symmetric only on iteration 1. Two
consequences:
-
run()'s forcestampwas never re-evaluated after the loop started, so a force-then-release flow turned into an infinite force loop on iterations 2..N once v7.9.5 made the lever structurally fire. Hot-patched at the consumer in v7.9.6. -
stream()andstream_typed()paths never stamped at all, so the lever was inactive on streaming entry points entirely.
v7.10 relocates ownership of force-resolution from the engine assemble-time hook to the react node, where:
- Resolution runs once per react iteration, so force AND release are symmetric by construction (no separate consumer-side release needed for the live path).
run(),stream(), andstream_typed()share the same node, so the streaming-gap closes as a side effect.
The ForcedToolChoiceResolver Protocol below is what the react
node looks up in :class:BaseAgentDeps. SymfonicAgent adapts
its existing _maybe_resolve_forced_tool_choice method to this
Protocol at engine construction time so the relocation does not
duplicate logic.
Architectural contract¶
- Returns the bare-identifier tool name when forcing is unambiguous;
Nonefor "let the model choose" (which IS the safe default). - Per-iteration safe: the implementation may consult
stateandmessagesfreely. Errors must degrade silently toNonerather than stall the loop. - The framework constrains the choice set only; it never authors tool arguments or model-owned reasoning. This Protocol therefore returns a string (the tool name) -- never a tool-call payload.
ForcedToolChoiceResolver ¶
Bases: Protocol
Live, per-iteration resolver for the forced tool_choice.
Registered in :class:symfonic.core.deps.BaseAgentDeps by the
engine. The react node calls resolve(state, messages) on
every iteration; the return value is forwarded to
bind_tools(tools, tool_choice=<name>) (or omitted when
None).
Adopters who need a custom resolution policy can register their
own implementation via deps.register(ForcedToolChoiceResolver,
impl) -- the engine-registered default will be replaced.