Files
ragflow/rag/advanced_rag/harness/orchestrator/agentic.py
Kevin Hu 3da2425158 Feat: Add dataset_navigate to agentic searhc (#17052)
### Summary

Add dataset_navigate tool to agentic search

---------

Co-authored-by: Yingfeng Zhang <yingfeng.zhang@gmail.com>
2026-07-20 09:59:33 +08:00

324 lines
12 KiB
Python

"""High/Ultra: two-level loop — orchestrator assigns claims, agent researches, sufficiency checks."""
import asyncio
import logging
from rag.advanced_rag.harness.types import (
ClaimTarget,
AgentResult,
OrchestratorContext,
)
from rag.advanced_rag.harness.config import get_mode
from rag.advanced_rag.harness.pipeline import Pipeline
from rag.advanced_rag.harness.agent import research_agent_loop
from rag.advanced_rag.harness.sufficiency import (
cross_check_claim,
compute_fusion_score,
route_sufficiency_verdict,
)
_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] + "..."
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)
pipeline = Pipeline(tools, compilation_map)
for cycle in range(mode.max_orchestrator_cycles):
ctx.iteration = cycle
_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]
if unverified:
# 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, pipeline, ctx, mode, compilation_map) 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)
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", []),
)
# 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 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]
cross_results = [cross_check_claim(r, all_chunks) for r in agent_results_list]
verdict = compute_fusion_score(agent_results_list, cross_results, mode)
ctx.verdict = verdict
action, should_continue = route_sufficiency_verdict(
verdict,
mode_label,
cycle,
mode.max_orchestrator_cycles,
)
_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)
if action == "ANSWER_PARTIAL":
return _finalize(ctx, tools, partial=True)
if action == "ABSTAIN":
tools.kbinfos["chunks"] = []
return {"verdict": verdict.__dict__, "abstain": True}
if action == "REPLAN":
# Ultra: re-plan on low score
from rag.advanced_rag.harness.planner import planner_node
state["feedback"] = verdict.feedback
state["route"] = route
new_plan = await planner_node(state, tools)
ctx.claims = new_plan.get("claims", ctx.claims)
if action == "FALLBACK_LLM":
return _finalize(ctx, tools, partial=True, fallback=True)
# Max cycles reached
return _finalize(ctx, tools, partial=True)
async def _run_claim_research(
claim: ClaimTarget,
tools,
pipeline: Pipeline,
ctx: OrchestratorContext,
mode,
compilation_map: dict,
) -> dict:
_LOG.info('[Agentic research] Researching: "%s"', _snip(claim.description))
try:
result = await asyncio.wait_for(
research_agent_loop(claim, tools, pipeline, ctx, mode, compilation_map),
timeout=CLAIM_RESEARCH_TIMEOUT_SECONDS,
)
except asyncio.CancelledError:
raise
except asyncio.TimeoutError:
_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) -> 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,
"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 evidence chunks from agent results
for c in ctx.claims:
if c.agent_result and c.agent_result.evidence_ids:
for eid in c.agent_result.evidence_ids:
if eid not in seen_evidence:
seen_evidence.add(eid)
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 common.misc_utils import thread_pool_exec
from api.db.services.compilation_template_group_service import CompilationTemplateGroupService
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 == "artifacts":
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