Skip to content

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:

  1. run()'s force stamp was 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.

  2. stream() and stream_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(), and stream_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; None for "let the model choose" (which IS the safe default).
  • Per-iteration safe: the implementation may consult state and messages freely. Errors must degrade silently to None rather 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.

resolve async

resolve(
    state: dict[str, Any], messages: list[Any]
) -> str | None

Return the bare-identifier tool name to force, or None.

Source code in src/symfonic/core/force_resolver.py
async def resolve(
    self,
    state: dict[str, Any],
    messages: list[Any],
) -> str | None:
    """Return the bare-identifier tool name to force, or ``None``."""
    ...