Files
qinling0210 2aaa6baf0c fix(agentic-rag): raise max_parallel_agents for high/ultra to 4, use web search after locate fails repeatedly (#18430)
### Summary

1. It changes the fallback semantics of the locate phase. When no chunks
are found, the system stays in locate. If the same claim has two
consecutive locate rounds with neither evidence chunks nor newly routed
document scope, web_search is admitted to the candidate tool set on the
next locate round as an external fallback.

2. It makes locate_empty_streak claim-scoped instead of shared in the
global context. This prevents one claim’s empty locate rounds from
affecting sibling claims running in parallel.

3. On the config side, it only raises max_parallel_agents for high /
ultra to 4, without changing max_agent_cycles. This increases parallel
claim execution without deepening per-claim search.
2026-08-18 19:40:07 +08:00

523 lines
24 KiB
Python

"""High/Ultra: two-level loop — orchestrator assigns claims, agent researches, sufficiency checks."""
import asyncio
import logging
from rag.advanced_rag.harness.agent import research_agent_loop
from rag.advanced_rag.harness.config import get_mode
from rag.advanced_rag.harness.orchestrator.sufficiency_llm import llm_sufficiency_boost
from rag.advanced_rag.harness.pipeline import Pipeline
from rag.advanced_rag.harness.stats import in_phase, record_round, record_round_claims
from rag.advanced_rag.harness.sufficiency import (
compute_fusion_score,
cross_check_claim,
route_sufficiency_verdict,
)
from rag.advanced_rag.harness.types import (
AgentResult,
ClaimTarget,
OrchestratorContext,
)
_LOG = logging.getLogger(__name__)
CLAIM_RESEARCH_TIMEOUT_SECONDS = 180
def _snip(text: str, limit: int = 160) -> str:
text = (text or "").replace("\n", " ").strip()
return text if len(text) <= limit else text[: limit - 3] + "..."
def _discovered_entity(tools) -> str | None:
"""Pick a salient discovered name from the gathered evidence.
Prefers an explicit entity/keyword tag on a chunk, falling back to a source
document name. Used only to gate ``graph_explore`` eligibility (its context
check needs ``context.last_entity``), so a coarse signal is enough.
"""
chunks = (getattr(tools, "kbinfos", {}) or {}).get("chunks", []) or []
for c in chunks:
for key in ("entities_kwd", "important_kwd"):
val = c.get(key)
if isinstance(val, list) and val:
first = str(val[0]).strip()
if first:
return first
if isinstance(val, str) and val.strip():
return val.strip().split()[0]
for c in chunks:
name = str(c.get("docnm_kwd") or "").strip()
if name:
return name
return None
@in_phase("orchestrator")
async def agentic_research(state: dict, tools) -> dict:
"""Two-level loop for high/ultra modes."""
question = state.get("question", "")
claims_raw = state.get("claims", [])
route = state.get("route", {})
mode_label = route.thinking_mode if route else "high"
mode = get_mode(mode_label)
# Resolve compilation map
compilation_map = await _get_compilation_map(tools)
claims = [ClaimTarget(**c) if isinstance(c, dict) else c for c in claims_raw]
ctx = OrchestratorContext(question=question, claims=claims, mode=mode_label)
# Stagnation guard: if the fusion score stops improving across consecutive
# rounds, further searching is unlikely to help (e.g. the corpus simply lacks
# the data, and follow-ups keep returning nothing). Without this, a
# persistently INSUFFICIENT verdict burns every remaining cycle and, in the
# worst case, feeds a long empty loop (see check.log Q4: AutoRater said
# "not in corpus", follow-ups found nothing, yet the loop kept spinning).
prev_score: float | None = None
_STAGNATION_CYCLES = 2 # rounds with no meaningful gain before giving up
_STAGNATION_GAIN = 0.05 # minimum fusion-score improvement to count
rounds_run = 0
for cycle in range(mode.max_orchestrator_cycles):
rounds_run = cycle + 1
ctx.iteration = cycle
record_round("orchestrator")
_LOG.info("[Agentic research] Research round %d of %d%d step(s) still unanswered.", cycle + 1, mode.max_orchestrator_cycles, sum(1 for c in ctx.claims if not c.is_verified))
# ── Step A: Research unverified claims (parallel if mode allows) ──
unverified = [c for c in ctx.claims if not c.is_verified]
record_round_claims("claim_research", len(unverified))
if unverified:
# Consume Phase-2 follow-up queries (missing-pieces feedback) ONCE for
# this round and hand the SAME list to every claim in the batch.
# Reading the shared ``ctx.pending_followups`` inside
# research_agent_loop would race under ``asyncio.gather``: the first
# claim to execute would clear it, starving the parallel siblings.
# We only consume/clear here — inside ``if unverified`` — because a
# research task must actually dispatch to use them. When everything
# is already verified no task runs, so we retain the queries for the
# next cycle instead of discarding them.
followups: list[str] = []
if ctx.pending_followups:
followups = [str(q.get("query") or q.get("question") or "") for q in ctx.pending_followups if q]
followups = [q for q in followups if q.strip()]
ctx.pending_followups = []
if followups:
_LOG.info("[Agentic research] Round %d: injecting %d follow-up query(ies) to all claims: %s", cycle + 1, len(followups), followups)
# Process in batches of max_parallel_agents
batch_size = mode.max_parallel_agents
for i in range(0, len(unverified), batch_size):
batch = unverified[i : i + batch_size]
_LOG.info(
"[Agentic research] Round %d: researching %d step(s) in parallel: %s",
cycle + 1,
len(batch),
"; ".join(f'"{c.description}"' for c in batch),
)
tasks = [_run_claim_research(c, tools, ctx, mode, compilation_map, followups=followups) for c in batch]
agent_results = await asyncio.gather(*tasks)
_LOG.info(
"[Agentic research] Round %d: finished researching %d step(s).",
cycle + 1,
len(agent_results),
)
for c, result in zip(batch, agent_results):
is_verified = result.get("is_verified", False)
c.is_verified = is_verified
c.confidence = result.get("confidence", 0.0)
grounded = result.get("grounded", [])
numbers = result.get("numbers", [])
if "grounded" not in result or "numbers" not in result:
_LOG.warning(
"[Agentic research] claim=%s report omitted the schema-required grounded/numbers fields (grounded=%r numbers=%r) — verification for it is degraded",
c.claim_id,
grounded,
numbers,
)
c.agent_result = AgentResult(
claim_id=c.claim_id,
report=result.get("report", ""),
is_verified=is_verified,
confidence=c.confidence,
evidence_ids=result.get("evidence_ids", []),
gaps=result.get("gaps", []),
discovered_claims=result.get("discovered_claims", []),
grounded=grounded,
numbers=numbers,
)
# Ultra: dynamic claim expansion
if mode.allows_dynamic_claims and result.get("discovered_claims"):
for dc in result["discovered_claims"]:
if dc and dc not in [cc.description for cc in ctx.claims]:
ctx.claims.append(
ClaimTarget(
claim_id=f"c_dyn_{len(ctx.claims)}",
description=dc,
)
)
_LOG.info('[Agentic research] Found a new angle worth researching: "%s"', dc)
# ── Step A.5: note a discovered entity so graph_explore becomes eligible
# in the next round (its context gate requires context.last_entity). ──
ctx.note_entity(_discovered_entity(tools))
# ── Step B: Sufficiency Check ──
all_chunks = {i: c for i, c in enumerate(tools.kbinfos.get("chunks", []))}
agent_results_list = [c.agent_result for c in ctx.claims if c.agent_result]
_LOG.info(
"[Sufficiency] Round %d: evidence pool=%d chunk(s), %d claim(s) with agent results: %s",
cycle + 1,
len(all_chunks),
len(agent_results_list),
[
{
"claim_id": r.claim_id,
"self_verified": r.is_verified,
"self_confidence": round(r.confidence, 3),
"evidence_ids": len(r.evidence_ids),
}
for r in agent_results_list
],
)
cross_results = [cross_check_claim(r, all_chunks) for r in agent_results_list]
verdict = compute_fusion_score(
agent_results_list,
cross_results,
mode,
question=ctx.question,
claims=ctx.claims,
all_chunks=all_chunks,
)
ctx.verdict = verdict
# Decision ladder: the LLM Sufficient Context AutoRater is the primary
# sufficiency judge (invoked every round in high/ultra). Its verdict is
# combined with the code-level signals (hard vetoes + agent confidence)
# inside ``route_sufficiency_verdict`` → ``sufficiency_ladder``. The
# AutoRater's missing-pieces feedback is saved for the next round.
cited_ids: list[str] = []
for r in agent_results_list:
cited_ids.extend(r.evidence_ids or [])
boost = await llm_sufficiency_boost(tools, ctx.question, verdict, evidence_ids=cited_ids)
if boost and boost.get("followups"):
# Missing pieces → targeted follow-up searches for the next round.
ctx.pending_followups = boost.get("followups", [])
_LOG.info("[Agentic research] Stored %d follow-up query(ies) for next round.", len(ctx.pending_followups))
if boost:
_LOG.info("[Agentic research] Round %d: AutoRater is_sufficient=%s confidence=%.2f", cycle + 1, boost.get("is_sufficient"), boost.get("confidence", 1.0))
# LLM groundedness review (Google "draft review" thought): check whether each
# claim's report is semantically supported by the cited evidence. Ungrounded
# claims (hallucinated / over-claimed drafts) are merged into hard_violations
# so the decision ladder forces a caveated answer — this catches relation/over-
# claim errors that the lexical code-level grounded check (cross_check_claim)
# cannot see.
from rag.advanced_rag.harness.orchestrator.grounded_llm import llm_grounded_verify
grounded = await llm_grounded_verify(
tools,
ctx.question,
[(r.claim_id, r.report or "") for r in agent_results_list if r.report],
cited_ids,
)
# Treat a claim as violating when it is explicitly grounded=False OR has
# non-empty ungrounded assertions (covers the degenerate grounded=False /
# empty-ungrounded case too). Only accept IDs present in the original
# claims collection — the LLM may echo a bogus claim_id that must not leak
# into hard_violations.
valid_claim_ids = {r.claim_id for r in agent_results_list}
ungrounded_ids = [cid for cid, g in grounded.items() if cid in valid_claim_ids and (g.get("grounded") is False or g.get("ungrounded"))]
if ungrounded_ids:
existing = set(verdict.hard_violations or [])
verdict.hard_violations = list(existing | set(ungrounded_ids))
_LOG.info("[Agentic research] Round %d: %d claim(s) have ungrounded (draft-review) assertions: %s", cycle + 1, len(ungrounded_ids), ungrounded_ids)
action, should_continue, caveat = route_sufficiency_verdict(
verdict,
mode_label,
cycle,
mode.max_orchestrator_cycles,
auto=boost,
)
if caveat:
_LOG.info("[Agentic research] Round %d: caveat=%s", cycle + 1, caveat)
# Stagnation guard: when the verdict is not (yet) sufficient and the
# fusion score has not meaningfully improved for a couple of rounds,
# stop instead of burning the remaining cycle budget on unproductive
# re-searches. Override the CONTINUE decision with a partial answer.
if should_continue and verdict.status in ("INSUFFICIENT", "USEFUL_BUT_INCOMPLETE"):
if prev_score is not None and cycle >= _STAGNATION_CYCLES and verdict.score - prev_score < _STAGNATION_GAIN:
_LOG.info(
"[Agentic research] Round %d: score stagnant (%.3f%.3f) — early-stopping to partial answer",
cycle + 1,
prev_score,
verdict.score,
)
action = "ANSWER_PARTIAL"
should_continue = False
else:
prev_score = verdict.score
_LOG.info("[Agentic research] Round %d: evidence looks %s (confidence %.0f%%) — next: %s", cycle + 1, verdict.status, verdict.score * 100, action)
if action == "ANSWER":
return _finalize(ctx, tools, partial=False, loop=rounds_run)
if action == "ANSWER_PARTIAL":
return _finalize(ctx, tools, partial=True, loop=rounds_run)
if action == "ABSTAIN":
tools.kbinfos["chunks"] = []
return {"verdict": verdict.__dict__, "abstain": True, "loop": rounds_run}
if action == "REPLAN":
# Ultra: re-plan on low score. Ground the new plan on the evidence
# gathered so far, and carry still-valid verified claims over so a
# replan doesn't re-research (and re-bill) work already done.
from rag.advanced_rag.harness.planner import planner_node
state["feedback"] = verdict.feedback
state["route"] = route
state["seed_chunks"] = list(tools.kbinfos.get("chunks", []) or [])
new_plan = await planner_node(state, tools)
# Keep EVERY verified claim (even ones the new plan omitted — their
# evidence is still valid and shouldn't be re-researched), then
# append only the new plan's unverified claims.
verified = [c for c in ctx.claims if c.is_verified]
new_by_desc = {}
for c in new_plan.get("claims", ctx.claims):
if isinstance(c, ClaimTarget):
new_by_desc.setdefault(c.description, c)
seen = {c.description for c in verified}
ctx.claims = verified + [c for c in new_by_desc.values() if c.description not in seen]
if action == "FALLBACK_LLM":
return _finalize(ctx, tools, partial=True, fallback=True, loop=rounds_run)
# Max cycles reached
return _finalize(ctx, tools, partial=True, loop=rounds_run)
async def _run_claim_research(
claim: ClaimTarget,
tools,
ctx: OrchestratorContext,
mode,
compilation_map: dict,
followups: list[str] | None = None,
) -> dict:
_LOG.info('[Agentic research] Researching: "%s"', _snip(claim.description))
# A dedicated pipeline per claim keeps the routing scope (``_routed_docs``)
# isolated: under asyncio.gather the shared single pipeline would let one
# claim's dataset_navigation_search leak its doc_scope into a sibling's
# follow-up searches (the doc_scope is set on the pipeline, not the claim).
# ``tools.kbinfos`` stays shared, so the citation pool still merges across
# claims via Pipeline._merge_into_kbinfos.
pipeline = Pipeline(tools, compilation_map)
try:
result = await asyncio.wait_for(
research_agent_loop(claim, tools, pipeline, ctx, mode, compilation_map, followups=followups),
timeout=CLAIM_RESEARCH_TIMEOUT_SECONDS,
)
except asyncio.CancelledError:
raise
except TimeoutError:
if getattr(pipeline, "_active_phase", None) == "locate":
if pipeline._round_had_evidence or pipeline._round_had_routed_scope_progress:
claim.locate_empty_streak = 0
else:
claim.locate_empty_streak += 1
_LOG.warning(
"[Agentic research] claim=%s timed out in locate (progress=%s, locate_empty_streak=%d).",
claim.claim_id,
pipeline._round_had_evidence or pipeline._round_had_routed_scope_progress,
claim.locate_empty_streak,
)
_LOG.warning(
'[Agentic research] Gave up on "%s" — it took longer than %ss.',
_snip(claim.description),
CLAIM_RESEARCH_TIMEOUT_SECONDS,
)
return {
"report": "",
"is_verified": False,
"confidence": 0.0,
"evidence_ids": [],
"gaps": [f"claim research timeout after {CLAIM_RESEARCH_TIMEOUT_SECONDS}s"],
"discovered_claims": [],
}
except Exception:
_LOG.exception('[Agentic research] Hit an error while researching "%s".', _snip(claim.description))
return {
"report": "",
"is_verified": False,
"confidence": 0.0,
"evidence_ids": [],
"gaps": ["claim research failed"],
"discovered_claims": [],
}
_LOG.info(
'[Agentic research] Finished "%s"%s, backed by %d passage(s) (confidence %.0f%%)%s.',
_snip(claim.description),
"answered" if result.get("is_verified") else "still unanswered",
len(result.get("evidence_ids") or []),
float(result.get("confidence") or 0.0) * 100,
f", {len(result.get('gaps') or [])} gap(s) remain" if result.get("gaps") else "",
)
return result
def _finalize(ctx: OrchestratorContext, tools, partial: bool = False, fallback: bool = False, loop: int = 0) -> dict:
"""Merge agent results into kbinfos and return."""
_merge_agent_results(ctx, tools)
return {
"verdict": ctx.verdict.__dict__ if ctx.verdict else None,
"partial_answer": partial or fallback,
"loop": loop,
"kbinfos": tools.kbinfos,
}
def _merge_agent_results(ctx: OrchestratorContext, tools):
"""Merge agent result reports into kbinfos as a pre_summary."""
combined = []
seen_evidence = set()
for c in ctx.claims:
if c.agent_result and c.agent_result.report:
status = "" if c.is_verified else ""
combined.append(f"{c.claim_id}{status} {c.agent_result.report[:500]}")
if combined:
tools.kbinfos["pre_summary"] = "\n\n".join(combined)
# Collect the chunks the agents actually cited across all claims. These
# indices share the same positional space as kb_prompt's ``[ID:n]`` markers
# (both index tools.kbinfos["chunks"]).
for c in ctx.claims:
if c.agent_result and c.agent_result.evidence_ids:
for eid in c.agent_result.evidence_ids:
if isinstance(eid, int):
seen_evidence.add(eid)
# Drop chunks no claim ever cited (e.g. pre_search recall that didn't pan
# out) so the final-answer LLM call only sees the useful evidence. Preserve
# order so the re-numbered [ID:n] citations stay stable. Defensive: never
# filter to empty — if nothing was cited, keep the full pool.
all_chunks = tools.kbinfos.get("chunks") or []
keep = sorted(i for i in seen_evidence if 0 <= i < len(all_chunks))
if keep and len(keep) < len(all_chunks):
_LOG.info("[Agentic research] Trimming evidence for the final answer: %d of %d chunk(s) were cited.", len(keep), len(all_chunks))
tools.kbinfos["chunks"] = [all_chunks[i] for i in keep]
async def _get_compilation_map(tools) -> dict[str, set[str]]:
"""Build compilation map from RAGTools - check which KBs have compilation artifacts."""
result = {}
if not tools.kbs:
return result
for kb in tools.kbs:
comps = set()
parser_config = getattr(kb, "parser_config", None) or {}
if parser_config.get("toc"):
comps.add("toc")
if parser_config.get("knowledge_graph"):
comps.add("knowledge_graph")
if parser_config.get("wiki"):
comps.add("wiki")
if parser_config.get("mindmap"):
comps.add("mindmap")
if parser_config.get("page_index"):
comps.add("page_index")
await _add_template_group_compilations(comps, parser_config, getattr(kb, "tenant_id", ""))
if await _has_dataset_nav_rows(getattr(kb, "tenant_id", ""), getattr(kb, "id", "")):
comps.add("tree")
if comps:
result[kb.id] = comps
return result
async def _has_dataset_nav_rows(tenant_id: str, kb_id: str) -> bool:
if not tenant_id or not kb_id:
return False
try:
from common import settings
from common.doc_store.doc_store_base import OrderByExpr
from common.misc_utils import thread_pool_exec
from rag.nlp import search
index_name = search.index_name(tenant_id)
if not settings.docStoreConn.index_exist(index_name, kb_id):
return False
res = await thread_pool_exec(
settings.docStoreConn.search,
["id"],
[],
{"compile_kwd": ["dataset_nav"]},
[],
OrderByExpr(),
0,
1,
index_name,
[kb_id],
)
return bool(settings.docStoreConn.get_total(res))
except Exception:
_LOG.exception("[agentic] dataset-nav existence check failed for kb=%s", kb_id)
return False
async def _add_template_group_compilations(comps: set[str], parser_config: dict, tenant_id: str) -> None:
"""Infer available compilation kinds from selected template groups."""
if not tenant_id:
return
try:
from api.db.services.compilation_template_group_service import CompilationTemplateGroupService
from common.misc_utils import thread_pool_exec
from rag.svr.task_executor_refactor.chunk_post_processor import (
_parser_config_compilation_template_group_ids,
)
except Exception:
_LOG.exception("[agentic] compilation-map helper import failed")
return
try:
group_ids = _parser_config_compilation_template_group_ids(parser_config)
except Exception:
_LOG.exception("[agentic] compilation template group id resolution failed")
return
for group_id in group_ids:
try:
group = await thread_pool_exec(CompilationTemplateGroupService.get_saved, group_id, tenant_id)
except Exception:
_LOG.exception("[agentic] compilation template group read failed id=%s", group_id)
continue
for template in (group or {}).get("templates") or []:
config = template.get("config") or {}
raw_kind = (config.get("kind") if isinstance(config, dict) else "") or template.get("kind") or ""
raw_norm = raw_kind.strip().lower().replace("-", "_") if isinstance(raw_kind, str) else ""
kind = _compilation_kind_for_agentic_map(raw_kind)
if raw_norm == "knowledge_graph":
comps.add("knowledge_graph")
if kind == "tree":
comps.add("tree")
elif kind in {"timeline", "page_index", "pageindex"}:
comps.add("page_index")
elif kind in {"mindmap", "mind_map"}:
comps.add("mindmap")
elif kind == "wiki":
comps.add("wiki")
def _compilation_kind_for_agentic_map(kind) -> str:
if not isinstance(kind, str):
return ""
normalized = kind.strip().lower().replace("-", "_")
if normalized in {"pageindex", "page_index"}:
return "timeline"
return normalized