cycle_detection: human_feedback ↔ generate_report_plan revision loop has no explicit max_revisions

Author: hatyibeiCreated May 9, 2026Updated Aug 4, 2026

Summary

legacy/graph.py's human_feedback node can route back to generate_report_plan indefinitely via the user-supplied feedback string, with no explicit upper bound on revisions. The cycle has a real exit branch (accept → research) so it's not structurally broken, but its bound is implicit — silently recursion_limit=25.

Surfaced by Shingan ([email protected]) cycle_detection (Warning, confidence 0.9).

Where

src/legacy/graph.py:142-189 (human_feedback) + src/legacy/graph.py:497 (generate_report_plan → human_feedback).

python
def human_feedback(state, config) -> Command[Literal["generate_report_plan","build_section_with_web_research"]]:
    interrupt_message = f"..."
    feedback = interrupt(interrupt_message)
    if isinstance(feedback, bool) and feedback is True:
        return Command(goto=[Send(...)])  # accept → research path
    elif isinstance(feedback, str):
        return Command(goto="generate_report_plan",
                       update={"feedback_on_report_plan": feedback})

The structural cycle is generate_report_plan → human_feedback → revise → generate_report_plan → …, exiting only when the user feeds True.

Why it matters

Three reachable paths trip GraphRecursionError mid-iteration:

  1. Automated reviewer (LLM acting as the human, no accept heuristic): revises indefinitely.
  2. Adversarial / accidentally-rejecting human.
  3. Property-based / chaos test suites that always feed a string.

recursion_limit is graph-wide, shared with the section-research subgraph, so attribution to the revision loop is hard.

Proposed remediation

  1. revisions_count field on ReportState + force-accept after N:

    python
    if isinstance(feedback, str) and state.get("revisions_count", 0) < MAX_REVISIONS:
        return Command(goto="generate_report_plan", update={
            "feedback_on_report_plan": feedback,
            "revisions_count": state.get("revisions_count", 0) + 1,
        })
    return Command(goto=[Send(...)])  # force-accept
  2. max_plan_revisions in Configuration + raise typed MaxPlanRevisionsExceededError. Same shape as the existing max_search_depth knob.

  3. Document the recursion_limit=25 dependency in legacy/README.md (low cost; surfaces the trap without fixing it).

(1) is the minimal patch; (2) lines up with existing config knobs. Happy to send a PR for either if maintainers prefer one direction.

Severity

Not a security bug — UX / robustness. Reachable by reasonable callers; surfaces as GraphRecursionError.

Shingan trace

$ shingan analyze --format langgraph --input src/legacy/graph.py
[warning] cycle_detection (90%) on generate_report_plan
  bounded cycle through non-Loop node "generate_report_plan" (type=llm):
  the cycle has an exit branch, but no explicit
  max_iterations / recursion_limit guard

(Side note: open_deep_research was a key dogfood target while building the v0.8.5+ analyser — its mix of Command(goto=...) + list path_map + multi-graph subgraph composition drove most of the LangGraph idiom support added in those releases. Down to 1 finding from 9 false positives in v0.8.4.)

Source: langchain-ai/open_deep_research