cycle_detection: human_feedback ↔ generate_report_plan revision loop has no explicit max_revisions
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).
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:
- Automated reviewer (LLM acting as the human, no accept heuristic): revises indefinitely.
- Adversarial / accidentally-rejecting human.
- 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
revisions_countfield onReportState+ force-accept afterN: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-acceptmax_plan_revisionsinConfiguration+ raise typedMaxPlanRevisionsExceededError. Same shape as the existingmax_search_depthknob.Document the
recursion_limit=25dependency inlegacy/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