Skip to content

symfonic.core.edges.tool_condition

tool_condition

tools_condition โ€” route the graph on the loop-termination verdict.

Pure routing function โ€” no state mutation. Returns framework-agnostic string literals "continue" / "finish"; the preset maps these to actual graph node names and END.

T3.1.3: the decision itself lives in :class:symfonic.capabilities.tools.execution.LoopTerminationPolicy, which names which of the three reasons stopped the loop. This module is the adapter: it reads the state stamp and collapses the verdict to the two-literal routing vocabulary LangGraph wants.

tools_condition

tools_condition(state: dict[str, Any]) -> Literal['continue', 'finish']

Route: tool_calls present -> 'continue', absent -> 'finish'.

Inspects the last message in state['messages']. Also detects infinite loops: if the same tool+args signature appears _LOOP_THRESHOLD or more times in the last _LOOP_WINDOW messages the graph is forced to 'finish' to avoid runaway execution.

v8.6.4 Ask 2(a) -- opt-in NAME-ONLY cut. The default cut above bakes args into the signature (name:str(args)), so a same-tool loop with VARYING args (e.g. paginating search(q="page1"), search(q="page2"), ...) never repeats a signature and never trips. When state["_tool_loop_name_only_threshold"] is a positive int, a SECOND cut fires: force finish if the same tool NAME (ignoring args) appears >= threshold times in the window. None (default, when the engine omits the key or the FrameworkConfig knob is unset) keeps this function byte-identical to the pre-v8.6.4 behaviour.

Source code in symfonic/core/edges/tool_condition.py
def tools_condition(state: dict[str, Any]) -> Literal["continue", "finish"]:
    """Route: tool_calls present -> 'continue', absent -> 'finish'.

    Inspects the last message in state['messages'].
    Also detects infinite loops: if the same tool+args signature appears
    _LOOP_THRESHOLD or more times in the last _LOOP_WINDOW messages the
    graph is forced to 'finish' to avoid runaway execution.

    v8.6.4 Ask 2(a) -- opt-in NAME-ONLY cut.  The default cut above bakes
    args into the signature (``name:str(args)``), so a same-tool loop with
    VARYING args (e.g. paginating ``search(q="page1")``,
    ``search(q="page2")``, ...) never repeats a signature and never trips.
    When ``state["_tool_loop_name_only_threshold"]`` is a positive int, a
    SECOND cut fires: force ``finish`` if the same tool NAME (ignoring
    args) appears >= threshold times in the window.  ``None`` (default,
    when the engine omits the key or the FrameworkConfig knob is unset)
    keeps this function byte-identical to the pre-v8.6.4 behaviour.
    """
    messages: list[BaseMessage] = state.get("messages", [])
    name_only_threshold = state.get("_tool_loop_name_only_threshold")
    policy = LoopTerminationPolicy(
        window=_LOOP_WINDOW,
        signature_threshold=_LOOP_THRESHOLD,
        name_only_threshold=(
            name_only_threshold if isinstance(name_only_threshold, int) else None
        ),
    )
    return "finish" if policy.evaluate(messages).terminate else "continue"