Skip to content

symfonic.core.edges.tool_condition

tool_condition

tools_condition — route graph based on tool_calls presence.

Pure routing function — no state mutation. Returns framework-agnostic string literals "continue" / "finish". The preset maps these to actual graph node names and END.

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 src/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", [])
    if not messages:
        return "finish"

    last_message = messages[-1]
    if not getattr(last_message, "tool_calls", None):
        return "finish"

    # --- Loop detection (arg-sensitive signature) ---
    recent = messages[-_LOOP_WINDOW:]
    signatures: list[str] = []
    names: list[str] = []
    for msg in recent:
        for tc in getattr(msg, "tool_calls", []) or []:
            signatures.append(f"{tc['name']}:{str(tc.get('args', {}))}")
            names.append(tc["name"])

    if any(count >= _LOOP_THRESHOLD for count in Counter(signatures).values()):
        return "finish"

    # --- v8.6.4 opt-in NAME-ONLY cut (args ignored) ---
    name_only_threshold = state.get("_tool_loop_name_only_threshold")
    if (
        isinstance(name_only_threshold, int)
        and name_only_threshold > 0
        and any(count >= name_only_threshold for count in Counter(names).values())
    ):
        return "finish"

    return "continue"