From 48e14efba997c8948c2cd2e4a081dfba0de17c2c Mon Sep 17 00:00:00 2001 From: yangyalong6 Date: Wed, 14 Oct 2026 14:32:06 +0800 Subject: [PATCH 1/2] feat: add tool-call loop detection for ReAct agents Detects four loop patterns (error_retry / repeat / repeat_by_output / cycle) during ReAct tool execution and reacts via a configurable mode: - disabled (default): no behaviour change - warn: inject a system hint into react_memory and keep going - block / fallback: stop the ReAct loop and return loop_fallback_message New files: - oxygent/utils/loop_detector.py core detection + prompt builder - oxygent/utils/loop_signatures.py tool-level signature registry - tests/unittest/test_loop_detector.py 16 unit tests - examples/agents/demo_loop_detection.py end-to-end demo Modified: - oxygent/oxy/agents/react_agent.py +47 lines (2 Fields + detection hook) SkillAgent inherits from ReActAgent so it gets the feature for free. Default mode is disabled; existing users see no behaviour change. Co-Authored-By: Claude Fable 5 --- examples/agents/demo_loop_detection.py | 73 +++++++ oxygent/oxy/agents/react_agent.py | 47 ++++ oxygent/utils/loop_detector.py | 285 +++++++++++++++++++++++++ oxygent/utils/loop_signatures.py | 173 +++++++++++++++ tests/unittest/test_loop_detector.py | 198 +++++++++++++++++ 5 files changed, 776 insertions(+) create mode 100644 examples/agents/demo_loop_detection.py create mode 100644 oxygent/utils/loop_detector.py create mode 100644 oxygent/utils/loop_signatures.py create mode 100644 tests/unittest/test_loop_detector.py diff --git a/examples/agents/demo_loop_detection.py b/examples/agents/demo_loop_detection.py new file mode 100644 index 0000000..bed7b70 --- /dev/null +++ b/examples/agents/demo_loop_detection.py @@ -0,0 +1,73 @@ +"""Demo: tool-call loop detection for ReAct agents. + +Shows how to enable `loop_detection_mode` on a ReActAgent so that repeated or +cyclic tool calls are caught and either (a) force the agent to receive a +system-level hint and continue (``warn``), or (b) terminate the ReAct loop and +return a configurable message to the caller (``block`` / ``fallback``). + +The ``buggy_search`` tool below always returns the same "no result" payload, +which forces any naive agent into a repeat loop. With detection enabled, the +agent stops after N identical calls instead of exhausting ``max_react_rounds``. +""" + +import asyncio +import os + +from oxygent import MAS, Config, oxy + +Config.set_agent_short_memory_size(7) + + +def buggy_search(query: str) -> str: + """A tool that always returns the same empty-result payload. + + Agents that keep trying different queries without noticing the result is + useless will eventually trigger ``repeat`` / ``repeat_by_output`` detection. + """ + return "No matching results. Please try again." + + +oxy_space = [ + oxy.HttpLLM( + name="default_llm", + api_key=os.getenv("DEFAULT_LLM_API_KEY"), + base_url=os.getenv("DEFAULT_LLM_BASE_URL"), + model_name=os.getenv("DEFAULT_LLM_MODEL_NAME"), + llm_params={"temperature": 0.01}, + semaphore=4, + timeout=300, + retries=3, + ), + oxy.FunctionTool( + name="buggy_search", + description="Search a knowledge base. Always returns no results in this demo.", + func_process=buggy_search, + ), + oxy.ReActAgent( + name="master_agent", + llm_model="default_llm", + tools=["buggy_search"], + prompt="You are a helpful assistant. Use the search tool to find answers.", + # Enable loop detection in 'block' mode. Switch to 'warn' to let the + # agent receive a system hint and try to self-correct, or 'disabled' to + # see the baseline behaviour (will burn through max_react_rounds). + loop_detection_mode="block", + loop_fallback_message=( + "The agent appears to be stuck in a tool-call loop and could not " + "complete the task. Please rephrase your question." + ), + max_react_rounds=10, + ), +] + + +async def main(): + async with MAS(oxy_space=oxy_space) as mas: + await mas.start_web_service( + first_query="Find the latest research on quantum tunneling.", + welcome_message="Hi, I'm the loop-detection demo. Ask me anything.", + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/oxygent/oxy/agents/react_agent.py b/oxygent/oxy/agents/react_agent.py index e33b132..0f9ca14 100644 --- a/oxygent/oxy/agents/react_agent.py +++ b/oxygent/oxy/agents/react_agent.py @@ -27,6 +27,7 @@ OxyState, ) from ...utils.common_utils import chunk_list, extract_first_json, generate_uuid +from ...utils.loop_detector import build_loop_break_prompt, detect_loop from ..base_oxy import ensure_async from .local_agent import LocalAgent @@ -83,6 +84,21 @@ class ReActAgent(LocalAgent): trust_mode: bool = Field(False, description="Enable trust mode for direct results") + loop_detection_mode: str = Field( + "disabled", + description=( + "Tool-call loop detection mode. One of: 'disabled', 'warn', 'block', " + "'fallback'. 'warn' logs a warning; 'block' stops the ReAct loop and " + "returns loop_fallback_message; 'fallback' is identical to 'block' " + "but can be customised via loop_fallback_message." + ), + ) + loop_fallback_message: str = Field( + "A tool-call loop was detected and execution was stopped. " + "Please rephrase your question or try again.", + description="Message returned to the caller when loop_detection_mode is 'block' or 'fallback'.", + ) + func_parse_llm_response: Optional[Callable[[str, OxyRequest], LLMResponse]] = Field( None, exclude=True, description="Function to parse LLM output" ) @@ -427,6 +443,37 @@ async def _execute(self, oxy_request: OxyRequest) -> OxyResponse: Message.assistant_message(llm_response.ori_response) ) react_memory.add_message(Message.user_message(observation.to_str())) + + # Loop detection: observe this round's tool calls / results and + # react to repeated / cyclical patterns according to mode. + if self.loop_detection_mode != "disabled": + loop_info = detect_loop( + oxy_request.shared_data, + tool_call_dict_list, + [r.output for r in oxy_responses], + current_round, + ) + if loop_info is not None: + logger.warning( + f"Loop detected: {loop_info}", + extra={ + "trace_id": oxy_request.current_trace_id, + "node_id": oxy_request.node_id, + }, + ) + if self.loop_detection_mode in ("block", "fallback"): + return OxyResponse( + state=OxyState.COMPLETED, + output=self.loop_fallback_message, + extra={ + "react_memory": react_memory.to_dict_list(), + "loop_detected": loop_info, + }, + ) + # 'warn' mode: inject a system hint and keep going + react_memory.add_message( + Message.user_message(build_loop_break_prompt(loop_info)) + ) else: # Parsing error - add to memory for correction logger.info( diff --git a/oxygent/utils/loop_detector.py b/oxygent/utils/loop_detector.py new file mode 100644 index 0000000..ac251c5 --- /dev/null +++ b/oxygent/utils/loop_detector.py @@ -0,0 +1,285 @@ +# -*- encoding: utf-8 -*- +"""ReAct loop detection utilities. + +Responsibilities: +1. Detect: fingerprint tool calls and spot three loop patterns + - error_retry: same arguments retried right after an error + - repeat: same arguments repeated N times in a row + - repeat_by_output: different arguments but same result category N times + - cycle: ABAB / ABCABC oscillation between tools +2. Build prompts: pure helpers that turn a detection result into a system + message the agent layer can inject into its memory. + +The detector is stateless aside from a short trace buffer stored in +``shared_data``; it is safe to call once per tool-execution round. + +Usage: + from oxygent.utils.loop_detector import detect_loop, build_loop_break_prompt + + detect_loop(oxy_request.shared_data, tool_calls, tool_results, current_round) + loop_info = oxy_request.shared_data.get("_loop_detected") + if loop_info: + prompt = build_loop_break_prompt(loop_info) +""" + +import hashlib +import json +from typing import Optional, Sequence + +from oxygent.utils.loop_signatures import ( + compute_signature, + get_signature_threshold, + is_benign_signature, +) + +# ---------------------------------------------------------------------- +# Constants +# ---------------------------------------------------------------------- + +TRACE_KEY = "_loop_detection_trace" +RESULT_KEY = "_loop_detected" + +# Trace buffer length (needs 2*period to detect period-3 cycles) +_MAX_TRACE_LEN = 6 + +# Error keywords: presence in a tool result marks it as an error +_ERROR_KEYWORDS = ( + "Traceback (most recent call last)", + "Exception:", + "Error:", + "FAILED", + '"status": "error"', + '"status": "fail"', + "", +) + +# Signature prefixes that count as errors for _detect_error_retry +_ERROR_SIGNATURE_PREFIXES = ( + "permanent_block", + "client_error", + "biz_fail", + "auth_err", + "error", + "denied", +) + +# Loop-break prompt templates +_LOOP_BREAK_PROMPTS = { + "error_retry": ( + "[System] You retried the failing tool '{tool_name}' with the same arguments. " + "The call will fail again. Analyze the error, change your arguments, " + "or try a different tool. If the task cannot be completed, say so clearly." + ), + "repeat": ( + "[System] You have called '{tool_name}' with identical arguments multiple times. " + "The result will not change. Stop repeating; switch strategy or give the final answer." + ), + "repeat_by_output": ( + "[System] You have called '{tool_name}' with different arguments but the result " + "category stays the same (signature={output_sig}). Changing arguments will not " + "unblock this - it is likely a permission / network / policy block, or this " + "direction has no data. Switch to a different tool or give the final answer." + ), + "cycle": ( + "[System] You are oscillating between tools (around '{tool_name}') without progress. " + "Stop this pattern and give the final answer based on information already gathered." + ), +} + + +# ---------------------------------------------------------------------- +# Detection entry point +# ---------------------------------------------------------------------- + + +def detect_loop( + shared_data: dict, + tool_calls: Sequence, + tool_results: Sequence, + current_round: int, +) -> Optional[dict]: + """Main detection entry point. Call once per ReAct round after tool results are known. + + Args: + shared_data: Mutable dict shared across the request (``OxyRequest.shared_data``). + tool_calls: Tool-call dicts for this round, each shaped like + ``{"tool_name": str, "arguments": dict}``. + tool_results: Tool execution results aligned with ``tool_calls``. Each item + can be a plain string, an ``OxyResponse``-like object with an ``output`` + attribute, or any serializable value. + current_round: Zero-based ReAct round index. + + Returns: + The detection dict (also stored at ``shared_data[RESULT_KEY]``) when a + loop is detected, otherwise ``None``. + """ + if shared_data.get(RESULT_KEY): + return shared_data[RESULT_KEY] + + trace = shared_data.setdefault(TRACE_KEY, []) + + for tc, result in zip(tool_calls, tool_results): + tool_name = tc.get("tool_name") or "" + if not tool_name: + continue + arguments = tc.get("arguments") or {} + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except (ValueError, TypeError): + arguments = {"__raw__": arguments} + + result_text = _coerce_result_text(result) + entry = { + "fingerprint": _compute_fingerprint(tool_name, arguments), + "output_sig": compute_signature(tool_name, result_text, arguments), + "tool_name": tool_name, + "is_error": False, + } + entry["is_error"] = _is_error_result(result_text) or _is_error_signature( + entry["output_sig"] + ) + trace.append(entry) + if len(trace) > _MAX_TRACE_LEN: + trace.pop(0) + + detected = ( + _detect_error_retry(trace) + or _detect_repeat(trace) + or _detect_repeat_by_output(trace) + or _detect_cycle(trace) + ) + if detected: + detected["round"] = current_round + shared_data[RESULT_KEY] = detected + return detected + return None + + +def build_loop_break_prompt(loop_info: dict) -> str: + """Return a system message describing the detected loop.""" + template = _LOOP_BREAK_PROMPTS.get( + loop_info.get("type", ""), _LOOP_BREAK_PROMPTS["repeat"] + ) + return template.format( + tool_name=loop_info.get("tool_name", "unknown"), + output_sig=loop_info.get("output_sig", "unknown"), + ) + + +# ---------------------------------------------------------------------- +# Internal helpers +# ---------------------------------------------------------------------- + + +def _coerce_result_text(result) -> str: + """Convert a tool result into text for signature / keyword matching.""" + if isinstance(result, str): + return result + output = getattr(result, "output", None) + if output is not None: + return output if isinstance(output, str) else str(output) + return str(result) + + +def _compute_fingerprint(tool_name: str, arguments: dict) -> str: + """Deterministic 12-char MD5 fingerprint of (tool_name, arguments).""" + canonical = json.dumps( + {"t": tool_name, "a": arguments}, + sort_keys=True, + ensure_ascii=False, + default=str, + ) + return hashlib.md5(canonical.encode()).hexdigest()[:12] + + +def _is_error_result(text: str) -> bool: + return any(kw in text for kw in _ERROR_KEYWORDS) + + +def _is_error_signature(signature: str) -> bool: + if not signature: + return False + prefix = signature.split(":", 1)[0] if ":" in signature else signature + return prefix in _ERROR_SIGNATURE_PREFIXES + + +def _detect_error_retry(trace: list) -> Optional[dict]: + """Trigger: previous step was an error and current step has the same fingerprint.""" + if len(trace) < 2: + return None + prev, curr = trace[-2], trace[-1] + if prev["is_error"] and curr["fingerprint"] == prev["fingerprint"]: + return { + "type": "error_retry", + "fingerprint": curr["fingerprint"], + "tool_name": curr["tool_name"], + } + return None + + +def _detect_repeat(trace: list, threshold: int = 3) -> Optional[dict]: + """Trigger: last ``threshold`` entries share the same fingerprint.""" + if len(trace) < threshold: + return None + recent = trace[-threshold:] + if all(e["fingerprint"] == recent[0]["fingerprint"] for e in recent): + return { + "type": "repeat", + "fingerprint": recent[0]["fingerprint"], + "tool_name": recent[0]["tool_name"], + } + return None + + +def _detect_repeat_by_output(trace: list) -> Optional[dict]: + """Trigger: same output signature N times with at least two distinct fingerprints. + + Catches the "LLM changed arguments but the result category stayed the same" + case. Only fires for classifier-provided signatures (not the ``h:`` fallback) + and skips benign signatures (``ok``, ``progress``). + """ + if len(trace) < 2: + return None + latest_sig = trace[-1].get("output_sig", "") + if not latest_sig or latest_sig.startswith("h:"): + return None + if is_benign_signature(latest_sig): + return None + threshold = get_signature_threshold(latest_sig, default=3) + if len(trace) < threshold: + return None + recent = trace[-threshold:] + if not all(e.get("output_sig") == latest_sig for e in recent): + return None + if len({e.get("fingerprint") for e in recent}) < 2: + return None + return { + "type": "repeat_by_output", + "output_sig": latest_sig, + "fingerprint": recent[-1]["fingerprint"], + "tool_name": recent[-1]["tool_name"], + "threshold": threshold, + } + + +def _detect_cycle(trace: list) -> Optional[dict]: + """Trigger: period-2 or period-3 oscillation in fingerprints or output signatures.""" + for field, source in (("fingerprint", "input"), ("output_sig", "output")): + seq = [e.get(field, "") for e in trace] + for period in (2, 3): + if len(seq) < period * 2: + continue + recent = seq[-(period * 2) :] + if recent[:period] != recent[period:] or len(set(recent)) <= 1: + continue + if source == "output" and all(is_benign_signature(s) for s in recent): + continue + return { + "type": "cycle", + "source": source, + "fingerprint": trace[-1]["fingerprint"], + "output_sig": trace[-1].get("output_sig", ""), + "tool_name": trace[-1]["tool_name"], + } + return None diff --git a/oxygent/utils/loop_signatures.py b/oxygent/utils/loop_signatures.py new file mode 100644 index 0000000..84fbe68 --- /dev/null +++ b/oxygent/utils/loop_signatures.py @@ -0,0 +1,173 @@ +# -*- encoding: utf-8 -*- +"""Tool-level output signature registry for loop detection. + +Design: + Loop Detector decides whether a tool call is a repeat not only by its input + fingerprint (hash of tool_name + arguments), but also by an output signature + that captures "what kind of result did this call produce". + + Two layers: + - Layer 1 (default fallback): a noise-stripped hash of the result. Catches + the common case where the LLM varies arguments but keeps producing an + identical response. + - Layer 2 (opt-in per tool): tool authors register a classifier function + via @register_signature. Useful when the raw result contains noise + (timestamps, UUIDs) or when distinct error categories should be + collapsed to the same signature (e.g. "permission_denied" vs + "network_timeout"). + + The detector prefers Layer 2 and falls back to Layer 1 when no classifier + is registered, or if the classifier raises or returns an empty value. + +Usage: + from oxygent.utils.loop_signatures import register_signature + + @register_signature("my_tool") + def _my_tool_signature(result, args): + if "PermissionDenied" in result: + return "denied" + return "ok" +""" + +import hashlib +import re +from typing import Any, Callable, Optional + +# ---------------------------------------------------------------------- +# Registry +# ---------------------------------------------------------------------- + +_REGISTRY: dict[str, Callable[[Any, dict], str]] = {} + + +def register_signature(tool_name: str): + """Decorator that attaches a signature classifier to a tool name. + + Args: + tool_name: Must match the ``tool_name`` field used in tool calls. + """ + + def decorator(fn: Callable[[Any, dict], str]) -> Callable[[Any, dict], str]: + _REGISTRY[tool_name] = fn + return fn + + return decorator + + +def compute_signature(tool_name: str, result: Any, args: Optional[dict] = None) -> str: + """Entry point used by the detector. + + Uses the registered classifier when present; otherwise falls back to the + noise-stripped hash. Classifier exceptions or empty returns fall back too. + """ + fn = _REGISTRY.get(tool_name) + if fn is not None: + try: + sig = fn(result, args or {}) + if isinstance(sig, str) and sig: + return sig + except Exception: + pass + return _default_hash_signature(result) + + +def get_signature_threshold(signature: str, default: int = 3) -> int: + """Look up the trigger threshold for a signature. + + Supports colon-prefix matching: ``"permanent_block:network_403"`` falls + back to the ``"permanent_block"`` entry when no exact match exists. + """ + if signature in _SIGNATURE_THRESHOLDS: + return _SIGNATURE_THRESHOLDS[signature] + if ":" in signature: + prefix = signature.split(":", 1)[0] + if prefix in _SIGNATURE_THRESHOLDS: + return _SIGNATURE_THRESHOLDS[prefix] + return default + + +# ---------------------------------------------------------------------- +# Layer 1: default noise-stripped hash +# ---------------------------------------------------------------------- + +_NOISE_PATTERNS = [ + # ISO / RFC timestamps + ( + re.compile( + r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?" + ), + "", + ), + # UUID + ( + re.compile( + r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", re.I + ), + "", + ), + # 32-64 hex strings (trace_id / md5 / sandbox_id) + (re.compile(r"\b[0-9a-f]{32,64}\b", re.I), ""), + # HTTP Date header + ( + re.compile( + r"\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s+\d{1,2}\s+\w{3}\s+\d{4}\s+\d{2}:\d{2}:\d{2}\s+GMT" + ), + "", + ), + # Python object memory addresses + (re.compile(r"at 0x[0-9a-f]+"), "at 0x"), + # Keyed trace/request/session ids + ( + re.compile( + r"([\"']?[\w-]*(?:trace|request|session|req)[_-]?id[\"']?\s*[:=]\s*)[\"']?[\w.\-]+[\"']?", + re.I, + ), + r"\1", + ), +] + + +def _strip_noise(text: str) -> str: + for pattern, repl in _NOISE_PATTERNS: + text = pattern.sub(repl, text) + return text + + +def _default_hash_signature(result: Any) -> str: + """Layer 1: MD5(first 12 chars) of the noise-stripped result, prefixed ``h:``.""" + text = result if isinstance(result, str) else str(result) + text = _strip_noise(text) + return "h:" + hashlib.md5(text.encode("utf-8", errors="replace")).hexdigest()[:12] + + +# ---------------------------------------------------------------------- +# Per-signature thresholds +# ---------------------------------------------------------------------- + +_SIGNATURE_THRESHOLDS: dict[str, int] = { + "auth_err": 2, + "permanent_block": 2, + "client_error": 3, + "biz_fail": 3, + "no_progress": 2, + "empty": 3, + "denied": 3, +} + + +# ---------------------------------------------------------------------- +# Benign signatures: never trigger repeat_by_output +# ---------------------------------------------------------------------- + +BENIGN_SIGNATURES = {"ok", "progress"} + + +def is_benign_signature(signature: str) -> bool: + """Return True if the signature represents a successful / progressing call.""" + if signature in BENIGN_SIGNATURES: + return True + if ":" in signature: + prefix = signature.split(":", 1)[0] + if prefix in BENIGN_SIGNATURES: + return True + return False diff --git a/tests/unittest/test_loop_detector.py b/tests/unittest/test_loop_detector.py new file mode 100644 index 0000000..cfd4502 --- /dev/null +++ b/tests/unittest/test_loop_detector.py @@ -0,0 +1,198 @@ +# -*- encoding: utf-8 -*- +"""Unit tests for oxygent.utils.loop_detector.""" + +import pytest + +from oxygent.utils.loop_detector import ( + RESULT_KEY, + TRACE_KEY, + build_loop_break_prompt, + detect_loop, +) +from oxygent.utils.loop_signatures import ( + compute_signature, + is_benign_signature, + register_signature, +) + + +def _call(tool_name, **kwargs): + return {"tool_name": tool_name, "arguments": kwargs} + + +# ---------------------------------------------------------------------- +# detect_loop: _detect_repeat +# ---------------------------------------------------------------------- + + +def test_repeat_triggers_after_three_identical_calls(): + shared = {} + for i in range(3): + detect_loop(shared, [_call("search", q="foo")], ["no result"], current_round=i) + assert shared[RESULT_KEY]["type"] == "repeat" + assert shared[RESULT_KEY]["tool_name"] == "search" + assert shared[RESULT_KEY]["round"] == 2 + + +def test_repeat_not_triggered_on_two_calls(): + shared = {} + for i in range(2): + detect_loop(shared, [_call("search", q="foo")], ["x"], current_round=i) + assert RESULT_KEY not in shared + + +# ---------------------------------------------------------------------- +# detect_loop: _detect_error_retry +# ---------------------------------------------------------------------- + + +def test_error_retry_detects_same_args_after_error(): + shared = {} + detect_loop(shared, [_call("http", url="x")], ["Error: timeout"], current_round=0) + detect_loop(shared, [_call("http", url="x")], ["Error: timeout"], current_round=1) + assert shared[RESULT_KEY]["type"] == "error_retry" + + +def test_error_retry_not_triggered_when_args_change(): + shared = {} + detect_loop(shared, [_call("http", url="x")], ["Error: timeout"], current_round=0) + detect_loop(shared, [_call("http", url="y")], ["Error: timeout"], current_round=1) + assert RESULT_KEY not in shared + + +# ---------------------------------------------------------------------- +# detect_loop: _detect_cycle (ABAB) +# ---------------------------------------------------------------------- + + +def test_cycle_abab_pattern(): + shared = {} + seq = ["A", "B", "A", "B"] + for i, name in enumerate(seq): + detect_loop(shared, [_call(name, q=i % 2)], ["ok"], current_round=i) + assert shared[RESULT_KEY]["type"] == "cycle" + + +# ---------------------------------------------------------------------- +# detect_loop: _detect_repeat_by_output +# ---------------------------------------------------------------------- + + +def test_repeat_by_output_requires_classifier_signature(): + @register_signature("flaky_tool") + def _sig(result, args): + return "biz_fail" + + shared = {} + # Three different arg sets, same classifier signature. + for i in range(3): + detect_loop( + shared, + [_call("flaky_tool", attempt=i)], + [f"attempt {i} failed with code 500"], + current_round=i, + ) + assert shared[RESULT_KEY]["type"] == "repeat_by_output" + assert shared[RESULT_KEY]["output_sig"] == "biz_fail" + + +def test_benign_signature_does_not_trigger_repeat_by_output(): + @register_signature("good_tool") + def _sig(result, args): + return "ok" + + shared = {} + for i in range(5): + detect_loop( + shared, + [_call("good_tool", attempt=i)], + [f"success {i}"], + current_round=i, + ) + assert RESULT_KEY not in shared + + +# ---------------------------------------------------------------------- +# detect_loop: miscellaneous +# ---------------------------------------------------------------------- + + +def test_detect_loop_is_idempotent_after_hit(): + shared = {} + for i in range(3): + detect_loop(shared, [_call("x", a=1)], ["same"], current_round=i) + first = shared[RESULT_KEY] + detect_loop(shared, [_call("x", a=1)], ["same"], current_round=99) + assert shared[RESULT_KEY] is first # not re-written + + +def test_detect_loop_handles_oxy_response_like_objects(): + class Fake: + def __init__(self, text): + self.output = text + + shared = {} + for i in range(3): + detect_loop(shared, [_call("x", a=1)], [Fake("same")], current_round=i) + assert shared[RESULT_KEY]["type"] == "repeat" + + +def test_detect_loop_handles_string_arguments(): + shared = {} + tc = {"tool_name": "x", "arguments": '{"a": 1}'} + for i in range(3): + detect_loop(shared, [tc], ["same"], current_round=i) + assert shared[RESULT_KEY]["type"] == "repeat" + + +def test_trace_is_bounded(): + shared = {} + for i in range(20): + detect_loop(shared, [_call(f"t{i}", a=i)], [f"r{i}"], current_round=i) + assert len(shared[TRACE_KEY]) <= 6 + + +# ---------------------------------------------------------------------- +# Signatures +# ---------------------------------------------------------------------- + + +def test_default_hash_signature_strips_timestamps(): + sig_a = compute_signature("any", "result at 2026-01-01T00:00:00Z done") + sig_b = compute_signature("any", "result at 2026-07-15T12:30:00Z done") + assert sig_a == sig_b + assert sig_a.startswith("h:") + + +def test_is_benign_signature_matches_prefix(): + assert is_benign_signature("ok") + assert is_benign_signature("ok:detailed") + assert not is_benign_signature("biz_fail") + + +def test_classifier_exception_falls_back_to_hash(): + @register_signature("boom_tool") + def _sig(result, args): + raise RuntimeError("classifier broken") + + sig = compute_signature("boom_tool", "payload") + assert sig.startswith("h:") + + +# ---------------------------------------------------------------------- +# Prompt builder +# ---------------------------------------------------------------------- + + +def test_build_loop_break_prompt_contains_tool_name(): + prompt = build_loop_break_prompt({"type": "repeat", "tool_name": "search"}) + assert "search" in prompt + + +def test_build_loop_break_prompt_defaults_for_unknown_type(): + prompt = build_loop_break_prompt({"type": "???", "tool_name": "search"}) + assert "search" in prompt + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 33654e93540145fbfa485e1062c5e05ccf279726 Mon Sep 17 00:00:00 2001 From: yangyalong6 Date: Thu, 15 Oct 2026 18:36:37 +0800 Subject: [PATCH 2/2] docs: add feature spec and test report; remove redundant 'fallback' mode - Add docs/loop_detection_feature.md (12-section feature spec covering problem statement, architecture, four detection patterns, three handling modes, two-layer signature mechanism, usage examples, design trade-offs and FAQ) - Add docs/loop_detection_test_report.md (comprehensive test report: 16 unit tests, 3-mode e2e smoke, Web UI real-request verification, 45-test targeted regression, 346-test full regression, performance comparison, risk analysis) - Remove 'fallback' mode (semantic duplicate of 'block'); loop_detection_mode now accepts only 'disabled' | 'warn' | 'block' - Tighten demo prompt so the LLM reliably triggers the search tool Co-Authored-By: Claude Fable 5 --- docs/loop_detection_feature.md | 606 +++++++++++++++++++++++++ docs/loop_detection_test_report.md | 555 ++++++++++++++++++++++ examples/agents/demo_loop_detection.py | 7 +- oxygent/oxy/agents/react_agent.py | 11 +- 4 files changed, 1172 insertions(+), 7 deletions(-) create mode 100644 docs/loop_detection_feature.md create mode 100644 docs/loop_detection_test_report.md diff --git a/docs/loop_detection_feature.md b/docs/loop_detection_feature.md new file mode 100644 index 0000000..fee3d78 --- /dev/null +++ b/docs/loop_detection_feature.md @@ -0,0 +1,606 @@ +# ReAct Agent 工具调用循环检测 — 功能说明 + +| 项目 | 内容 | +|------|------| +| **功能名称** | Loop Detection for ReAct Agents | +| **所属模块** | `oxygent.utils.loop_detector` / `oxygent.utils.loop_signatures` | +| **接入点** | `oxygent.oxy.agents.ReActAgent`(及其子类 `SkillAgent` / `RagAgent`)| +| **版本** | feature-loopdetection-20261014 | +| **状态** | 已完成单测与端到端验证,待合入 | + +--- + +## 目录 + +1. [一句话概括](#1-一句话概括) +2. [要解决的问题](#2-要解决的问题) +3. [功能全貌](#3-功能全貌) +4. [四种循环模式](#4-四种循环模式) +5. [三种处理模式](#5-三种处理模式) +6. [两层输出签名机制](#6-两层输出签名机制) +7. [使用方式](#7-使用方式) +8. [工作原理](#8-工作原理) +9. [与现有系统的关系](#9-与现有系统的关系) +10. [扩展与定制](#10-扩展与定制) +11. [设计取舍](#11-设计取舍) +12. [FAQ](#12-faq) + +--- + +## 1. 一句话概括 + +> **自动识别 ReAct Agent 的工具调用死循环,可配置为静默、告警或立即终止,默认关闭,对现有用户零影响。** + +--- + +## 2. 要解决的问题 + +### 2.1 现状痛点 + +OxyGent 的 `ReActAgent` 以 `max_react_rounds`(默认 16)为唯一兜底 —— 只要 agent 没返回最终答案就一直循环。但现实中大量场景是 LLM **陷入了无效循环**: + +- **工具报错 → LLM 看不懂 → 用相同参数再调 → 再报错**(头铁硬刚) +- **同一个 query 查到空结果 → LLM 不死心 → 换个 query → 还是空**(换皮循环) +- **权限/网络被拒 → LLM 以为是参数问题 → 反复换参数**(永久阻断仍重试) +- **A 工具 → B 工具 → A 工具 → B 工具**(钟摆震荡) + +这些场景的共同特征: +1. **一定会跑满 `max_react_rounds` 才停**,烧 10 倍以上 LLM 调用 +2. **最终返回的答案往往是胡编**(LLM 在上下文窗口耗尽后编一个结论糊弄) +3. **工具侧的流控/配额被白白消耗**(相同参数反复调同一个工具) +4. **用户等待 30 秒+**,体验极差 + +### 2.2 本功能的价值 + +| 维度 | 改进前 | 改进后 | +|------|-------|-------| +| LLM 调用次数 | 跑满 max_react_rounds(10-16 次)| 3 次触发后立即停 | +| 响应时间 | 5-30 秒 | 1-2 秒 | +| 工具调用次数 | 跑满 max_react_rounds | 3 次触发后立即停 | +| 用户体验 | 等很久拿一个胡诌答案 | 快速拿到 "请换个问法" 的明确提示 | +| 问题可观测 | 需翻完整轮次日志才能看出循环 | 一条 WARN 日志 `Loop detected: {...}` | + +--- + +## 3. 功能全貌 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ReActAgent._execute() │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ 每一轮循环: │ │ +│ │ 1. LLM 生成 tool_call │ │ +│ │ 2. 执行工具 → 拿到结果 │ │ +│ │ 3. 写入 react_memory │ │ +│ │ 4. 【本次新增】调用 detect_loop(...) ←────────┼──┐ │ +│ │ - 读 shared_data 里的历史 trace │ │ │ +│ │ - 跑 4 个检测器 │ │ │ +│ │ - 命中 → 按配置的模式处理 │ │ │ +│ └──────────────────────────────────────────────────┘ │ │ +│ │ │ +└──────────────────────────────────────────────────────────┼───┘ + │ + ┌────────────────────────────┼───┐ + │ oxygent/utils/ │ │ + │ ├── loop_detector.py ◄───┘ │ + │ │ • 4 个检测器 │ + │ │ • trace 缓冲区(上限 6) │ + │ │ • 提示模板 │ + │ │ │ + │ └── loop_signatures.py │ + │ • Layer 1 hash 兜底 │ + │ • Layer 2 @register │ + │ • 阈值表 / 良性签名 │ + └────────────────────────────────┘ +``` + +### 3.1 核心能力清单 + +| 能力 | 组件 | 说明 | +|------|------|------| +| 识别"同参重调" | `_detect_repeat` | 连续 N 次完全相同参数 | +| 识别"报错硬刚" | `_detect_error_retry` | 工具报错后同参再调 | +| 识别"换皮循环" | `_detect_repeat_by_output` | 换参数但结果类别一致 | +| 识别"钟摆震荡" | `_detect_cycle` | ABAB / ABCABC 周期 | +| 输出降噪 | Layer 1 签名 | 剥除时间戳 / UUID / trace_id | +| 自定义归类 | Layer 2 签名 | 装饰器注册语义分类 | +| 静默跳过 | `disabled` 模式 | 默认,零开销 | +| 告警 + 自救 | `warn` 模式 | 注入系统提示让 LLM 自救 | +| 立即终止 | `block` 模式 | 返回 fallback 消息 | +| 诊断留痕 | `shared_data` | 命中后保留 `_loop_detected` 结构 | + +--- + +## 4. 四种循环模式 + +### 4.1 error_retry(头铁硬刚) + +**触发条件**: 上一步工具报错,当前步用**完全相同参数**再调。 + +**典型场景**: +``` +step 1: curl(url="http://xxx") → Error: timeout +step 2: curl(url="http://xxx") → 触发 error_retry +``` + +**为什么要立即拦**: 参数没变,网络环境没变,结果几乎必然再次报错。 + +### 4.2 repeat(单曲循环) + +**触发条件**: 连续 3 次(默认阈值)用**完全相同参数**调同一工具。 + +**典型场景**: +``` +step 1: search(q="量子隧穿") → "No matching results" +step 2: search(q="量子隧穿") → "No matching results" +step 3: search(q="量子隧穿") → 触发 repeat +``` + +**为什么阈值是 3**: 允许正常的"查一次没有,稍微换一下再查一次"(2 次不触发),但第 3 次相同参数就不合理了。 + +### 4.3 repeat_by_output(换皮循环) + +**触发条件**: 换参数但结果**语义类别一致** N 次(阈值按签名类别可配)。 + +**典型场景**: +``` +step 1: curl(url="http://a") → "403 Permission Denied" → sig=permanent_block +step 2: curl(url="http://b") → "403 Permission Denied" → sig=permanent_block(阈值=2) + → 触发 repeat_by_output +``` + +**依赖**: 需要工具通过 `@register_signature` 注册自定义签名;未注册时降级为 Layer 1 hash,只能抓完全一致输出。 + +**为什么需要**: `curl` 调不同 URL 都被同一个 proxy 拒掉 → fingerprint 不同,Layer 1 hash 不同,普通 `repeat` 抓不到。签名让"同类错误"坍缩成一个类别。 + +### 4.4 cycle(钟摆震荡) + +**触发条件**: 最近 4 步(或 6 步)的指纹序列呈现 ABAB / ABCABC 模式。 + +**典型场景**: +``` +step 1: list_files() ← A +step 2: read_file(f1) ← B +step 3: list_files() ← A +step 4: read_file(f1) ← B → 触发 cycle(周期 2) +``` + +**为什么需要**: `repeat` 只能抓单工具循环,钟摆是多工具循环。 + +--- + +## 5. 三种处理模式 + +通过 `ReActAgent.loop_detection_mode` 配置: + +### 5.1 `disabled`(默认) + +```python +loop_detection_mode="disabled" +``` + +**行为**: 完全跳过检测器,不读取 `shared_data`,不写 trace。 + +**何时用**: 现有用户升级后无需任何改动 —— 这是默认值,保证**零破坏性变更**。 + +### 5.2 `warn`(告警 + 自救) + +```python +loop_detection_mode="warn" +``` + +**行为**: +1. 日志打印 `WARNING - Loop detected: {...}` +2. 向 `react_memory` 注入一条**面向 LLM 的系统提示**,告诉它检测到了什么循环,建议它换策略 +3. **继续执行**后续 react 轮次 + +**何时用**: 开发/调试期,或希望给 LLM 一次自救机会的生产场景。 + +**注入提示示例**(type=repeat 时): +> `[System] You have called 'search' with identical arguments multiple times. The result will not change. Stop repeating; switch strategy or give the final answer.` + +### 5.3 `block`(立即终止) + +```python +loop_detection_mode="block" +loop_fallback_message="已检测到工具调用循环,请换一种问法。" +``` + +**行为**: +1. 日志打印 `WARNING - Loop detected: {...}` +2. **立即终止** ReAct 循环 +3. 返回 `OxyResponse(output=loop_fallback_message, extra={..., "loop_detected": {...}})` + +**何时用**: 生产环境,希望快速止损、给用户明确反馈。`extra.loop_detected` 可被上游消费做监控/告警。 + +--- + +## 6. 两层输出签名机制 + +签名把工具的输出归类成一个短字符串(如 `"ok"` / `"empty"` / `"permanent_block:auth"`),用于 `repeat_by_output` 和 `cycle` 的输出维度检测。 + +### 6.1 Layer 1:默认 hash 兜底 + +**没注册签名的工具自动走这里**。流程: + +1. 对工具返回文本做噪声剥除(见下表) +2. 取 MD5 前 12 位 +3. 前缀 `h:` 便于日志中区分 + +**剥除的噪声模式**: +| 噪声类型 | 示例 | 替换为 | +|---------|-----|-------| +| ISO/RFC 时间戳 | `2026-10-14T12:30:00Z` | `` | +| UUID | `3a7f9b2c-...` | `` | +| 32-64 位 hex | `abc123...` (MD5/SHA/trace_id) | `` | +| HTTP Date | `Mon, 14 Oct 2026 ...` | `` | +| Python 内存地址 | `at 0x7f...` | `at 0x` | +| 键控 id | `trace_id="xxx"` / `requestId: xxx` | `` | + +**优点**: 零配置、能抓到"每次返回完全一样"的循环。 +**局限**: 抓不到"每次返回不同文本但语义是同类错误"的循环。 + +### 6.2 Layer 2:工具自定义签名 + +**适用于需要语义归类的工具**。用装饰器注册: + +```python +from oxygent.utils.loop_signatures import register_signature + +@register_signature("search_api") +def _search_signature(result, args): + if "403" in result or "Permission denied" in result: + return "permanent_block:auth" # 阈值 2,快速拦截 + if "500" in result or "Internal Error" in result: + return "biz_fail" # 阈值 3,允许重试 + if "no results" in result.lower(): + return "empty" # 阈值 3 + return "ok" # 良性,不触发检测 +``` + +**优先级**: Detector 优先用 Layer 2;函数抛异常或返回空值时降级到 Layer 1。 + +### 6.3 内置阈值与良性签名 + +**阈值表**(连续 N 次相同签名触发): + +| 签名前缀 | 阈值 | 语义 | +|---------|-----|------| +| `auth_err` | 2 | 鉴权失败,快速拦截 | +| `permanent_block` | 2 | 永久阻断(网络/策略)| +| `client_error` | 3 | 请求构造错,LLM 可自救 | +| `biz_fail` | 3 | 业务软失败,允许重试 | +| `no_progress` | 2 | 探索无进展 | +| `empty` | 3 | 查无结果 | +| `denied` | 3 | 拒绝 | +| (默认) | 3 | 其他签名 | + +**良性签名**(永不触发 `repeat_by_output`): `ok`, `progress` +- 执行成功 / 有新进展的调用,连续出现是正常的 + +--- + +## 7. 使用方式 + +### 7.1 最简用法 + +```python +from oxygent import oxy + +agent = oxy.ReActAgent( + name="my_agent", + llm_model="my_llm", + tools=["search"], + loop_detection_mode="block", # ← 加这一行即可 +) +``` + +### 7.2 自定义 fallback 消息 + +```python +agent = oxy.ReActAgent( + name="my_agent", + llm_model="my_llm", + tools=["search"], + loop_detection_mode="block", + loop_fallback_message=( + "抱歉,我在尝试回答这个问题时陷入了循环。" + "请换一种描述方式,或拆分成更具体的子问题。" + ), +) +``` + +### 7.3 warn 模式(允许 LLM 自救) + +```python +agent = oxy.ReActAgent( + name="my_agent", + llm_model="my_llm", + tools=["search"], + loop_detection_mode="warn", + max_react_rounds=16, # warn 模式下继续跑,建议配合 max_react_rounds 兜底 +) +``` + +### 7.4 为工具注册语义签名 + +```python +# tools/my_api.py +from oxygent.utils.loop_signatures import register_signature + +def my_api_tool(url: str) -> str: + # ... 实际逻辑 ... + return response_text + +@register_signature("my_api_tool") +def _my_api_signature(result, args): + if "permission" in result.lower(): + return "permanent_block:auth" + if "timeout" in result.lower(): + return "client_error" + return "ok" +``` + +**然后在 agent 中正常使用即可**,检测器会自动调用注册的签名函数。 + +### 7.5 监控 / 告警集成 + +```python +# 在 block 模式下,响应里会带诊断信息 +resp = await mas.chat_with_agent(payload={"query": "..."}) +loop_info = resp.extra.get("loop_detected") +if loop_info: + # 发送到监控系统 + send_metric("agent.loop_detected", tags={ + "type": loop_info["type"], + "tool_name": loop_info["tool_name"], + }) +``` + +--- + +## 8. 工作原理 + +### 8.1 整体流程 + +``` +┌─ ReActAgent._execute() 每轮 ─────────────────────────────────────┐ +│ │ +│ 1. LLM.call(messages=[...]) │ +│ 2. parse_llm_response() → {tool_name, arguments} │ +│ 3. await oxy_request.call(tool_name, arguments) │ +│ 4. react_memory.add_message(...) ← 结果写入 memory │ +│ │ +│ 5. ┌─ 本次新增 ─────────────────────────────────────────┐ │ +│ │ if loop_detection_mode != "disabled": │ │ +│ │ loop_info = detect_loop( │ │ +│ │ shared_data=oxy_request.shared_data, │ │ +│ │ tool_calls=本轮 tool_calls, │ │ +│ │ tool_results=本轮 tool_results, │ │ +│ │ current_round=i, │ │ +│ │ ) │ │ +│ │ if loop_info: │ │ +│ │ logger.warning(...) │ │ +│ │ if mode == "block": │ │ +│ │ return fallback_response │ │ +│ │ else: # warn │ │ +│ │ inject_system_hint(react_memory) │ │ +│ └────────────────────────────────────────────────────┘ │ +│ │ +│ 6. 继续下一轮 │ +└───────────────────────────────────────────────────────────────────┘ +``` + +### 8.2 detect_loop 内部 + +``` +detect_loop(shared_data, tool_calls, tool_results, current_round) + │ + ├─ 如果已经检测到循环 → 直接返回(幂等) + │ + ├─ 把本轮 tool_call → trace entry: + │ - fingerprint = MD5(tool_name + sorted(arguments))[:12] + │ - output_sig = compute_signature(tool_name, result, args) + │ - is_error = 关键字匹配 OR 签名前缀归为 error + │ + ├─ trace 缓冲区上限 6(够抓周期 3 的 cycle) + │ + ├─ 按优先级跑 4 个检测器(命中即停): + │ 1. _detect_error_retry (2 步窗口,最快) + │ 2. _detect_repeat (3 步窗口) + │ 3. _detect_repeat_by_output + │ 4. _detect_cycle (4-6 步窗口) + │ + └─ 命中 → 写 shared_data["_loop_detected"] = {...} +``` + +### 8.3 为什么把 trace 存在 shared_data + +`OxyRequest.shared_data` 是 OxyGent 原生就有的"请求级共享字典"(oxy.py:161),生命周期与请求一致,天然满足: +- 跨轮次保留(同一个请求内所有 react 轮共享) +- 跨 agent 隔离(不同请求不互相污染) +- 请求结束自动回收(不泄漏) + +无需引入新的状态管理机制。 + +### 8.4 为什么用指纹而不是直接存参数 + +- 存指纹:固定 12 字节,长 trace 也只占 ~100 字节 +- 存参数原文:参数可能是大 JSON,长 trace 会撑爆上下文 +- 指纹是 MD5 的前 12 位,碰撞概率可忽略 + +--- + +## 9. 与现有系统的关系 + +### 9.1 继承链 + +``` +ReActAgent(本次改动) + ├── SkillAgent — 继承,自动获得循环检测能力 + └── RagAgent — 继承,自动获得循环检测能力 +``` + +**改一处,三处生效**。 + +### 9.2 与 max_react_rounds 的关系 + +- `max_react_rounds` 是**硬上限**(防止无限循环的最后防线) +- 循环检测是**早停机制**(在上限之前主动识别无效循环) +- 两者**互补**:有检测的情况下,max_react_rounds 可以配置更大的值,给合理推理更多空间 + +### 9.3 与 trust_mode 的关系 + +`trust_mode=True` 时第一次工具返回就直接返回,根本不会多轮调用 → 循环检测不会触发,无冲突。 + +### 9.4 与现有日志 / trace 系统的关系 + +- 日志按 OxyGent 现有习惯打到 `logger.warning`,带 `trace_id` / `node_id` extra 字段 +- 循环信息写入 `shared_data["_loop_detected"]`,可被下游 agent 消费 +- `block` 模式的 `OxyResponse.extra` 带 `loop_detected` 结构,上游可用于告警 + +--- + +## 10. 扩展与定制 + +### 10.1 调整检测阈值 + +当前阈值在 `oxygent/utils/loop_signatures.py:_SIGNATURE_THRESHOLDS` 字典里,直接改即可: + +```python +_SIGNATURE_THRESHOLDS: dict[str, int] = { + "auth_err": 2, # 想更严格 → 改 1 + "permanent_block": 2, + "client_error": 3, # 想更宽松 → 改 5 + ... +} +``` + +### 10.2 添加新的检测模式 + +在 `loop_detector.py` 新增 `_detect_xxx` 函数,然后加入 `detect_loop` 的优先级链: + +```python +detected = ( + _detect_error_retry(trace) + or _detect_repeat(trace) + or _detect_repeat_by_output(trace) + or _detect_cycle(trace) + or _detect_xxx(trace) # ← 新增 +) +``` + +### 10.3 自定义 fallback 行为 + +目前 `block` 模式返回固定消息。如需转交给其他 agent,可在 `_execute()` 的循环处理块里扩展: + +```python +if self.loop_detection_mode == "block": + # 示例:转交给 chat_agent 做兜底 + fallback_resp = await oxy_request.call( + callee="chat_agent", + arguments={"query": f"用户问题陷入循环,原问题:{oxy_request.get_query()}"}, + ) + return OxyResponse(state=OxyState.COMPLETED, output=fallback_resp.output, ...) +``` + +### 10.4 为特定工具豁免检测 + +参考 superassistant 的做法,在 `loop_detector.py` 加白名单: + +```python +_EXEMPT_TOOLS = { + "mode_switch", # 模式切换类工具,天然可能多次调用 + "set_variable", # 状态设置类 +} +``` + +然后在 `detect_loop` 遍历时跳过: + +```python +if tool_name in _EXEMPT_TOOLS: + continue +``` + +(本次开源版未加这个白名单,保持通用;需要可在业务层扩展) + +--- + +## 11. 设计取舍 + +### 11.1 为什么默认 disabled + +**最小惊讶原则**:开源框架升级不应改变默认行为。用户主动启用意味着他认同"宁可拒绝也不要死循环"的权衡。 + +### 11.2 为什么 trace 上限 6 + +- 周期 3 的 cycle 检测需要 6 步窗口(`period * 2`) +- 更长的窗口对检测精度收益递减,但内存占用线性增加 +- 实测 6 够抓绝大多数生产场景 + +### 11.3 为什么 block 返回固定文本而不是让 LLM 总结 + +**可预测性**: +- 固定文本:用户/上游系统能准确判断"这是循环中断",不需解析 LLM 自由文本 +- LLM 总结:又调一次 LLM,又多等 1-3 秒,而且可能产出低质量内容 + +需要 LLM 总结的,可通过 `warn` 模式让 LLM 自救,或业务层接 `extra.loop_detected` 转交。 + +### 11.4 为什么签名机制分两层 + +- Layer 1 零配置通用兜底 → **80% 场景够用** +- Layer 2 工具级精细控制 → **解决 Layer 1 抓不到的换皮循环** +- 两层互不耦合,用户按需启用 + +### 11.5 为什么不是中间件 / Hook + +OxyGent 当前 `ReActAgent._execute()` 没有标准 Hook 机制。本次移植选择最小改动路径 —— 直接在 `_execute()` 内加检测点,而非引入全新的 Hook 架构。如果后续 OxyGent 要抽象 Hook,可以把这块逻辑迁过去。 + +--- + +## 12. FAQ + +**Q1: 开启检测会不会误伤正常重试?** +A: 几乎不会。 +- `repeat` 阈值 3,允许"查无 → 换参 → 查无 → 换参 → 查有"的正常流程 +- `error_retry` 要求上一步显式报错,正常重试会先调整参数 +- 真正担心的话用 `warn` 模式,LLM 可继续执行 + +**Q2: 签名函数抛异常会怎样?** +A: 自动降级到 Layer 1 hash,不影响主循环。参见 `test_classifier_exception_falls_back_to_hash` 测试。 + +**Q3: shared_data 的 trace 会不会被持久化到 ES?** +A: `shared_data` 是请求级内存字典,请求结束即释放,不入库。 + +**Q4: 如何在生产环境打开,但只收集数据不中断?** +A: 用 `warn` 模式 + 监控消费 `shared_data["_loop_detection_trace"]`,拿到循环分布后再决定要不要切 `block`。 + +**Q5: 自定义签名怎么测试?** +A: 签名函数是纯函数,可独立单测,不需要拉起 agent。参考 `tests/unittest/test_loop_detector.py:test_classifier_exception_falls_back_to_hash`。 + +**Q6: 支持异步工具吗?** +A: 支持。检测器只看 tool_call 和 tool_result,与工具是同步/异步无关。 + +**Q7: 和 `max_react_rounds` 冲突吗?** +A: 不冲突,互补。`max_react_rounds` 是硬上限,检测器是早停。启用检测后可适当调大 `max_react_rounds`。 + +**Q8: 新工具一定要注册签名吗?** +A: 不是。默认 Layer 1 hash 足够抓"同参 / 同输出"的循环;只有需要抓"换参但同类错"的循环时才需要注册。 + +--- + +## 附录 A:关键文件索引 + +| 文件 | 行数 | 职责 | +|------|-----|------| +| `oxygent/utils/loop_detector.py` | 285 | 4 个检测器 + 提示构造 + 轨迹管理 | +| `oxygent/utils/loop_signatures.py` | 173 | Layer 1 hash + Layer 2 装饰器 + 阈值表 | +| `oxygent/oxy/agents/react_agent.py` | +47 | 2 个 Field + `_execute` 内调用点 | +| `tests/unittest/test_loop_detector.py` | 198 | 16 项单测 | +| `examples/agents/demo_loop_detection.py` | 78 | 端到端 Web demo | + +## 附录 B:相关文档 + +- 测试报告:`docs/loop_detection_test_report.md` +- 参考实现:`/Users/yangyalong6/super-assistant/oxygent/utils/loop_detector.py`(内部项目,含业务耦合,本次开源版已裁剪) diff --git a/docs/loop_detection_test_report.md b/docs/loop_detection_test_report.md new file mode 100644 index 0000000..c6f94c6 --- /dev/null +++ b/docs/loop_detection_test_report.md @@ -0,0 +1,555 @@ +# 工具调用循环检测功能 — 测试报告 + +| 项目 | 内容 | +|------|------| +| **功能名称** | ReAct Agent 工具调用循环检测(Loop Detection) | +| **目标** | 防止 LLM agent 在工具调用链中陷入死循环,烧 token 跑满 `max_react_rounds` | +| **分支** | `feature-loopdetection-20261014-yangyalong` | +| **Commit** | `48e14ef` | +| **测试日期** | 2026-10-14 ~ 2026-10-15 | +| **测试环境** | macOS Darwin 23.1.0 / Python 3.11.15 | +| **代码路径** | `/Users/yangyalong6/OxyGent` | + +--- + +## 执行摘要 + +| 测试类型 | 用例数 | 通过 | 失败 | 跳过 | 结论 | +|---------|-------|-----|-----|-----|-----| +| 新功能单元测试 | 16 | **16** | 0 | 0 | ✅ | +| 端到端 Smoke(三种模式) | 3 | **3** | 0 | 0 | ✅ | +| Web UI 真实请求测试 | 1 | **1** | 0 | 0 | ✅ | +| 受影响模块定向回归 | 45 | **45** | 0 | 0 | ✅ | +| 项目全量回归 | 360 | **346** | 0 | 14 | ✅ | + +**综合结论**: 功能逻辑正确、端到端可用、无向后兼容破坏,**达到 PR 提交标准,可合入**。 + +--- + +## 目录 + +1. [功能概述](#1-功能概述) +2. [变更范围](#2-变更范围) +3. [测试策略](#3-测试策略) +4. [单元测试](#4-单元测试) +5. [端到端 Smoke 测试](#5-端到端-smoke-测试) +6. [Web UI 真实请求测试](#6-web-ui-真实请求测试) +7. [回归测试](#7-回归测试) +8. [性能对照](#8-性能对照) +9. [风险与限制](#9-风险与限制) +10. [测试结论](#10-测试结论) +11. [附录:复现步骤](#11-附录复现步骤) + +--- + +## 1. 功能概述 + +### 1.1 检测维度(4 种循环模式) + +| 循环类型 | 触发条件 | 典型场景 | +|---------|---------|---------| +| `error_retry` | 工具报错后用**相同参数**再次调用 | LLM 没看懂错误、硬刚 | +| `repeat` | 连续 N 次(默认 3)**完全相同参数**调同一工具 | 单曲循环 | +| `repeat_by_output` | 参数每次不同但**结果类别一致**N 次 | 换皮循环(权限永久拒绝、数据不存在)| +| `cycle` | ABAB / ABCABC 周期震荡 | 钟摆效应 | + +### 1.2 处理模式(配置项 `loop_detection_mode`) + +| 模式 | 行为 | +|------|------| +| `disabled`(默认) | 完全跳过检测,对现有用户零影响 | +| `warn` | 日志 WARN + 向 `react_memory` 注入系统提示,让 LLM 自救,继续执行 | +| `block` | 立即终止 ReAct 循环,返回配置的 `loop_fallback_message` | + +### 1.3 两层签名机制(可扩展) + +- **Layer 1 兜底**:对工具返回文本做噪声剥除(时间戳 / UUID / `trace_id` / HTTP Date / 内存地址)后取 MD5 前 12 位,前缀 `h:` +- **Layer 2 opt-in**:通过 `@register_signature("tool_name")` 装饰器为工具注册自定义分类函数,把 "权限拒绝 / 网络超时 / 业务失败" 归为不同类别,支持冒号前缀匹配阈值 + +### 1.4 继承链覆盖 + +``` +ReActAgent(本次改动) + └── SkillAgent — 继承,无需额外改动 + └── RagAgent — 继承,无需额外改动 +``` + +--- + +## 2. 变更范围 + +### 2.1 文件变更统计 + +``` +examples/agents/demo_loop_detection.py | 78 ++++++++ (新增) +oxygent/oxy/agents/react_agent.py | 47 ++++ (修改) +oxygent/utils/loop_detector.py | 285 ++++++++ (新增) +oxygent/utils/loop_signatures.py | 173 ++++++ (新增) +tests/unittest/test_loop_detector.py | 198 ++++++++ (新增) +docs/loop_detection_test_report.md | (本文件) (新增) +────────────────────────────────────────────────────── +5 files changed, 781 insertions(+) +``` + +### 2.2 API 变更 + +**新增字段**(加在 `ReActAgent` 上): + +```python +loop_detection_mode: str = Field( + "disabled", + description="One of: 'disabled', 'warn', 'block'", +) +loop_fallback_message: str = Field( + "A tool-call loop was detected and execution was stopped. " + "Please rephrase your question or try again.", + description="Message returned when loop_detection_mode is 'block'", +) +``` + +**破坏性变更**: 无。默认 `"disabled"` 保证所有现有代码零改动可用。 + +### 2.3 核心调用点 + +**位置**: `oxygent/oxy/agents/react_agent.py:_execute()` +**时机**: 每轮工具执行完成、`react_memory` 更新后 +**代码块**: 新增 ~25 行,被 `if self.loop_detection_mode != "disabled":` 守卫,disabled 时不产生任何开销 + +--- + +## 3. 测试策略 + +本次测试采用**四层金字塔**: + +``` + ┌─────────────────────────┐ + │ Web UI 真实请求 │ 真实 LLM + 真实工具链,端到端 + ├─────────────────────────┤ + │ 端到端 Smoke │ 真实 ReActAgent._execute + Mock LLM + ├─────────────────────────┤ + │ 定向回归(6 模块) │ 受影响模块的现有用例 + ├─────────────────────────┤ + │ 新功能单元测试(16) │ 纯函数、分支覆盖、边界条件 + └─────────────────────────┘ + │ + ▼ + ┌─────────────────────────┐ + │ 项目全量回归(346) │ 整体稳定性兜底 + └─────────────────────────┘ +``` + +每层都要独立通过,形成互相印证的证据链。 + +--- + +## 4. 单元测试 + +### 4.1 执行命令 + +```bash +pytest tests/unittest/test_loop_detector.py -v +``` + +### 4.2 原始输出 + +``` +============================= test session starts ============================== +platform darwin -- Python 3.11.15, pytest-9.1.1, pluggy-1.6.0 +collected 16 items + +test_repeat_triggers_after_three_identical_calls PASSED [ 6%] +test_repeat_not_triggered_on_two_calls PASSED [ 12%] +test_error_retry_detects_same_args_after_error PASSED [ 18%] +test_error_retry_not_triggered_when_args_change PASSED [ 25%] +test_cycle_abab_pattern PASSED [ 31%] +test_repeat_by_output_requires_classifier_signature PASSED [ 37%] +test_benign_signature_does_not_trigger_repeat_by_output PASSED [ 43%] +test_detect_loop_is_idempotent_after_hit PASSED [ 50%] +test_detect_loop_handles_oxy_response_like_objects PASSED [ 56%] +test_detect_loop_handles_string_arguments PASSED [ 62%] +test_trace_is_bounded PASSED [ 68%] +test_default_hash_signature_strips_timestamps PASSED [ 75%] +test_is_benign_signature_matches_prefix PASSED [ 81%] +test_classifier_exception_falls_back_to_hash PASSED [ 87%] +test_build_loop_break_prompt_contains_tool_name PASSED [ 93%] +test_build_loop_break_prompt_defaults_for_unknown_type PASSED [100%] + +============================== 16 passed in 0.04s ============================== +``` + +### 4.3 覆盖矩阵 + +| # | 用例 | 覆盖检测器/模块 | 覆盖点 | +|---|------|---------------|--------| +| 1 | test_repeat_triggers_after_three_identical_calls | `_detect_repeat` | 阈值 3 触发 | +| 2 | test_repeat_not_triggered_on_two_calls | `_detect_repeat` | 阈值 2 不触发(避免误报)| +| 3 | test_error_retry_detects_same_args_after_error | `_detect_error_retry` | 工具报错后同参再调 | +| 4 | test_error_retry_not_triggered_when_args_change | `_detect_error_retry` | 换参数不误报 | +| 5 | test_cycle_abab_pattern | `_detect_cycle` | 周期 2 震荡识别 | +| 6 | test_repeat_by_output_requires_classifier_signature | `_detect_repeat_by_output` | 换参数但结果类别一致 | +| 7 | test_benign_signature_does_not_trigger_repeat_by_output | 签名机制 | ok / progress 良性不触发 | +| 8 | test_detect_loop_is_idempotent_after_hit | 幂等性 | 首次命中后不重复覆盖结果 | +| 9 | test_detect_loop_handles_oxy_response_like_objects | 兼容性 | 支持 `OxyResponse` 对象 | +| 10 | test_detect_loop_handles_string_arguments | 兼容性 | 支持 JSON 字符串形式 arguments | +| 11 | test_trace_is_bounded | 内存安全 | trace 缓冲区上限 6 | +| 12 | test_default_hash_signature_strips_timestamps | 签名降噪 | 时间戳 / UUID / trace_id 剥除 | +| 13 | test_is_benign_signature_matches_prefix | 签名机制 | 冒号前缀匹配 | +| 14 | test_classifier_exception_falls_back_to_hash | 容错 | 分类函数异常不影响主流程 | +| 15 | test_build_loop_break_prompt_contains_tool_name | 提示构造 | 提示文本正确插入 tool_name | +| 16 | test_build_loop_break_prompt_defaults_for_unknown_type | 提示构造 | 未知类型兜底 | + +### 4.4 本节结论 + +**16/16 通过,耗时 0.04 秒**。覆盖所有 4 种检测器 + 签名机制 + 容错 + 边界条件。 + +--- + +## 5. 端到端 Smoke 测试 + +### 5.1 测试方法 + +构造 `FakeAgent` 继承真实的 `ReActAgent`,重写 `_parse_llm_response` 固定返回同一个 tool_call、`OxyRequest.call` 固定返回同一个 tool_result,强制触发 `repeat`。 +**关键点**: 走的是真实 `_execute()` 方法(未 mock 核心逻辑),只替换了外部依赖。 + +分别用 `disabled` / `warn` / `block` 三种模式各执行一次 `_execute()`,对比输出。 + +### 5.2 原始输出 + +``` +=== mode=disabled === + trace entries: 0 + loop_detected: None + response.output: irrelevant + response.extra keys: ['react_memory'] + +=== mode=warn === + trace entries: 3 + loop_detected: {'type': 'repeat', 'fingerprint': 'c0be41f35edb', + 'tool_name': 'buggy_search', 'round': 2} + response.output: irrelevant + response.extra keys: ['react_memory'] + +=== mode=block === + trace entries: 3 + loop_detected: {'type': 'repeat', 'fingerprint': 'c0be41f35edb', + 'tool_name': 'buggy_search', 'round': 2} + response.output: LOOP_HIT_IN_BLOCK_MODE + response.extra keys: ['react_memory', 'loop_detected'] +``` + +### 5.3 结果验证 + +| 模式 | 预期行为 | 实测结果 | 验证点 | 结论 | +|------|---------|---------|-------|------| +| `disabled` | trace 为空、无检测、正常跑满轮次 | ✓ trace=0, loop_detected=None | 向后兼容:disabled 时零开销 | **通过** | +| `warn` | 第 3 轮触发 repeat,继续跑,注入系统提示 | ✓ 检测到 round=2,output 正常输出 | 检测生效但不打断流程 | **通过** | +| `block` | 第 3 轮触发 repeat,立即返回 fallback | ✓ output=LOOP_HIT_IN_BLOCK_MODE,extra 带 loop_detected | 终止 + 自定义消息 + 诊断信息 | **通过** | + +--- + +## 6. Web UI 真实请求测试 + +### 6.1 启动环境 + +**LLM 配置**(`.env`): + +``` +DEFAULT_LLM_API_KEY=*** +DEFAULT_LLM_BASE_URL=http://api.chatrhino.jd.com/api/v2/chat/completions +DEFAULT_LLM_MODEL_NAME=Chatrhino-81B-Pro +``` + +**启动命令**: + +```bash +source .venv311/bin/activate +python -m examples.agents.demo_loop_detection +``` + +**服务就绪**: + +``` +2026-10-14 17:10:05,597 - INFO - Uvicorn running on http://127.0.0.1:8080 +2026-10-14 17:10:05,698 - INFO - The web page URL is: + http://127.0.0.1:8080/web/index.html, click to open. +``` + +### 6.2 测试请求 + +**Trace ID**: `KKLUr2BWYMj6tfsA` + +**请求**: +```bash +curl -X POST http://127.0.0.1:8080/chat \ + -H "Content-Type: application/json" \ + -d '{"query":"Only use the JSON tool call format. Output exactly this JSON and nothing else: {\"tool_name\":\"buggy_search\",\"arguments\":{\"query\":\"quantum tunneling\"}}"}' +``` + +**响应**: +```json +{ + "answer": "The agent appears to be stuck in a tool-call loop and could not complete the task. Please rephrase your question.", + "current_trace_id": "KKLUr2BWYMj6tfsA" +} +``` + +### 6.3 完整时间线(后台原始日志) + +| 时间 | 事件 | +|------|------| +| 17:12:03.490 | 收到请求 | +| 17:12:03.491 | `user >>> master_agent` | +| 17:12:04.082 | LLM 返回 tool_call(第 1 次)| +| 17:12:04.084 | 调用 `buggy_search(query="quantum tunneling")` | +| 17:12:04.090 | 返回 "No matching results..." | +| 17:12:04.502 | LLM 返回**相同** tool_call(第 2 次)| +| 17:12:04.504 | 调用 `buggy_search(query="quantum tunneling")` | +| 17:12:04.504 | 返回 "No matching results..." | +| 17:12:05.009 | LLM 返回**相同** tool_call(第 3 次)| +| 17:12:05.011 | 调用 `buggy_search(query="quantum tunneling")` | +| 17:12:05.012 | 返回 "No matching results..." | +| **17:12:05.013** | **检测器触发 `repeat`,agent 中止,返回 fallback** | + +### 6.4 关键日志证据 + +``` +2026-10-14 17:12:05,013 - WARNING - KKLUr2BWYMj6tfsA - Gu3ECKGReq2eJuod - + Loop detected: { + 'type': 'repeat', + 'fingerprint': 'add9b9c305f5', + 'tool_name': 'buggy_search', + 'round': 2 + } + +2026-10-14 17:12:05,013 - INFO - KKLUr2BWYMj6tfsA - Gu3ECKGReq2eJuod - + user <<< master_agent : The agent appears to be stuck in a tool-call loop + and could not complete the task. Please rephrase your question. +``` + +### 6.5 本节结论 + +真实 Web 服务 + 真实 LLM + 真实工具链条件下,检测器在**第 3 轮准确触发**并返回配置的 fallback 消息。end-to-end 验证通过。 + +--- + +## 7. 回归测试 + +### 7.1 受影响模块定向回归 + +**执行命令**: +```bash +pytest tests/unittest/test_react_agent.py \ + tests/unittest/test_skill_agent.py \ + tests/unittest/test_base_agent.py \ + tests/unittest/test_local_agent.py \ + tests/unittest/test_chat_agent.py \ + tests/unittest/test_loop_detector.py -v +``` + +**执行结果**: `45 passed in 0.13s` ✅ + +| 模块 | 用例 | 关键验证点 | +|------|-----|-----------| +| `test_react_agent.py` | 3/3 | `_parse_llm_response`、`_execute` 完整 ReAct 循环、权限工具列表 — **证明 `loop_detection_mode="disabled"` 时现有行为完全不变** | +| `test_skill_agent.py` | 18/18 | SkillAgent 继承 ReActAgent 后 frontmatter 解析、metadata 加载等逻辑不受影响 | +| `test_base_agent.py` | 4/4 | agent 基类生命周期(初始化、pre/post 钩子、持久化)无回归 | +| `test_local_agent.py` | 2/2 | LocalAgent 完整 execute 流程无回归 | +| `test_chat_agent.py` | 2/2 | ChatAgent 消息构建与 LLM 调用无回归 | +| `test_loop_detector.py` | 16/16 | 本次新增单测 | + +### 7.2 项目全量回归 + +**执行命令**: +```bash +pytest tests/unittest/ --tb=no -q +``` + +**执行结果**: +``` +.......................................................ssss.ssss.ssss... +........................................................................ +........................................................................ +........................................................................ +...................................................................... +346 passed, 14 skipped in 5.37s +``` + +| 指标 | 数值 | 说明 | +|------|------|------| +| **通过** | **346** | 全部通过 | +| **失败** | **0** | 无新增失败 | +| **跳过** | 14 | 原本就 skip 的历史用例,与本次改动无关 | +| 耗时 | 5.37s | 无性能回退 | + +### 7.3 关键保障结论 + +1. **向后兼容**: `test_react_agent::test_execute_trust_mode` 走完整 `_execute()` 流程,在不显式配置 `loop_detection_mode` 情况下**通过**,证明默认 `"disabled"` 对现有用户零影响 +2. **继承链安全**: `SkillAgent` 和 `RagAgent` 都继承 ReActAgent,相关测试全绿,无需子类适配 +3. **无新增 flaky**: 定向 0.13 秒、全量 5.37 秒跑完,无超时、无随机失败 +4. **代码质量**: 已通过 `ruff format` 格式化,符合项目风格 + +--- + +## 8. 性能对照 + +### 8.1 耗时与调用次数 + +| 配置 | 实际轮次 | LLM 调用 | 工具调用 | 耗时(估)| +|------|---------|---------|---------|---------| +| `loop_detection_mode="block"` | **3 轮触发** | **3 次** | **3 次** | **~1.5 秒** | +| `loop_detection_mode="disabled"`(对照)| 跑满 `max_react_rounds=10` | 10 次 + 1 次总结 | 10 次 | ~5-8 秒 | + +### 8.2 性能收益 + +- LLM 调用 **-70%**(11 → 3) +- 工具调用 **-70%**(10 → 3) +- 响应时间 **-70%**(~5-8s → ~1.5s) + +### 8.3 开销评估 + +检测器本身开销: +- `_compute_fingerprint`: MD5 一次,~ 几微秒 +- `_detect_*` 四个检测器: 遍历长度 ≤ 6 的 trace,O(1) 实际 +- `disabled` 时**完全跳过**,零开销 + +--- + +## 9. 风险与限制 + +### 9.1 已知限制 + +| 限制 | 说明 | 影响 | 缓解 | +|------|------|------|------| +| **仅覆盖 ReAct 系** | 只对 `ReActAgent` 及其子类生效,`WorkflowAgent` / `ChatAgent` 不触发 | ReAct 外的 agent 无保护 | 其他类型 agent 可按同样模式扩展 | +| **依赖 OxyGent 的 tool_call 协议** | 检测器读取 `{"tool_name", "arguments"}` 结构 | 自定义解析器需保持兼容 | 已通过兼容性测试 | +| **repeat_by_output 需签名** | Layer 2 签名未注册时降级为 Layer 1 hash,只能抓完全一致输出 | 对需语义归类的工具检测弱 | 文档引导用户用 `@register_signature` 注册 | +| **循环后不会自动重试** | block 模式直接返回,不触发二次尝试 | 用户体验上是"硬失败" | 可用 warn 模式让 LLM 自救;或业务层做转交 | + +### 9.2 潜在误判 + +| 场景 | 风险等级 | 现有防护 | +|------|---------|---------| +| 用户问题本身要求重复调同一工具 | 低 | `disabled` 模式关闭即可;或调大 `max_react_rounds` 延后触发 | +| 工具返回有必要变化但 Layer 1 hash 相同 | 低 | hash 已剥除时间戳 / UUID / trace_id 等噪声 | +| 多轮里存在 >6 步前相似模式 | 低 | trace 缓冲区上限 6,只看最近窗口 | + +### 9.3 Web UI 测试的环境依赖 + +Web UI 真实请求测试依赖**兼容 OpenAI SSE 流格式**(`chunk["choices"][0]["delta"]`)的 LLM 服务。实测时发现: +- 内部 `qwen25-32b-native` 服务 503 不可用 +- 内部 `gpt-4o`(走 proxy)返回非 streaming 格式,不走 delta 协议 +- **成功在 `Chatrhino-81B-Pro` 上验证通过** + +该限制不影响框架功能本身,用户使用标准 OpenAI/Anthropic API 时均可正常工作。 + +--- + +## 10. 测试结论 + +### 10.1 测试覆盖汇总 + +| 维度 | 测试类型 | 结果 | +|------|---------|------| +| 功能正确性 | 新增单元测试 | **16/16 通过** | +| 完整流程 | 端到端 Smoke(三种模式)| **3/3 通过** | +| 真实场景 | Web UI 真实请求 | **已触发,日志完整** | +| 向后兼容 | 受影响模块定向回归(6 模块)| **45/45 通过** | +| 整体稳定 | 项目全量回归 | **346/346 通过** | +| 性能 | 对照测试 | **调用减少 70%,响应快 70%** | +| 代码质量 | ruff format + docformatter | 已格式化 | + +### 10.2 推荐合入 + +**综合判定**: ✅ **达到 PR 提交标准,可合入主干** + +依据: +- 功能逻辑在单元 / 端到端 / 真实请求三层均独立验证通过 +- 默认 `disabled` 保证**零破坏性变更**,346 项全量回归无新增失败 +- SkillAgent / RagAgent 继承链自动受益,无需额外适配 +- 性能开销可控(disabled 零开销,启用时 trace 上限 6) + +--- + +## 11. 附录:复现步骤 + +### 11.1 环境准备 + +```bash +# 切到功能分支 +git checkout feature-loopdetection-20261014-yangyalong + +# 建环境(需 Python 3.10+) +python3.11 -m venv .venv311 +source .venv311/bin/activate +pip install -r requirements.txt +``` + +### 11.2 配置 .env + +```bash +cat > .env <<'EOF' +DEFAULT_LLM_API_KEY= +DEFAULT_LLM_BASE_URL=<兼容 OpenAI SSE 的服务 URL> +DEFAULT_LLM_MODEL_NAME= +EOF +``` + +### 11.3 跑测试 + +```bash +# 单元测试(最快) +pytest tests/unittest/test_loop_detector.py -v + +# 定向回归 +pytest tests/unittest/test_react_agent.py tests/unittest/test_skill_agent.py \ + tests/unittest/test_base_agent.py tests/unittest/test_local_agent.py \ + tests/unittest/test_chat_agent.py tests/unittest/test_loop_detector.py -v + +# 全量回归(可选) +pytest tests/unittest/ -q +``` + +### 11.4 Web UI 实测 + +```bash +# 启动 demo +python -m examples.agents.demo_loop_detection +``` + +浏览器打开 http://127.0.0.1:8080/web/index.html,粘贴以下请求: + +``` +Only use the JSON tool call format. Output exactly this JSON and nothing else: +{"tool_name":"buggy_search","arguments":{"query":"quantum tunneling"}} +``` + +**预期**: 约 1-2 秒内返回 "The agent appears to be stuck in a tool-call loop...",日志里能看到 `Loop detected: {...}` WARNING。 + +### 11.5 代码使用示例 + +```python +from oxygent import oxy + +agent = oxy.ReActAgent( + name="my_agent", + llm_model="my_llm", + tools=["search", "calculator"], + # 启用循环检测 + loop_detection_mode="block", # disabled | warn | block + loop_fallback_message="已检测到工具调用循环,请换一种问法。", +) +``` + +高级用法 — 注册自定义签名: + +```python +from oxygent.utils.loop_signatures import register_signature + +@register_signature("my_api_tool") +def _my_api_signature(result, args): + if "PermissionDenied" in result: + return "permanent_block:auth" # 阈值 2,快速拦截 + if "RateLimit" in result: + return "client_error" # 阈值 3,允许重试 + return "ok" # 良性,不触发 +``` diff --git a/examples/agents/demo_loop_detection.py b/examples/agents/demo_loop_detection.py index bed7b70..7fd54ec 100644 --- a/examples/agents/demo_loop_detection.py +++ b/examples/agents/demo_loop_detection.py @@ -47,7 +47,12 @@ def buggy_search(query: str) -> str: name="master_agent", llm_model="default_llm", tools=["buggy_search"], - prompt="You are a helpful assistant. Use the search tool to find answers.", + prompt=( + "You are a research assistant. You MUST use the buggy_search tool " + "to look up any factual question - do NOT answer from your own " + "knowledge. If the first search returns no results, try different " + "queries until you find something." + ), # Enable loop detection in 'block' mode. Switch to 'warn' to let the # agent receive a system hint and try to self-correct, or 'disabled' to # see the baseline behaviour (will burn through max_react_rounds). diff --git a/oxygent/oxy/agents/react_agent.py b/oxygent/oxy/agents/react_agent.py index 0f9ca14..4d6fa21 100644 --- a/oxygent/oxy/agents/react_agent.py +++ b/oxygent/oxy/agents/react_agent.py @@ -87,16 +87,15 @@ class ReActAgent(LocalAgent): loop_detection_mode: str = Field( "disabled", description=( - "Tool-call loop detection mode. One of: 'disabled', 'warn', 'block', " - "'fallback'. 'warn' logs a warning; 'block' stops the ReAct loop and " - "returns loop_fallback_message; 'fallback' is identical to 'block' " - "but can be customised via loop_fallback_message." + "Tool-call loop detection mode. One of: 'disabled', 'warn', 'block'. " + "'warn' logs a warning and injects a system hint; 'block' stops the " + "ReAct loop and returns loop_fallback_message." ), ) loop_fallback_message: str = Field( "A tool-call loop was detected and execution was stopped. " "Please rephrase your question or try again.", - description="Message returned to the caller when loop_detection_mode is 'block' or 'fallback'.", + description="Message returned to the caller when loop_detection_mode is 'block'.", ) func_parse_llm_response: Optional[Callable[[str, OxyRequest], LLMResponse]] = Field( @@ -461,7 +460,7 @@ async def _execute(self, oxy_request: OxyRequest) -> OxyResponse: "node_id": oxy_request.node_id, }, ) - if self.loop_detection_mode in ("block", "fallback"): + if self.loop_detection_mode == "block": return OxyResponse( state=OxyState.COMPLETED, output=self.loop_fallback_message,