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"
|