Skip to content

symfonic.agent.cutover.rounds

rounds

One translation: the legacy loop budget expressed as the kernel's.

Split out of :mod:symfonic.agent.cutover.delegate, where it sat beside a class it shares nothing with. The delegate serves a turn; this is arithmetic over two constants that describe the legacy graph's shape, and the two change for unrelated reasons — a new preset node moves the numbers here and touches nothing there.

rounds_for_recursion_limit

rounds_for_recursion_limit(recursion_limit: Any) -> int

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.

Source code in src/symfonic/agent/cutover/rounds.py
def rounds_for_recursion_limit(recursion_limit: Any) -> int:
    """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.
    """
    if not isinstance(recursion_limit, int) or isinstance(recursion_limit, bool):
        return MAX_TOOL_ITERATIONS
    return max(1, (recursion_limit - _ENTRY_STEPS) // _STEPS_PER_ROUND)