Don't lose deep-research findings when synthesis times out (#1551) (#1562)

Two problems made deep research report "No information could be gathered" even
after it had extracted findings, on slow local models (reporter served a 20B
via LM Studio):

- _synthesize hard-capped its LLM call at timeout=60, while extraction uses the
  user's extraction_timeout (300s here) and the final report uses 180s. The slow
  model needed >60s to synthesize the round's findings, so synthesis timed out
  after 3 attempts. Raised it to 180s to match the final-report call.

- When synthesis produced no report (it returns the unchanged, still-empty
  report on failure during round 1), the run hit
  `if not report: return "No information could be gathered…"` and discarded the
  findings it had already gathered. Now it falls back to a compiled report built
  from those findings (_fallback_report) so the user keeps the gathered material.

Tests stub the LLM (no live model/DB), pin the synthesis timeout >= 180, that the
fallback surfaces the findings rather than the give-up message, and that a failed
synthesis preserves the previous report.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lekt8 2026-06-03 07:11:44 +08:00 committed by GitHub
parent c9361262df
commit 1f743970dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 116 additions and 1 deletions

View file

@ -344,6 +344,16 @@ class DeepResearcher:
self._emit(phase="writing", total_sources=len(self.urls_fetched), self._emit(phase="writing", total_sources=len(self.urls_fetched),
total_findings=len(findings)) total_findings=len(findings))
if not report: if not report:
# Synthesis can fail (e.g. the LLM timed out) even though the search
# rounds did gather findings. Don't throw that work away — return the
# gathered findings as a basic compiled report instead of claiming
# nothing was found (#1551).
if findings:
logger.warning(
"Synthesis produced no report; returning %d gathered "
"finding(s) as a fallback", len(findings)
)
return self._fallback_report(question, findings)
return "No information could be gathered for this question." return "No information could be gathered for this question."
self.evolving_report = report # preserve pre-synthesis report self.evolving_report = report # preserve pre-synthesis report
@ -662,7 +672,11 @@ class DeepResearcher:
[{"role": "user", "content": prompt}], [{"role": "user", "content": prompt}],
temperature=0.3, temperature=0.3,
max_tokens=self.max_report_tokens, max_tokens=self.max_report_tokens,
timeout=60, # Synthesis is a heavy generation call like the final report
# (which gets 180s); a slow local model (e.g. a 20B served from
# LM Studio) routinely needs >60s for it. The old 60s cap timed
# out mid-stream and discarded the round's findings (#1551).
timeout=180,
) )
except Exception as e: except Exception as e:
logger.error(f"Synthesis failed: {e}") logger.error(f"Synthesis failed: {e}")
@ -841,6 +855,21 @@ class DeepResearcher:
parts.append(f"**Finding {i}** — [{title}]({url})\n{content}") parts.append(f"**Finding {i}** — [{title}]({url})\n{content}")
return "\n\n".join(parts) return "\n\n".join(parts)
def _fallback_report(self, question: str, findings: List[Dict]) -> str:
"""Compile gathered findings into a basic report.
Used when the LLM synthesis step produced no report (e.g. it timed out)
but the search rounds did collect findings so the user still gets the
material that was gathered instead of "No information could be gathered"
(#1551).
"""
return (
f"# {question}\n\n"
"_Automatic synthesis did not complete, so this report lists the "
f"{len(findings)} finding(s) gathered during research._\n\n"
f"{self._format_findings(findings)}"
)
def get_stats(self) -> Dict: def get_stats(self) -> Dict:
"""Return research statistics.""" """Return research statistics."""
elapsed = time.time() - self._start_time if self._start_time else 0 elapsed = time.time() - self._start_time if self._start_time else 0

View file

@ -0,0 +1,86 @@
"""Regression tests for issue #1551 — deep research reported "No information
could be gathered" and showed nothing, even though the search rounds had already
extracted findings.
Two root causes in src/deep_research.py:
1. `_synthesize` hard-capped its LLM call at `timeout=60`, while extraction uses
the user's `extraction_timeout` (e.g. 300s) and the final report uses 180s. A
slow local model (the reporter served a 20B from LM Studio) needs >60s to
synthesize a round's findings, so synthesis timed out after 3 attempts.
2. When synthesis failed on the first round, the gathered findings were thrown
away: `if not report: return "No information could be gathered…"`. The 8
findings the run had already extracted were lost.
The fixes: give synthesis the same 180s budget as the final report, and fall
back to a compiled report built from the gathered findings when synthesis
produced nothing. These run without a live LLM or DB (same stub pattern as
tests/test_deep_research_date_context.py).
"""
import asyncio
from src.deep_research import DeepResearcher
def _researcher():
# Build without the heavy __init__; the methods under test only need these.
r = DeepResearcher.__new__(DeepResearcher)
r.synthesis_window = 10
r.max_report_tokens = 4096
return r
_FINDINGS = [
{"url": "https://ex.com/a", "title": "Diarization basics",
"summary": "Speaker diarization segments audio by speaker identity."},
{"url": "https://ex.com/b", "title": "x-vectors",
"evidence": "x-vectors are embeddings used to cluster speech segments."},
]
def test_synthesis_uses_a_generous_timeout_not_60s():
"""The synthesis LLM call must get a budget consistent with the final report
(180s), not the old 60s that timed out on slow local models (#1551)."""
r = _researcher()
seen = {}
async def _fake_llm(messages, **kwargs):
seen.update(kwargs)
return "synthesized report"
r._llm = _fake_llm
r._emit = lambda **k: None
out = asyncio.run(r._synthesize("q", _FINDINGS, ""))
assert out == "synthesized report"
assert seen.get("timeout", 0) >= 180, f"synthesis timeout too short: {seen.get('timeout')}"
def test_fallback_report_preserves_findings():
"""_fallback_report must surface the gathered findings (title + content),
not a 'nothing found' message."""
r = _researcher()
report = r._fallback_report("how does speaker diarization work", _FINDINGS)
assert "speaker diarization" in report.lower()
assert "Diarization basics" in report
assert "x-vectors" in report
assert "https://ex.com/a" in report
# It must NOT be the give-up message.
assert "No information could be gathered" not in report
def test_synthesis_failure_keeps_previous_report():
"""If synthesis raises, the previous report is preserved (not blanked) so the
findings survive the round and the fallback can use them."""
r = _researcher()
async def _boom(messages, **kwargs):
raise RuntimeError("502 after 3 attempts")
r._llm = _boom
r._emit = lambda **k: None
prev = "existing report body"
out = asyncio.run(r._synthesize("q", _FINDINGS, prev))
assert out == prev # unchanged, not emptied