diff --git a/api/apps/restful_apis/chat_api.py b/api/apps/restful_apis/chat_api.py
index 9398676a30..12a2662795 100644
--- a/api/apps/restful_apis/chat_api.py
+++ b/api/apps/restful_apis/chat_api.py
@@ -30,7 +30,7 @@ from api.apps.restful_apis._generation_params import merge_generation_config, po
from api.db.joint_services.tenant_model_service import get_api_key, get_tenant_default_model_by_type, resolve_model_config
from api.db.services.chunk_feedback_service import ChunkFeedbackService
from api.db.services.conversation_service import ConversationService, structure_answer
-from api.db.services.dialog_service import DialogService, async_chat, gen_mindmap
+from api.db.services.dialog_service import DialogService, async_chat, gen_mindmap, rag_agent
from api.db.services.knowledgebase_service import KnowledgebaseService, validate_dataset_embedding_models
from api.db.services.llm_service import LLMBundle
from api.db.services.search_service import SearchService
@@ -1256,7 +1256,7 @@ async def session_completion(chat_id_in_arg=""):
# start_to_think/end_to_think events.
legacy_answer = ""
final_answer = None
- async for ans in async_chat(dia, msg, True, session_id=session_id, **req):
+ async for ans in rag_agent(dia, msg, True, session_id=session_id, **req):
ans = _format_answer(ans)
if ans.get("final"):
final_answer = ans
@@ -1293,7 +1293,7 @@ async def session_completion(chat_id_in_arg=""):
payload = _sanitize_json_floats({"code": 0, "message": "", "data": final_chunk})
yield "data:" + json.dumps(payload, ensure_ascii=False) + "\n\n"
else:
- async for ans in async_chat(dia, msg, True, session_id=session_id, **req):
+ async for ans in rag_agent(dia, msg, True, session_id=session_id, **req):
ans = _format_answer(ans)
payload = _sanitize_json_floats({"code": 0, "message": "", "data": ans})
yield "data:" + json.dumps(payload, ensure_ascii=False) + "\n\n"
@@ -1313,7 +1313,7 @@ async def session_completion(chat_id_in_arg=""):
return resp
answer = None
- async for ans in async_chat(dia, msg, False, session_id=session_id, **req):
+ async for ans in rag_agent(dia, msg, False, session_id=session_id, **req):
answer = _format_answer(ans)
if conv is not None:
await thread_pool_exec(ConversationService.update_by_id, conv.id, conv.to_dict())
diff --git a/api/db/services/dialog_service.py b/api/db/services/dialog_service.py
index 8572211a1a..b99207e073 100644
--- a/api/db/services/dialog_service.py
+++ b/api/db/services/dialog_service.py
@@ -19,6 +19,7 @@ import re
import time
import uuid
from copy import deepcopy
+from rag.advanced_rag.agentic_rag import RAGTools
logger = logging.getLogger(__name__)
from datetime import datetime
@@ -715,7 +716,8 @@ async def async_chat(dialog, messages, stream=True, **kwargs):
logging.debug("Proceeding with retrieval")
tenant_ids = list(set([kb.tenant_id for kb in kbs]))
knowledges = []
- if prompt_config.get("reasoning", False) or kwargs.get("reasoning"):
+ # replaced by extension of reasoning: 0, 1, 2
+ if False: # prompt_config.get("reasoning", False) or kwargs.get("reasoning"):
reasoner = DeepResearcher(
chat_mdl,
prompt_config,
@@ -1835,3 +1837,177 @@ async def gen_mindmap(question, kb_ids, tenant_id, search_config={}):
mindmap = MindMapExtractor(chat_mdl)
mind_map = await mindmap([c["content_with_weight"] for c in ranks["chunks"]])
return mind_map.output
+
+
+async def rag_agent(dialog, messages, stream=True, **kwargs):
+ logging.debug("Begin rag_agent")
+ assert messages[-1]["role"] == "user", "The last content of this conversation is not from user."
+ prompt_config = dialog.prompt_config
+ if not prompt_config.get("reasoning", 0) and not kwargs.get("reasoning"):
+ async for ans in async_chat(dialog, messages, stream, **kwargs):
+ yield ans
+ return
+ kbs, embd_mdl, rerank_mdl, chat_mdl, tts_mdl = get_models(dialog)
+ use_web_search = _should_use_web_search(prompt_config, kwargs.get("internet"))
+ logging.debug("web_search kb=%s tavily=%s internet=%r enabled=%s", bool(dialog.kb_ids), bool(dialog.prompt_config.get("tavily_api_key")), kwargs.get("internet"), use_web_search)
+ tenant_ids = list(set([kb.tenant_id for kb in kbs]))
+ # "reasoning" arrives as "1".."4" mapping to the ordered THINKING_MODES
+ # (low, medium, high, ultra); fall back to "medium" on anything else.
+ from rag.advanced_rag.harness.config import THINKING_MODES
+
+ _mode_labels = list(THINKING_MODES.keys())
+ try:
+ _n = int(str(kwargs.get("reasoning")).strip())
+ thinking_mode = _mode_labels[_n - 1] if 1 <= _n <= len(_mode_labels) else "medium"
+ except (TypeError, ValueError):
+ thinking_mode = "medium"
+
+ rag_tools = RAGTools(
+ tenant_ids,
+ chat_mdl,
+ embed_mdl=embd_mdl,
+ kb_ids=dialog.kb_ids,
+ tav=Tavily(prompt_config["tavily_api_key"]) if use_web_search else None,
+ do_refer=False,
+ thinking_mode=thinking_mode,
+ )
+
+ async def decorate_answer(answer):
+ nonlocal rag_tools, messages
+
+ refs = []
+ ans = answer.split("")
+ think = ""
+ if len(ans) == 2:
+ think = ans[0] + ""
+ answer = ans[1]
+
+ idx = set([])
+ normalized_answer = normalize_arabic_digits(answer) or ""
+ for match in CITATION_MARKER_PATTERN.finditer(normalized_answer):
+ i = int(match.group(1))
+ if i < len(rag_tools.kbinfos["chunks"]):
+ idx.add(i)
+
+ answer, idx = repair_bad_citation_formats(answer, rag_tools.kbinfos, idx)
+
+ doc_ids = set()
+ for citation in idx:
+ try:
+ chunk_index = int(citation)
+ except (TypeError, ValueError):
+ if citation:
+ doc_ids.add(str(citation))
+ continue
+ if 0 <= chunk_index < len(rag_tools.kbinfos["chunks"]):
+ doc_id = rag_tools.kbinfos["chunks"][chunk_index].get("doc_id")
+ if doc_id:
+ doc_ids.add(doc_id)
+
+ recall_docs = [d for d in rag_tools.kbinfos["doc_aggs"] if d["doc_id"] in doc_ids]
+ if not recall_docs:
+ recall_docs = rag_tools.kbinfos["doc_aggs"]
+ rag_tools.kbinfos["doc_aggs"] = recall_docs
+
+ refs = deepcopy(rag_tools.kbinfos) if doc_ids else []
+ for c in refs.get("chunks", []) if isinstance(refs, dict) else []:
+ if c.get("vector"):
+ del c["vector"]
+
+ if answer.lower().find("invalid key") >= 0 or answer.lower().find("invalid api") >= 0:
+ answer += " Please set LLM API-Key in 'User Setting -> Model providers -> API-Key'"
+
+ return {"answer": think + answer, "reference": refs, "prompt": "", "created_at": time.time()}
+
+ # The agentic-search graph composes the final cited answer itself, so we
+ # stream its tokens straight to the client instead of relaying a tool
+ # result through a second outer-LLM pass.
+
+ chat_mdl.bind_tools(None, rag_tools.tools)
+ # `rag` composes the full cited answer itself, so treat it as terminal: once
+ # the model calls it, stream its result and stop — otherwise the model would
+ # have to relay the (citation-bearing) answer through another round, which
+ # small models mangle or drop, so the client receives nothing.
+ if getattr(chat_mdl, "mdl", None) is not None:
+ chat_mdl.mdl.terminal_tools = {"rag"}
+ gen_conf = dialog.llm_setting
+ if stream:
+ # Surface the agentic pipeline's bracket-tagged progress logs to the
+ # client as content, interleaved with the real token stream.
+ from rag.advanced_rag.think_log import install_think_log_handler, set_think_log_sink, reset_think_log_sink
+
+ install_think_log_handler()
+ event_queue: asyncio.Queue = asyncio.Queue()
+ loop = asyncio.get_running_loop()
+
+ def _log_sink(msg):
+ try:
+ loop.call_soon_threadsafe(event_queue.put_nowait, ("log", msg))
+ except RuntimeError:
+ pass
+
+ async def _drive_stream():
+ try:
+ stream_iter = chat_mdl.async_chat_streamly_delta(rag_tools.sys_prompt(), messages, gen_conf)
+ async for kind, value, state in _stream_with_think_delta(stream_iter):
+ event_queue.put_nowait(("stream", kind, value, state))
+ except Exception:
+ logging.exception("rag_agent: agentic stream failed")
+ finally:
+ event_queue.put_nowait(("stream_done",))
+
+ token = set_think_log_sink(_log_sink)
+ drive = asyncio.create_task(_drive_stream())
+ last_state = None
+ log_think_open = False
+ try:
+ while True:
+ item = await event_queue.get()
+ if item[0] == "log":
+ if not log_think_open:
+ yield {"answer": "", "reference": {}, "audio_binary": None, "final": False, "start_to_think": True}
+ log_think_open = True
+ yield {"answer": item[1] + "\n", "reference": {}, "audio_binary": None, "final": False}
+ continue
+ if item[0] == "stream_done":
+ break
+ _, kind, value, state = item
+ if state is not None:
+ last_state = state
+ # A real stream event follows the logs -> close the log think block.
+ if log_think_open:
+ yield {"answer": "", "reference": {}, "audio_binary": None, "final": False, "end_to_think": True}
+ log_think_open = False
+ if kind == "marker":
+ flags = {"start_to_think": True} if value == "" else {"end_to_think": True}
+ yield {"answer": "", "reference": {}, "audio_binary": None, "final": False, **flags}
+ continue
+ yield {"answer": value, "reference": {}, "audio_binary": tts(tts_mdl, value), "final": False}
+ if log_think_open:
+ yield {"answer": "", "reference": {}, "audio_binary": None, "final": False, "end_to_think": True}
+ log_think_open = False
+ finally:
+ reset_think_log_sink(token)
+ if not drive.done():
+ drive.cancel()
+ try:
+ await drive
+ except asyncio.CancelledError:
+ pass
+ except Exception:
+ logging.exception("rag_agent: drive task error")
+
+ full_answer = last_state.full_text if last_state else ""
+ if full_answer:
+ final = await decorate_answer(_extract_visible_answer(full_answer))
+ final["final"] = True
+ final["audio_binary"] = None
+ yield final
+ else:
+ answer = await chat_mdl.async_chat(rag_tools.sys_prompt(), messages, gen_conf)
+ user_content = messages[-1].get("content", "[content not available]")
+ logging.debug("User: {}|Assistant: {}".format(user_content, answer))
+ res = await decorate_answer(answer)
+ res["audio_binary"] = tts(tts_mdl, answer)
+ yield res
+ return
diff --git a/api/db/services/llm_service.py b/api/db/services/llm_service.py
index c1fe749bee..2a41918be9 100644
--- a/api/db/services/llm_service.py
+++ b/api/db/services/llm_service.py
@@ -412,7 +412,7 @@ class LLMBundle(LLM4Tenant):
return queue
async def async_chat(self, system: str, history: list, gen_conf: dict = {}, **kwargs):
- if self.is_tools and getattr(self.mdl, "is_tools", False) and hasattr(self.mdl, "async_chat_with_tools"):
+ if self.is_tools and hasattr(self.mdl, "async_chat_with_tools"):
base_fn = self.mdl.async_chat_with_tools
elif hasattr(self.mdl, "async_chat"):
base_fn = self.mdl.async_chat
diff --git a/pyproject.toml b/pyproject.toml
index bbced889bf..32bb849fbb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -132,6 +132,7 @@ dependencies = [
"yfinance==0.2.65",
"zhipuai==2.0.1",
"peewee>=3.17.1,<4.0.0",
+ "langgraph==1.2.0",
# following modules aren't necessary
# "nltk==3.9.1",
# "numpy>=1.26.0,<2.0.0",
diff --git a/rag/advanced_rag/__init__.py b/rag/advanced_rag/__init__.py
index c294e8898d..d2b3ddc294 100644
--- a/rag/advanced_rag/__init__.py
+++ b/rag/advanced_rag/__init__.py
@@ -15,6 +15,17 @@
#
from .tree_structured_query_decomposition_retrieval import TreeStructuredQueryDecompositionRetrieval as DeepResearcher
+from .harness.config import THINKING_MODES, get_mode
+from .harness.types import RouteDecision, ExecutionStrategy, ClaimTarget, WorkflowPlan, SufficiencyVerdict
-__all__ = ["DeepResearcher"]
+__all__ = [
+ "DeepResearcher",
+ "THINKING_MODES",
+ "get_mode",
+ "RouteDecision",
+ "ExecutionStrategy",
+ "ClaimTarget",
+ "WorkflowPlan",
+ "SufficiencyVerdict",
+]
diff --git a/rag/advanced_rag/agentic_rag.py b/rag/advanced_rag/agentic_rag.py
new file mode 100644
index 0000000000..01eada141e
--- /dev/null
+++ b/rag/advanced_rag/agentic_rag.py
@@ -0,0 +1,614 @@
+#
+# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+"""Agentic-RAG capability layer.
+
+``RAGTools`` bundles every retrieval primitive the agentic-search graph
+(:mod:`rag.advanced_rag.agentic_rag_graph`) needs — question formalisation,
+document scoping, keyword analysis, KB / web / structured retrieval, a
+sufficiency judge and follow-up-question generation — plus the two things
+the *outer* LLM is ever allowed to call as tools: ``rag`` (run the whole
+agentic-search graph) and ``summarize_document`` (dump one document for an
+explicit summary request).
+
+The individual search steps are deliberately NOT ``@tool``-decorated: the
+graph orchestrates them itself, so ``chat_mdl`` stays a plain reasoning
+model (no tool schema is bound onto it) and its ``async_chat*`` calls take
+the fast non-tool-calling path.
+"""
+
+from copy import deepcopy
+import logging
+import re
+from typing import Any, List
+
+import json_repair
+from api.db.services.doc_metadata_service import DocMetadataService
+from api.db.services.document_service import DocumentService
+from api.db.services.knowledgebase_service import KnowledgebaseService
+from api.db.services.llm_service import LLMBundle
+from common import settings
+from common.misc_utils import thread_pool_exec
+from common.token_utils import num_tokens_from_string
+from rag.advanced_rag.agentic_rag_graph import _strip_think_stream
+from rag.app.tag import label_question
+from rag.llm.tool_decorator import tool
+from rag.prompts.generator import (
+ citation_prompt,
+ form_message,
+ gen_meta_filter,
+ kb_prompt,
+ message_fit_in,
+ multi_queries_gen,
+ sufficiency_select,
+)
+from api.db.db_models import Document, Knowledgebase
+from rag.utils.tavily_conn import Tavily
+
+
+# Tokens held back from the model's context when fitting retrieved evidence
+# into the sufficiency / follow-up prompts. The evidence sits in the MIDDLE of
+# those templates (question first, JSON output rules last), so if the combined
+# prompt overflows the downstream trimmer eats the output rules, not the
+# evidence. Reserving headroom for the template skeleton + question + output
+# lets us trim the evidence up front instead.
+_EVIDENCE_PROMPT_RESERVE_TOKENS = 1024
+
+
+class RAGTools:
+ def __init__(
+ self,
+ tenant_ids: list[str],
+ chat_mdl: LLMBundle,
+ embed_mdl: LLMBundle | None = None,
+ kb_ids: List[str] | None = None,
+ kbs: list[Knowledgebase] | None = None,
+ tav: Tavily | None = None,
+ meta_data_filter: dict | None = None,
+ user_defined_prompts: dict | None = None,
+ do_refer: bool | None = True,
+ thinking_mode: str = "medium",
+ ):
+ self.tenant_ids = tenant_ids
+ self.chat_mdl = deepcopy(chat_mdl)
+ self.embed_mdl = embed_mdl
+ self.thinking_mode = thinking_mode
+ self.field_map = {}
+ self.sql_kbs = []
+ self.kbs = []
+ self.kb_ids = []
+
+ def _exclude_sql_kb(kb):
+ if kb.parser_config and "field_map" in kb.parser_config:
+ self.field_map.update(kb.parser_config["field_map"])
+ self.sql_kbs.append(kb)
+ else:
+ self.kbs.append(kb)
+ self.kb_ids.append(kb.id)
+
+ if kb_ids:
+ for kb in KnowledgebaseService.get_by_ids(kb_ids):
+ _exclude_sql_kb(kb)
+ elif kbs:
+ for kb in kbs:
+ _exclude_sql_kb(kb)
+
+ self.tav = tav
+ self.meta_data_filter = meta_data_filter
+ self.user_defined_prompts = user_defined_prompts or {}
+ self.kbinfos = {"chunks": [], "doc_aggs": []}
+ self.do_refer = do_refer
+ # Citation pool shared with the final-answer node: the graph publishes
+ # the chunks it actually used here (in the SAME order the answer's
+ # ``[ID:n]`` markers index), so the caller can resolve references.
+ self.kbinfos: dict[str, list] = {"chunks": [], "doc_aggs": []}
+
+ # The two tools the outer LLM may bind. They are NOT auto-bound here —
+ # the agentic-search flow drives the graph directly — but callers that
+ # want a tool surface can do ``chat_mdl.bind_tools(tools=rag_tools.tools)``.
+ self.tools = [self.rag, self.summarize_document]
+
+ # ------------------------------------------------------------------ #
+ # Capability flags / cheap introspection
+ # ------------------------------------------------------------------ #
+ def has_unstructured(self) -> bool:
+ return bool(self.kb_ids)
+
+ def has_structured(self) -> bool:
+ return bool(self.sql_kbs and self.field_map)
+
+ def has_web(self) -> bool:
+ return self.tav is not None
+
+ def has_llm(self) -> bool:
+ return self.chat_mdl is not None
+
+ async def _fit_messages(self, system: str, user: str) -> list:
+ """Fit system+user messages into the model's context window."""
+ from rag.prompts.generator import form_message, message_fit_in
+
+ _, msg = message_fit_in(form_message(system, user), self.chat_mdl.max_length)
+ return msg
+
+ def get_citation_guidelines(self) -> str:
+ """Return the citation guidelines the final answer must follow."""
+ return citation_prompt(self.user_defined_prompts)
+
+ def sys_prompt(self) -> str:
+ """Thin router prompt for callers that bind ``self.tools``.
+
+ The heavy workflow now lives inside the ``rag`` graph, so the outer
+ model only has to decide between answering-with-retrieval (``rag``)
+ and an explicit single-document summary (``summarize_document``).
+ """
+ summarize_line = (
+ "- Call `summarize_document` ONLY when the user explicitly asks to summarise a specific document ('summarise the security audit', 'tldr the onboarding guide'). It needs a document ID.\n"
+ if self.has_unstructured()
+ else ""
+ )
+ return (
+ "You are a smart agent. For any question that needs "
+ "evidence from the knowledge bases or the web, call the `rag` tool "
+ "with a self-contained question — it runs the full search-and-answer "
+ "pipeline and returns a cited answer.\n"
+ "After the `rag` tool returns, do not call `rag` again for the same "
+ "user question. Use the returned cited answer as the final answer "
+ "unless the user explicitly asks a new question.\n"
+ f"{summarize_line}"
+ "Do not invent facts and do not fabricate document IDs."
+ )
+
+ # ------------------------------------------------------------------ #
+ # Graph node helpers (plain async methods — never exposed as tools)
+ # ------------------------------------------------------------------ #
+ async def formalize(self, messages: List[Any]) -> tuple[str, str]:
+ """Rewrite the latest user message into a standalone question AND derive
+ its search keywords (each with close synonyms), in one LLM call.
+
+ ``messages`` may be a list of role dicts (``{"role", "content"}``) or
+ pre-formatted ``"Speaker: text"`` strings.
+
+ Returns ``(question, keywords)`` where ``keywords`` is a comma-separated
+ string of the question's key terms plus 1-2 close synonyms / alternative
+ phrasings for each, in the same language as the question.
+ """
+ if not messages:
+ return "", ""
+
+ lines: list[str] = []
+ last_user = ""
+ for m in messages:
+ if isinstance(m, str):
+ lines.append(m)
+ last_user = m
+ continue
+ role = m.get("role", "user")
+ content = m.get("content", "") or ""
+ if role == "user":
+ last_user = content
+ prefix = "User" if role == "user" else ("Assistant" if role == "assistant" else str(role).capitalize())
+ lines.append(f"{prefix}: {content}")
+ transcript = "\n".join(lines)
+
+ system = (
+ "You are given a conversation. Do BOTH of the following and return JSON only:\n"
+ "1. Rewrite the LAST user message into a single, self-contained question that can be "
+ "understood without seeing the prior conversation — resolve pronouns, ellipses and "
+ "follow-up shortcuts using earlier turns. Preserve the original language of the last "
+ "user message. If it is already a complete standalone question, keep it unchanged.\n"
+ "2. Extract keywords ONLY from the wording of the STANDALONE QUESTION itself — the "
+ "salient content words and phrases that literally appear in it (key nouns, named "
+ "entities, domain terms). Do NOT answer the question, and do NOT include any term that "
+ "would be part of the answer or is not present in the question. Then, for each extracted "
+ "term, you MAY add 1-2 close synonyms or alternative phrasings OF THAT SAME TERM. Output "
+ "them all together as one comma-separated list, in the SAME language as the question.\n"
+ ' Example — question "In which year did Apple acquire Beats?": keywords = '
+ '"Apple, Apple Inc., acquire, acquisition, Beats" (terms from the question + synonyms; '
+ 'the year is the ANSWER, so it must NOT appear).\n\n'
+ 'Output ONLY JSON, no prose, no code fences: '
+ '{"question": "", "keywords": ""}'
+ )
+ user = f"Conversation:\n{transcript}\n\nOutput JSON:"
+ _, msg = message_fit_in(form_message(system, user), self.chat_mdl.max_length)
+ ans = await self.chat_mdl.async_chat(msg[0]["content"], msg[1:], {"temperature": 0.1})
+ if isinstance(ans, tuple):
+ ans = ans[0]
+ cleaned = re.sub(r"^.*", "", ans, flags=re.DOTALL)
+ cleaned = re.sub(r"```(?:json)?\s*|\s*```", "", cleaned).strip()
+ try:
+ data = json_repair.loads(cleaned)
+ except Exception as e:
+ logging.warning(f"formalize could not parse LLM output: {e!r} raw={ans[:200]!r}")
+ data = {}
+ if not isinstance(data, dict):
+ data = {}
+
+ question = str(data.get("question") or "").strip().strip('"').strip("'")
+ if not question:
+ # Fall back to the raw last user message rather than an empty question.
+ question = (last_user or "").strip()
+
+ keywords = data.get("keywords") or ""
+ if isinstance(keywords, list):
+ keywords = ", ".join(str(k).strip() for k in keywords if str(k).strip())
+ keywords = str(keywords).strip()
+ return question, keywords
+
+ async def pick_documents(self, question: str) -> List[str] | None:
+ """Narrow the search to a document subset for ``question``.
+
+ Uses document metadata when the bound KBs carry any (mirrors the old
+ ``filter_docs_by_metadata``); otherwise asks an LLM to pick relevant
+ titles (mirrors the old ``select_documents``). Returns ``None`` when
+ no useful scope can be derived, meaning "search everything".
+ """
+ return None
+ if not self.kb_ids:
+ return None
+
+ metas = await self._get_cached_metas()
+ if metas:
+ ids = await self._filter_by_metadata(question, metas)
+ return ids or None
+
+ ids = await self._select_by_titles(question)
+ return ids or None
+
+ async def _filter_by_metadata(self, question: str, metas: dict) -> List[str]:
+ filters = await gen_meta_filter(self.chat_mdl, metas, question)
+ logging.debug(f"Metadata filter(auto) generated: {filters}")
+ conditions = filters.get("conditions") or []
+ if not conditions:
+ return []
+ logic = filters.get("logic", "and")
+ try:
+ doc_ids = await thread_pool_exec(
+ DocMetadataService.filter_doc_ids_by_meta_pushdown,
+ self.kb_ids,
+ conditions,
+ logic,
+ )
+ except Exception as e:
+ logging.error(f"Metadata filter push down errored: {e}")
+ return []
+ return doc_ids or []
+
+ async def _select_by_titles(self, question: str, max_docs: int = 512) -> List[str]:
+ docs = await thread_pool_exec(self._collect_doc_titles, max_docs)
+ if not docs:
+ return []
+
+ catalogue = "\n".join(f"docID: {doc_id}, title: {title}" for doc_id, title in docs)
+ system = (
+ "You filter a document catalogue to find which documents are relevant "
+ "to a user's question. Use ONLY the titles in the catalogue — do not "
+ "invent docIDs. "
+ "Output ONLY a JSON array of the docIDs you consider relevant, e.g. "
+ '["abc123", "def456"]. If no document is clearly relevant, output []. '
+ "No explanations, no Markdown, no code fences, no prose around the array."
+ )
+ user = f"Question:\n{question}\n\nDocuments:\n{catalogue}\n\nRelevant docIDs (JSON array):"
+ _, msg = message_fit_in(form_message(system, user), self.chat_mdl.max_length)
+ ans = await self.chat_mdl.async_chat(msg[0]["content"], msg[1:], {"temperature": 0.1})
+ if isinstance(ans, tuple):
+ ans = ans[0]
+ cleaned = re.sub(r"^.*", "", ans, flags=re.DOTALL)
+ cleaned = re.sub(r"```(?:json)?\s*|\s*```", "", cleaned).strip()
+ try:
+ ids = json_repair.loads(cleaned)
+ except Exception as e:
+ logging.warning(f"select_by_titles could not parse LLM output: {e!r} raw={ans[:200]!r}")
+ return []
+ if not isinstance(ids, list):
+ return []
+ known = {doc_id for doc_id, _ in docs}
+ return [doc_id for doc_id in ids if isinstance(doc_id, str) and doc_id in known]
+
+ async def extract_keywords(self, question: str) -> str:
+ """Produce a compact keyword string (terms + a few close synonyms).
+
+ Replaces the keywords the outer LLM used to hand to the retrieval
+ tool. Falls back to the question itself when extraction fails.
+ """
+ if not question:
+ return ""
+ system = (
+ "Extract the search terms for a knowledge-base query from the "
+ "question below. Output 3-8 of the most important content terms, "
+ "plus 1-2 close synonyms or alternative phrasings for any ambiguous "
+ "term. Single words or short noun phrases, space-separated, in the "
+ "SAME language as the question. Output ONLY the terms — no labels, "
+ "no punctuation lists, no explanation."
+ )
+ try:
+ _, msg = message_fit_in(form_message(system, question), self.chat_mdl.max_length)
+ ans = await self.chat_mdl.async_chat(msg[0]["content"], msg[1:], {"temperature": 0.2})
+ if isinstance(ans, tuple):
+ ans = ans[0]
+ ans = re.sub(r"^.*", "", ans, flags=re.DOTALL).strip()
+ except Exception:
+ logging.exception("extract_keywords failed")
+ ans = ""
+ return ans or question
+
+ async def retrieve(
+ self,
+ question: str,
+ keywords: str | list = "",
+ doc_scope: List[str] | None = None,
+ top_n: int = 6,
+ similarity_threshold: float = 0.2,
+ using_embedding: bool = False,
+ ) -> dict[str, list]:
+ """Retrieve chunks from the unstructured KBs for one question.
+
+ Returns a raw ``{"chunks": [...], "doc_aggs": [...]}`` dict — no
+ citation stamping, no accumulation onto ``self.kbinfos`` (the graph
+ owns merging so parallel per-question retrieval stays race-free).
+ """
+ if not self.kb_ids:
+ return {"chunks": [], "doc_aggs": []}
+ if isinstance(keywords, list):
+ keywords = ",".join(keywords)
+ logging.info(f"@retrieve: {question}@{keywords}")
+
+ if doc_scope:
+ candidates = [d for d in doc_scope if isinstance(d, str)]
+ known = await thread_pool_exec(self._filter_known_doc_ids, candidates)
+ valid = [d for d in candidates if d in known]
+ if valid:
+ doc_scope = valid
+ else:
+ if candidates:
+ logging.warning("retrieve: every supplied doc ID was unknown; falling back to unfiltered retrieval")
+ doc_scope = None
+
+ search_terms = keywords.strip() if keywords else ""
+ if not search_terms:
+ search_terms = question
+ else:
+ question = question + " " + search_terms
+
+ embd_mdl = self.embed_mdl if using_embedding else None
+ vector_weight = 0.7 if embd_mdl else 0
+ kbinfos = await settings.retriever.retrieval(
+ question,
+ embd_mdl,
+ self.tenant_ids,
+ self.kb_ids,
+ 1,
+ top_n,
+ similarity_threshold,
+ vector_similarity_weight=vector_weight,
+ aggs=True,
+ highlight=True,
+ doc_ids=doc_scope,
+ rank_feature=label_question(question, self.kbs),
+ )
+ if not kbinfos:
+ return {"chunks": [], "doc_aggs": []}
+ kbinfos["chunks"] = settings.retriever.retrieval_by_children(kbinfos.get("chunks", []), self.tenant_ids)
+ return {"chunks": kbinfos.get("chunks", []), "doc_aggs": kbinfos.get("doc_aggs", [])}
+
+ async def web_retrieve(self, query: str) -> dict[str, list]:
+ """Retrieve chunks from the public web (Tavily). Raw kbinfos shape."""
+ if self.tav is None:
+ return {"chunks": [], "doc_aggs": []}
+ try:
+ tav_res = await thread_pool_exec(self.tav.retrieve_chunks, query)
+ except Exception:
+ logging.exception("web_retrieve failed")
+ return {"chunks": [], "doc_aggs": []}
+ return {"chunks": tav_res.get("chunks", []), "doc_aggs": tav_res.get("doc_aggs", [])}
+
+ async def structured_retrieve(self, question: str) -> dict[str, Any]:
+ """Query the structured (tabular) KBs by translating to SQL.
+
+ Returns ``{"answer": str, "chunks": [...], "doc_aggs": [...]}``. The
+ answer is the natural-language SQL result the final node can weave in;
+ the chunks/doc_aggs feed the shared citation pool.
+ """
+ if not self.has_structured():
+ return {"answer": "", "chunks": [], "doc_aggs": []}
+
+ # Lazy import — dialog_service constructs RAGTools.
+ from api.db.services.dialog_service import use_sql
+
+ sql_kb_ids = [kb.id for kb in self.sql_kbs]
+ tenant_id = self.sql_kbs[0].tenant_id
+ try:
+ ans = await use_sql(question, self.field_map, tenant_id, self.chat_mdl, quota=True, kb_ids=sql_kb_ids)
+ except Exception as e:
+ logging.exception(f"structured_retrieve: use_sql failed: {e}")
+ return {"answer": "", "chunks": [], "doc_aggs": []}
+ if not ans:
+ return {"answer": "", "chunks": [], "doc_aggs": []}
+ reference = ans.get("reference") or {}
+ return {
+ "answer": ans.get("answer", "") or "",
+ "chunks": reference.get("chunks") or [],
+ "doc_aggs": reference.get("doc_aggs") or [],
+ }
+
+ def _fit_evidence(self, question: str, evidence_md: str) -> str:
+ """Trim ``evidence_md`` so ``question`` + evidence + the prompt template
+ stay inside the model's context window.
+
+ ``message_fit_in`` keeps the small side (the question) whole and trims
+ the large side (the evidence); we shrink the budget by a reserve so the
+ template skeleton and JSON output rules still fit afterwards.
+ """
+ if not evidence_md:
+ return evidence_md
+ budget = max(256, self.chat_mdl.max_length - _EVIDENCE_PROMPT_RESERVE_TOKENS)
+ _, msg = message_fit_in(form_message(question, evidence_md), budget)
+ return msg[-1]["content"]
+
+ async def judge_sufficiency(self, question: str, evidence_md: str) -> dict:
+ """Judge whether ``evidence_md`` answers ``question`` and pick useful chunks.
+
+ ``evidence_md`` must carry ``ID: n`` markers per chunk (as produced by
+ ``kb_prompt``). Returns the verdict dict:
+ ``{"is_sufficient": bool, "reasoning": str, "missing_information": [...],
+ "useful_chunk_ids": [int, ...]}``.
+ """
+ evidence_md = self._fit_evidence(question, evidence_md)
+ try:
+ return await sufficiency_select(self.chat_mdl, question, evidence_md) or {}
+ except Exception:
+ logging.exception("judge_sufficiency failed")
+ return {}
+
+ async def gen_followups(self, question: str, query: str, missing: List[str], evidence_md: str) -> List[dict]:
+ """Generate complementary follow-up (question, query) pairs for gaps."""
+ evidence_md = self._fit_evidence(question, evidence_md)
+ try:
+ res = await multi_queries_gen(self.chat_mdl, question, query or question, missing or [], evidence_md) or {}
+ except Exception:
+ logging.exception("gen_followups failed")
+ return []
+ qs = res.get("questions") or []
+ return [q for q in qs if isinstance(q, dict) and (q.get("question") or "").strip()]
+
+ async def fetch_full_document(self, doc_id: str) -> dict[str, list]:
+ """Fetch a whole document's chunks in reading order (raw kbinfos)."""
+ if not self.kb_ids:
+ return {"chunks": [], "doc_aggs": []}
+ resolved = await thread_pool_exec(self._resolve_doc_tenant, doc_id)
+ if resolved is None:
+ logging.warning(f"fetch_full_document: doc_id {doc_id!r} not in any bound KB — refusing to fetch")
+ return {"chunks": [], "doc_aggs": []}
+ kb_id, tenant_id = resolved
+
+ cks = []
+ tokens = 0
+ for offset in range(0, 10000, 128):
+ chunks = await thread_pool_exec(
+ settings.retriever.chunk_list,
+ doc_id,
+ tenant_id,
+ [kb_id],
+ max_count=offset + 128,
+ offset=offset,
+ fields=["content_with_weight", "docnm_kwd", "doc_id"],
+ sort_by_position=True,
+ retrieve_all=False,
+ )
+ if not chunks:
+ break
+ for ck in chunks:
+ num = num_tokens_from_string(str(ck["content_with_weight"]))
+ if tokens + num > self.chat_mdl.max_length:
+ break
+ tokens += num
+ cks.append(ck)
+ if not cks:
+ return {"chunks": [], "doc_aggs": []}
+ doc_name = next((c.get("docnm_kwd") or "" for c in cks if c.get("docnm_kwd")), "")
+ return {
+ "chunks": cks,
+ "doc_aggs": [{"doc_name": doc_name, "doc_id": doc_id, "count": len(cks)}],
+ }
+
+ # ------------------------------------------------------------------ #
+ # Bound tools
+ # ------------------------------------------------------------------ #
+ @tool(timeout=600)
+ async def rag(self, question: str) -> str:
+ """Answer a question with evidence from the knowledge bases and the web.
+
+ Runs the full agentic-search pipeline: it formalises the question,
+ narrows the document scope, analyses keywords, retrieves evidence,
+ checks whether the evidence is sufficient (looping with follow-up
+ searches when it is not), and finally composes a cited answer.
+
+ :param question: a self-contained natural-language question.
+
+ :returns: the composed answer with inline citation markers.
+ """
+ from rag.advanced_rag.agentic_rag_graph import run_agentic_rag
+
+ messages = [{"role": "user", "content": question}] if question else []
+ final = ""
+ async for delta in _strip_think_stream(run_agentic_rag(self, messages)):
+ if isinstance(delta, str):
+ final += delta
+ for p, r in [(r"\(\**(ID:\d)\**\)", "[\1]")]:
+ final = re.sub(p, r, final)
+ return final
+
+ @tool
+ async def summarize_document(self, doc_id: str) -> list[str]:
+ """Return a single document's content, position-ordered, ready to summarise.
+
+ Call ONLY for an explicit summary request about a specific document.
+ For general Q&A use the `rag` tool instead.
+
+ :param doc_id: a 32-character lowercase hex document ID that some
+ other tool returned in this turn. Never invent one.
+
+ :returns: formatted chunk blocks (document order) fitting the model's
+ context budget, prefixed with the citation rules to apply.
+ """
+ kbinfos = await self.fetch_full_document(doc_id)
+ if not kbinfos["chunks"]:
+ return []
+ start_idx = len(self.kbinfos.get("chunks", []))
+ self.kbinfos["chunks"].extend(kbinfos["chunks"])
+ self.kbinfos["doc_aggs"].extend(kbinfos["doc_aggs"])
+ blocks = kb_prompt(self.kbinfos, self.chat_mdl.max_length)
+ if not self.do_refer:
+ return blocks[start_idx:] if start_idx else blocks
+ header = "# Citation rules\nApply the following rules VERBATIM to your final answer.\n\n" + citation_prompt(self.user_defined_prompts).strip() + "\n\n----\n\n"
+ return [header] + (blocks[start_idx:] if start_idx else blocks)
+
+ # ------------------------------------------------------------------ #
+ # Low-level DB helpers (sync — wrap in thread_pool_exec at call sites)
+ # ------------------------------------------------------------------ #
+ async def _get_cached_metas(self) -> dict:
+ cached = getattr(self, "_metas_cache", None)
+ if cached is not None:
+ return cached
+ if not self.kb_ids:
+ self._metas_cache = {}
+ return self._metas_cache
+ self._metas_cache = await thread_pool_exec(DocMetadataService.get_flatted_meta_by_kbs, self.kb_ids)
+ return self._metas_cache or {}
+
+ def _collect_doc_titles(self, max_docs: int = 512) -> list[tuple[str, str]] | None:
+ result: list[tuple[str, str]] = []
+ for kb_id in self.kb_ids:
+ for doc in DocumentService.query(kb_id=kb_id):
+ result.append((doc.id, doc.name))
+ if len(result) >= max_docs:
+ return None
+ return result
+
+ def _filter_known_doc_ids(self, candidate_ids: list[str]) -> set[str]:
+ if not candidate_ids or not self.kb_ids:
+ return set()
+ rows = Document.select(Document.id).where((Document.id.in_(list(candidate_ids))) & (Document.kb_id.in_(self.kb_ids)))
+ return {row.id for row in rows}
+
+ def _resolve_doc_tenant(self, doc_id: str) -> tuple[str, str] | None:
+ rows = list(Document.select(Document.kb_id).where((Document.id == doc_id) & (Document.kb_id.in_(self.kb_ids))))
+ if not rows:
+ return None
+ kb_id = rows[0].kb_id
+ for kb in self.kbs:
+ if kb.id == kb_id:
+ return kb_id, kb.tenant_id
+ return None
diff --git a/rag/advanced_rag/agentic_rag_graph.py b/rag/advanced_rag/agentic_rag_graph.py
new file mode 100644
index 0000000000..fb87300cc1
--- /dev/null
+++ b/rag/advanced_rag/agentic_rag_graph.py
@@ -0,0 +1,346 @@
+#
+# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+"""LangGraph agentic-search graph — 4 nodes.
+
+Architecture:
+
+ formalize_question → route → planner → orchestrator_loop → formalize_answer
+
+The ``orchestrator_loop`` node internally dispatches to one of three execution
+strategies based on the thinking mode:
+
+ low: direct_search — single hybrid search, no decomposition
+ medium: decompose_and_search — decompose → parallel search → sufficiency
+ high: agentic_research — two-level loop (orchestrator + research agent)
+ ultra: deep_research — same as high + dynamic claim expansion + replan
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from typing import Any, TypedDict
+
+from langgraph.graph import END, START, StateGraph
+
+from rag.prompts.generator import form_message, kb_prompt, message_fit_in
+
+_LOG = logging.getLogger(__name__)
+
+
+def _snip(value: Any, limit: int = 240) -> str:
+ try:
+ s = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
+ except Exception:
+ s = str(value)
+ s = " ".join(s.split())
+ if len(s) > limit:
+ s = s[:limit] + f"...(+{len(s) - limit} chars)"
+ return s
+
+
+class AgenticState(TypedDict, total=False):
+ messages: list
+ question: str
+ keywords: str # search keywords + close synonyms for the formalized question
+ seed_chunks: list # preliminary hybrid_search chunks used to ground the plan
+ route: dict # RouteDecision serialized
+ plan: dict # WorkflowPlan serialized
+ claims: list # ClaimTarget[] serialized
+ kbinfos: dict # accumulated chunks & doc_aggs
+ verdict: dict # SufficiencyVerdict serialized
+ partial_answer: bool
+ abstain: bool
+ empty_result: bool
+ final_answer: str
+ loop: int
+ feedback: str # replanning feedback
+
+
+# ── Think tag helpers ──
+
+_THINK_OPEN = ""
+_THINK_CLOSE = ""
+
+
+def _partial_tag_tail(s: str, tag: str) -> int:
+ for k in range(min(len(s), len(tag) - 1), 0, -1):
+ if s.endswith(tag[:k]):
+ return k
+ return 0
+
+
+async def _strip_think_stream(stream):
+ """Strip ... spans from a token stream."""
+ buf = ""
+ in_think = False
+ async for token in stream:
+ if not isinstance(token, str):
+ yield token
+ continue
+ buf += token
+ out = []
+ while buf:
+ if not in_think:
+ idx = buf.find(_THINK_OPEN)
+ if idx == -1:
+ hold = _partial_tag_tail(buf, _THINK_OPEN)
+ if hold:
+ out.append(buf[: len(buf) - hold])
+ buf = buf[len(buf) - hold :]
+ else:
+ out.append(buf)
+ buf = ""
+ break
+ out.append(buf[:idx])
+ buf = buf[idx + len(_THINK_OPEN) :]
+ in_think = True
+ else:
+ idx = buf.find(_THINK_CLOSE)
+ if idx != -1:
+ buf = buf[idx + len(_THINK_CLOSE) :]
+ in_think = False
+ continue
+ hold = _partial_tag_tail(buf, _THINK_CLOSE)
+ buf = buf[len(buf) - hold :] if hold else ""
+ break
+ piece = "".join(out)
+ if piece:
+ yield piece
+ if buf and not in_think:
+ yield buf
+
+
+# ── Graph construction ──
+
+
+def _merge_result_into_kbinfos(tools, result: dict) -> None:
+ """Merge a search result's chunks/doc_aggs into ``tools.kbinfos``, deduped.
+
+ Mirrors the orchestrators' merge so seed evidence and orchestrator evidence
+ share one deduplicated pool.
+ """
+ if not result or not result.get("chunks"):
+ return
+ kb = tools.kbinfos
+ seen = {c.get("chunk_id") or c.get("id") or id(c) for c in kb.get("chunks", [])}
+ for c in result.get("chunks", []):
+ k = c.get("chunk_id") or c.get("id") or id(c)
+ if k in seen:
+ continue
+ seen.add(k)
+ kb.setdefault("chunks", []).append(c)
+ dseen = {d.get("doc_id") for d in kb.get("doc_aggs", [])}
+ for d in result.get("doc_aggs", []):
+ if d.get("doc_id") in dseen:
+ continue
+ dseen.add(d.get("doc_id"))
+ kb.setdefault("doc_aggs", []).append(d)
+
+
+def build_agentic_graph(tools, token_queue: asyncio.Queue, gen_conf: dict | None = None):
+ """Compile the 4-node agentic-search graph."""
+ answer_conf = dict(gen_conf) if gen_conf else {"temperature": 0.3}
+
+ # ── Node: formalize_question ──
+ async def formalize_question(state: AgenticState) -> dict:
+ msgs = state.get("messages") or []
+ _LOG.info("[formalize_question] IN | %d msg(s)", len(msgs))
+ q, kw = await tools.formalize(msgs)
+ q = (q or "").strip()
+ kw = (kw or "").strip()
+ _LOG.info("[formalize_question] OUT | question=%s | keywords=%s", _snip(q), _snip(kw))
+ return {
+ "question": q,
+ "keywords": kw,
+ "kbinfos": {"chunks": [], "doc_aggs": []},
+ "loop": 0,
+ "partial_answer": False,
+ "abstain": False,
+ }
+
+ # ── Node: route ──
+ async def route(state: AgenticState) -> dict:
+ from rag.advanced_rag.harness.route import route_node
+
+ return await route_node(state, tools)
+
+ # ── Node: pre_search ──
+ async def pre_search(state: AgenticState) -> dict:
+ """Preliminary hybrid_search to ground the planner's decomposition.
+
+ Only runs for decomposition modes (direct/low mode retrieves in
+ orchestrator_loop anyway, so we skip the duplicate search). The result
+ is narrowed by keywords inside ``hybrid_search`` and merged into the
+ shared citation pool so it also enriches the final answer.
+ """
+ route = state.get("route")
+ if not route or not getattr(route, "requires_decomposition", False):
+ _LOG.info("[pre_search] SKIP | direct/low mode (no decomposition)")
+ return {"seed_chunks": []}
+
+ from rag.advanced_rag.harness.tools.search import hybrid_search
+
+ q = state.get("question", "")
+ kw = state.get("keywords", "")
+ _LOG.info("[pre_search] IN | question=%s | keywords=%s", _snip(q), _snip(kw))
+ try:
+ result = await hybrid_search(tools, query=q, keywords=kw)
+ except Exception:
+ _LOG.exception("[pre_search] hybrid_search failed")
+ return {"seed_chunks": []}
+
+ chunks = result.get("chunks", []) or []
+ _merge_result_into_kbinfos(tools, result)
+ _LOG.info("[pre_search] OUT | %d seed chunk(s), kbinfos now %d", len(chunks), len(tools.kbinfos.get("chunks", [])))
+ return {"seed_chunks": chunks}
+
+ # ── Node: planner ──
+ async def planner(state: AgenticState) -> dict:
+ from rag.advanced_rag.harness.planner import planner_node
+
+ return await planner_node(state, tools)
+
+ # ── Node: orchestrator_loop ──
+ async def orchestrator_loop(state: AgenticState) -> dict:
+ from rag.advanced_rag.harness.orchestrator import orchestrator_loop as _run
+
+ return await _run(state, tools)
+
+ # ── Node: formalize_answer ──
+ async def formalize_answer(state: AgenticState) -> dict:
+ kbinfos = state.get("kbinfos") or {"chunks": [], "doc_aggs": []}
+ question = state.get("question") or ""
+ partial = state.get("partial_answer", False)
+ abstain = state.get("abstain", False)
+ empty_result = state.get("empty_result", False)
+
+ _LOG.info("[formalize_answer] IN | question=%s | chunks=%d | partial=%s | abstain=%s", _snip(question), len(kbinfos["chunks"]), partial, abstain)
+
+ tools.kbinfos = kbinfos
+
+ # Abstain
+ if abstain:
+ msg = "I cannot answer this question based on the available information."
+ token_queue.put_nowait(msg)
+ return {"final_answer": msg}
+
+ # Empty result
+ if empty_result or not kbinfos["chunks"]:
+ msg = "I don't have enough information based on the available sources."
+ token_queue.put_nowait(msg)
+ return {"final_answer": msg}
+
+ # Build evidence
+ evidence = kb_prompt(kbinfos, tools.chat_mdl.max_length)
+ parts = [f"Question:\n{question}\n"]
+
+ # Include pre_summary from agent results if available
+ pre_summary = kbinfos.get("pre_summary")
+ if pre_summary:
+ parts.append(f"Research Summary:\n{pre_summary}\n")
+
+ if partial:
+ from rag.advanced_rag.harness.prompts.report_prompt import PARTIAL_ANSWER_PREAMBLE
+
+ parts.append(f"{PARTIAL_ANSWER_PREAMBLE}\n")
+
+ from rag.advanced_rag.harness.prompts.report_prompt import FINAL_ANSWER_SYSTEM
+ from rag.prompts.generator import citation_prompt as cp
+
+ rules = cp(tools.user_defined_prompts).strip()
+ system = FINAL_ANSWER_SYSTEM.format(cite_rules=rules)
+
+ parts.append(f"Evidence:\n{evidence}")
+ user_content = "\n".join(parts)
+
+ _, msg = message_fit_in(form_message(system, user_content), tools.chat_mdl.max_length)
+ try:
+ async for tok in tools.chat_mdl.async_chat_streamly_delta(msg[0]["content"], msg[1:], answer_conf):
+ token_queue.put_nowait(tok)
+ except Exception:
+ _LOG.exception("formalize_answer: stream failed")
+ token_queue.put_nowait("I'm sorry, I encountered an error while composing the answer.")
+
+ return {"final_answer": ""}
+
+ # ── Build graph ──
+ g = StateGraph(AgenticState)
+ g.add_node("formalize_question", formalize_question)
+ g.add_node("route", route)
+ g.add_node("pre_search", pre_search)
+ g.add_node("planner", planner)
+ g.add_node("orchestrator_loop", orchestrator_loop)
+ g.add_node("formalize_answer", formalize_answer)
+
+ g.add_edge(START, "formalize_question")
+ g.add_edge("formalize_question", "route")
+ g.add_edge("route", "pre_search")
+ g.add_edge("pre_search", "planner")
+ g.add_edge("planner", "orchestrator_loop")
+ g.add_edge("orchestrator_loop", "formalize_answer")
+ g.add_edge("formalize_answer", END)
+
+ return g.compile()
+
+
+# ── Runner ──
+
+
+async def run_agentic_rag(tools, messages: list, max_loops: int = 3, gen_conf: dict | None = None):
+ """Drive the agentic-search graph, yielding answer-token strings."""
+ _LOG.info("[agentic-rag] RUN START | %d message(s), max_loops=%d", len(messages or []), max_loops)
+
+ token_queue: asyncio.Queue = asyncio.Queue()
+ graph = build_agentic_graph(tools, token_queue, gen_conf=gen_conf)
+ _SENTINEL = object()
+ holder: dict[str, Any] = {}
+
+ async def _drive():
+ try:
+ holder["state"] = await graph.ainvoke(
+ {"messages": messages},
+ {"recursion_limit": max(25, max_loops * 8)},
+ )
+ except Exception:
+ logging.exception("run_agentic_rag: graph execution failed")
+ holder["error"] = True
+ finally:
+ token_queue.put_nowait(_SENTINEL)
+
+ task = asyncio.create_task(_drive())
+ produced = False
+ try:
+ while True:
+ item = await token_queue.get()
+ if item is _SENTINEL:
+ break
+ produced = True
+ yield item
+ finally:
+ await task
+
+ state = holder.get("state") or {}
+ final_kb = state.get("kbinfos")
+ if isinstance(final_kb, dict) and final_kb.get("chunks"):
+ tools.kbinfos = final_kb
+
+ _LOG.info("[agentic-rag] RUN END | streamed=%s, loops=%d, chunks=%d", produced, state.get("loop", 0), len((state.get("kbinfos") or {}).get("chunks", [])))
+
+ if not produced and holder.get("error"):
+ yield "I couldn't complete the search due to an internal error."
diff --git a/rag/advanced_rag/harness/__init__.py b/rag/advanced_rag/harness/__init__.py
new file mode 100644
index 0000000000..af1a61737d
--- /dev/null
+++ b/rag/advanced_rag/harness/__init__.py
@@ -0,0 +1 @@
+"""Harness: Agentic RAG orchestration layer."""
diff --git a/rag/advanced_rag/harness/agent.py b/rag/advanced_rag/harness/agent.py
new file mode 100644
index 0000000000..31061e443e
--- /dev/null
+++ b/rag/advanced_rag/harness/agent.py
@@ -0,0 +1,345 @@
+"""Research Agent — inner tool-calling loop for high/ultra modes.
+
+Native tool-calling: a chat model deep-copied from ``tools.chat_mdl`` is bound
+(via ``bind_tools``) to the phase-gated tool schemas plus ``think_tool`` /
+``generate_report``, and a lightweight session routes each tool call to the
+harness pipeline. Binding onto a *copy* keeps the shared ``tools.chat_mdl``
+(used by the other graph nodes) free of any tool schema.
+
+Models without native tool-calling fall back to prompt-based tool selection:
+the tools are described in the prompt and the model emits ```` JSON
+that the loop parses.
+"""
+
+import json
+import logging
+import re
+from copy import deepcopy
+
+from rag.advanced_rag.harness.types import ClaimTarget, ExecutionStrategy, ToolResult
+from rag.advanced_rag.harness.pipeline import Pipeline
+from rag.advanced_rag.harness.tools.gating import (
+ get_gated_tools,
+ determine_current_phase,
+ SEARCH_PHASES,
+)
+from rag.advanced_rag.harness.tools.registry import _generate_report_schema, _think_schema
+from rag.advanced_rag.harness.prompts.research_agent_prompt import (
+ RESEARCH_AGENT_PROMPT,
+ RESEARCH_AGENT_TEXT_PROMPT,
+)
+
+_LOG = logging.getLogger(__name__)
+
+
+class ResearchToolSession:
+ """ToolCallSession adapter routing native tool calls to the harness pipeline.
+
+ - regular tools run through :func:`execute_with_fallback`;
+ - ``think_tool`` is a no-op reasoning step that just lets the loop continue;
+ - ``generate_report`` is *captured* (not executed) so the agent loop can
+ return its structured arguments as the claim result.
+ """
+
+ def __init__(self, pipeline: Pipeline, phase: str):
+ self.pipeline = pipeline
+ self.phase = phase
+ self.report: dict | None = None
+ self.got_evidence = False
+ self.evidence_ids: list[int] = []
+ self._seen_evidence_ids: set[int] = set()
+
+ async def tool_call_async(self, name: str, arguments: dict, request_timeout: float | int = 300):
+ arguments = arguments or {}
+ if name == "generate_report":
+ self.report = self._normalize_report(arguments)
+ return "Report received. Stop calling tools now."
+ if name == "think_tool":
+ return "Noted. Proceed with the next tool call."
+ result = await execute_with_fallback(self.pipeline, name, self.phase, **arguments)
+ if result.chunks:
+ self.got_evidence = True
+ self._record_evidence_ids(result.chunks)
+ return _fmt_tool_result(result)
+
+ def _normalize_report(self, report: dict) -> dict:
+ normalized = dict(report)
+ evidence_ids = []
+ for eid in normalized.get("evidence_ids") or []:
+ try:
+ idx = int(eid)
+ except (TypeError, ValueError):
+ continue
+ if idx not in evidence_ids:
+ evidence_ids.append(idx)
+ if not evidence_ids and self.evidence_ids:
+ evidence_ids = list(self.evidence_ids)
+ normalized["evidence_ids"] = evidence_ids
+ return normalized
+
+ def _record_evidence_ids(self, chunks: list[dict]) -> None:
+ all_chunks = self.pipeline.tools.kbinfos.get("chunks", [])
+ index_by_key = {}
+ for idx, chunk in enumerate(all_chunks):
+ index_by_key[_chunk_key(chunk)] = idx
+
+ for chunk in chunks:
+ idx = index_by_key.get(_chunk_key(chunk))
+ if idx is None:
+ idx = next((i for i, existing in enumerate(all_chunks) if existing is chunk), None)
+ if idx is None or idx in self._seen_evidence_ids:
+ continue
+ self._seen_evidence_ids.add(idx)
+ self.evidence_ids.append(idx)
+
+
+def _chunk_key(chunk: dict) -> object:
+ return chunk.get("chunk_id") or chunk.get("id") or id(chunk)
+
+
+def _build_tool_schemas(gated_defs: list[dict]) -> list[dict]:
+ """Phase-gated schemas (minus harness-only ``x_*`` keys) + the control tools."""
+ schemas: list[dict] = []
+ for d in gated_defs:
+ schemas.append({k: v for k, v in d.items() if not k.startswith("x_")})
+ schemas.append(_think_schema())
+ schemas.append(_generate_report_schema())
+ return schemas
+
+
+async def research_agent_loop(
+ claim: ClaimTarget,
+ tools,
+ pipeline: Pipeline,
+ context,
+ mode: ExecutionStrategy,
+ compilation_map: dict,
+) -> dict:
+ """Inner loop for a single claim — native tool-calling with a text fallback."""
+ phase = determine_current_phase(context)
+ phase_config = SEARCH_PHASES.get(phase, {})
+ gated_defs = get_gated_tools(
+ phase=phase,
+ available_tools=mode.available_tools,
+ compilation_map=compilation_map,
+ context=context,
+ )
+
+ # Deep-copy so binding tools never leaks onto the shared chat model.
+ agent_mdl = deepcopy(tools.chat_mdl)
+ if getattr(agent_mdl, "is_tools", False):
+ return await _research_native(claim, agent_mdl, pipeline, phase, phase_config, gated_defs, mode)
+
+ _LOG.info("research_agent: model lacks native tool support; falling back to text-based tool selection")
+ return await _research_text(claim, tools, pipeline, phase, phase_config, gated_defs, mode)
+
+
+async def _research_native(
+ claim: ClaimTarget,
+ agent_mdl,
+ pipeline: Pipeline,
+ phase: str,
+ phase_config: dict,
+ gated_defs: list[dict],
+ mode: ExecutionStrategy,
+) -> dict:
+ """Bind tools onto ``agent_mdl`` and let its native tool loop drive research."""
+ schemas = _build_tool_schemas(gated_defs)
+ session = ResearchToolSession(pipeline, phase)
+ agent_mdl.bind_tools(session, schemas)
+ # Bound the model's internal tool loop to the mode's agent-cycle budget.
+ if hasattr(agent_mdl, "mdl") and hasattr(agent_mdl.mdl, "max_rounds"):
+ agent_mdl.mdl.max_rounds = max(1, mode.max_agent_cycles)
+
+ system = RESEARCH_AGENT_PROMPT.format(
+ claim_description=claim.description,
+ phase=phase,
+ phase_hint=phase_config.get("tool_hint", ""),
+ max_cycles=mode.max_agent_cycles,
+ )
+ history = [{"role": "user", "content": f"Research task: {claim.description}\nBegin."}]
+
+ final_text = ""
+ try:
+ final_text = await agent_mdl.async_chat(system, history, {"temperature": 0.3})
+ if isinstance(final_text, tuple):
+ final_text = final_text[0]
+ except Exception:
+ _LOG.exception("research_agent(native): tool loop failed")
+
+ if session.report is not None:
+ return session.report
+
+ # The model finished without calling generate_report — synthesize a report
+ # from its final free-text turn so the claim still yields something usable.
+ _LOG.info("research_agent(native): no generate_report call; using final text as report")
+ return {
+ "report": (final_text or "").strip(),
+ "is_verified": session.got_evidence,
+ "confidence": 0.5 if session.got_evidence else 0.0,
+ "evidence_ids": list(session.evidence_ids),
+ "gaps": [] if session.got_evidence else ["no generate_report emitted"],
+ "discovered_claims": [],
+ }
+
+
+async def _research_text(
+ claim: ClaimTarget,
+ tools,
+ pipeline: Pipeline,
+ phase: str,
+ phase_config: dict,
+ gated_defs: list[dict],
+ mode: ExecutionStrategy,
+) -> dict:
+ """Fallback: prompt-based tool selection for models without native tools."""
+ system = RESEARCH_AGENT_TEXT_PROMPT.format(
+ claim_description=claim.description,
+ phase=phase,
+ phase_hint=phase_config.get("tool_hint", ""),
+ tool_list=_fmt_tool_list(gated_defs),
+ max_cycles=mode.max_agent_cycles,
+ )
+
+ history: list[dict] = []
+
+ for cycle in range(mode.max_agent_cycles):
+ try:
+ ans = await tools.chat_mdl.async_chat(system, history, {"temperature": 0.3})
+ if isinstance(ans, tuple):
+ ans = ans[0]
+ except Exception:
+ _LOG.exception("research_agent(text): LLM call failed cycle %d", cycle)
+ continue
+
+ history.append({"role": "assistant", "content": ans})
+
+ tool_call = _parse_tool_call(ans)
+ if not tool_call:
+ history.append({"role": "user", "content": "Please call a tool. Do not output plain text."})
+ continue
+
+ if tool_call.get("name") == "generate_report":
+ return tool_call.get("arguments", {})
+
+ if tool_call.get("name") == "think_tool":
+ history.append({"role": "user", "content": "[continue]"})
+ continue
+
+ args = tool_call.get("arguments", {})
+ result = await execute_with_fallback(pipeline, tool_call["name"], phase, **args)
+ history.append({"role": "user", "content": _fmt_tool_result(result)})
+
+ return await _force_generate_report(history, tools, claim.claim_id)
+
+
+def _parse_tool_call(text: str) -> dict | None:
+ """Parse tool call from LLM response text (text-fallback path)."""
+ m = re.search(r"(.*?)", text, re.DOTALL)
+ if m:
+ try:
+ return json.loads(m.group(1).strip())
+ except Exception:
+ pass
+
+ m = re.search(r"```(?:json)?\s*({.*?})\s*```", text, re.DOTALL)
+ if m:
+ try:
+ return json.loads(m.group(1).strip())
+ except Exception:
+ pass
+
+ m = re.search(r'\{\s*"name"\s*:', text)
+ if m:
+ try:
+ import json_repair
+
+ return json_repair.loads(text)
+ except Exception:
+ pass
+
+ return None
+
+
+async def execute_with_fallback(
+ pipeline: Pipeline,
+ tool_name: str,
+ phase: str,
+ **kwargs,
+) -> ToolResult:
+ """Execute tool; if empty, fall back along phase priority."""
+ result = await pipeline.execute(tool_name, **kwargs)
+
+ if result.chunks or result.error:
+ return result
+
+ phase_config = SEARCH_PHASES.get(phase, {})
+ priority = phase_config.get("tools_priority", [])
+ current_idx = next(
+ (i for i, t in enumerate(priority) if t == tool_name),
+ -1,
+ )
+ for fallback_name in priority[current_idx + 1 :]:
+ fallback_result = await pipeline.execute(fallback_name, **kwargs)
+ if fallback_result.chunks:
+ _LOG.info("fallback: %s empty → %s found %d chunks", tool_name, fallback_name, len(fallback_result.chunks))
+ fallback_result.metadata["was_fallback"] = True
+ fallback_result.metadata["fallback_from"] = tool_name
+ return fallback_result
+ if fallback_result.error:
+ break
+ return result
+
+
+async def _force_generate_report(
+ history: list,
+ tools,
+ claim_id: str,
+) -> dict:
+ """Force generate report when max cycles reached (text-fallback path)."""
+ try:
+ ans = await tools.chat_mdl.async_chat(
+ "",
+ history + [{"role": "user", "content": "We've reached the research limit. Please output a final report as JSON."}],
+ {"temperature": 0.3},
+ )
+ if isinstance(ans, tuple):
+ ans = ans[0]
+ text = re.sub(r"```(?:json)?\s*|\s*```", "", ans).strip()
+ import json_repair
+
+ return json_repair.loads(text)
+ except Exception:
+ _LOG.exception("force_generate_report failed")
+ return {
+ "report": "",
+ "is_verified": False,
+ "confidence": 0.0,
+ "evidence_ids": [],
+ "gaps": ["forced report — data may be incomplete"],
+ "discovered_claims": [],
+ }
+
+
+def _fmt_tool_list(defs: list[dict]) -> str:
+ lines = []
+ for d in defs:
+ func = d.get("function", d)
+ name = func.get("name", "?")
+ desc = func.get("description", "")
+ params = func.get("parameters", {}).get("properties", {})
+ params_text = ", ".join(f"{k}: {v.get('description', '')}" for k, v in params.items())
+ lines.append(f"- {name}: {desc}")
+ if params_text:
+ lines.append(f" Parameters: {params_text}")
+ return "\n".join(lines)
+
+
+def _fmt_tool_result(result: ToolResult) -> str:
+ if result.error:
+ return f"[tool error] {result.error}"
+ chunks = result.chunks[:3]
+ texts = [c.get("content_with_weight", c.get("text", ""))[:300] for c in chunks]
+ if not texts:
+ return "[no results found]"
+ return "\n\n".join(texts)
diff --git a/rag/advanced_rag/harness/config.py b/rag/advanced_rag/harness/config.py
new file mode 100644
index 0000000000..af9ffbd42e
--- /dev/null
+++ b/rag/advanced_rag/harness/config.py
@@ -0,0 +1,103 @@
+"""Thinking mode configurations."""
+
+from rag.advanced_rag.harness.types import ExecutionStrategy
+
+THINKING_MODES: dict[str, ExecutionStrategy] = {
+ "low": ExecutionStrategy(
+ label="low",
+ execution_strategy="direct_search",
+ requires_decomposition=False,
+ requires_agent_loop=False,
+ requires_sufficiency_judge=False,
+ requires_selective_gen=False,
+ allows_dynamic_claims=False,
+ allows_replan=False,
+ max_orchestrator_cycles=1,
+ max_agent_cycles=0,
+ max_parallel_agents=1,
+ available_tools=["hybrid_search"],
+ sufficiency_threshold=0.85,
+ partial_threshold=0.50,
+ fallback_to_direct_llm=False,
+ ),
+ "medium": ExecutionStrategy(
+ label="medium",
+ execution_strategy="decompose_and_search",
+ requires_decomposition=True,
+ requires_agent_loop=False,
+ requires_sufficiency_judge=True,
+ requires_selective_gen=True,
+ allows_dynamic_claims=False,
+ allows_replan=False,
+ max_orchestrator_cycles=3,
+ max_agent_cycles=0,
+ max_parallel_agents=1,
+ available_tools=["hybrid_search"],
+ sufficiency_threshold=0.75,
+ partial_threshold=0.40,
+ fallback_to_direct_llm=False,
+ ),
+ "high": ExecutionStrategy(
+ label="high",
+ execution_strategy="agentic_research",
+ requires_decomposition=True,
+ requires_agent_loop=True,
+ requires_sufficiency_judge=True,
+ requires_selective_gen=True,
+ allows_dynamic_claims=False,
+ allows_replan=False,
+ max_orchestrator_cycles=3,
+ max_agent_cycles=2,
+ max_parallel_agents=2,
+ available_tools=[
+ "hybrid_search",
+ "web_search",
+ "toc_navigate",
+ "page_index_navigate",
+ "graph_explore",
+ "inspector_open_context",
+ "inspector_compare",
+ ],
+ sufficiency_threshold=0.65,
+ partial_threshold=0.30,
+ fallback_to_direct_llm=False,
+ ),
+ "ultra": ExecutionStrategy(
+ label="ultra",
+ execution_strategy="deep_research",
+ requires_decomposition=True,
+ requires_agent_loop=True,
+ requires_sufficiency_judge=True,
+ requires_selective_gen=True,
+ allows_dynamic_claims=True,
+ allows_replan=True,
+ max_orchestrator_cycles=4,
+ max_agent_cycles=2,
+ max_parallel_agents=3,
+ available_tools=[
+ "hybrid_search",
+ "bm25_search",
+ "web_search",
+ "structured_query",
+ "toc_navigate",
+ "page_index_navigate",
+ "mindmap_navigate",
+ "graph_explore",
+ "wiki_query",
+ "inspector_open_context",
+ "inspector_compare",
+ "inspector_grep_within",
+ "inspector_request_adjacent",
+ ],
+ sufficiency_threshold=0.55,
+ partial_threshold=0.20,
+ fallback_to_direct_llm=True,
+ ),
+}
+
+
+def get_mode(label: str) -> ExecutionStrategy:
+ mode = THINKING_MODES.get(label)
+ if not mode:
+ raise ValueError(f"Unknown thinking mode: {label}. Available: {list(THINKING_MODES.keys())}")
+ return mode
diff --git a/rag/advanced_rag/harness/orchestrator/__init__.py b/rag/advanced_rag/harness/orchestrator/__init__.py
new file mode 100644
index 0000000000..66b6e35497
--- /dev/null
+++ b/rag/advanced_rag/harness/orchestrator/__init__.py
@@ -0,0 +1,35 @@
+"""Orchestrator loop — dispatches to execution strategy based on thinking mode."""
+
+import logging
+
+from rag.advanced_rag.harness.config import get_mode
+from rag.advanced_rag.harness.orchestrator.direct import direct_search
+from rag.advanced_rag.harness.orchestrator.decompose import decompose_and_search
+from rag.advanced_rag.harness.orchestrator.agentic import agentic_research
+
+_LOG = logging.getLogger(__name__)
+
+
+async def orchestrator_loop(state: dict, tools) -> dict:
+ """Main orchestrator — dispatch to strategy based on thinking mode."""
+ route = state.get("route")
+ if not route:
+ _LOG.warning("orchestrator: no route, using direct_search")
+ return await direct_search(state, tools)
+
+ mode_label = route.thinking_mode if isinstance(route, dict) else route.thinking_mode
+ mode = get_mode(mode_label)
+
+ _LOG.info("[orchestrator] strategy=%s mode=%s", mode.execution_strategy, mode_label)
+
+ if mode.execution_strategy == "direct_search":
+ return await direct_search(state, tools)
+
+ if mode.execution_strategy == "decompose_and_search":
+ return await decompose_and_search(state, tools)
+
+ if mode.execution_strategy in ("agentic_research", "deep_research"):
+ return await agentic_research(state, tools)
+
+ _LOG.warning("orchestrator: unknown strategy %s, fallback to direct", mode.execution_strategy)
+ return await direct_search(state, tools)
diff --git a/rag/advanced_rag/harness/orchestrator/agentic.py b/rag/advanced_rag/harness/orchestrator/agentic.py
new file mode 100644
index 0000000000..844e0306f8
--- /dev/null
+++ b/rag/advanced_rag/harness/orchestrator/agentic.py
@@ -0,0 +1,241 @@
+"""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] cycle %d/%d, claims: %d unverified", 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] batch start cycle=%d offset=%d size=%d claim_ids=%s",
+ cycle + 1,
+ i,
+ len(batch),
+ [c.claim_id 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] batch done cycle=%d offset=%d size=%d",
+ cycle + 1,
+ i,
+ 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] discovered new claim: %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] cycle=%d verdict=%s score=%.2f action=%s", cycle, verdict.status, verdict.score, 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] claim start id=%s desc=%s", claim.claim_id, _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] claim timeout id=%s timeout=%ss desc=%s",
+ claim.claim_id,
+ CLAIM_RESEARCH_TIMEOUT_SECONDS,
+ _snip(claim.description),
+ )
+ 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] claim failed id=%s desc=%s", claim.claim_id, _snip(claim.description))
+ return {
+ "report": "",
+ "is_verified": False,
+ "confidence": 0.0,
+ "evidence_ids": [],
+ "gaps": ["claim research failed"],
+ "discovered_claims": [],
+ }
+
+ _LOG.info(
+ "[agentic] claim done id=%s verified=%s confidence=%.2f evidence=%d gaps=%d",
+ claim.claim_id,
+ result.get("is_verified", False),
+ float(result.get("confidence") or 0.0),
+ len(result.get("evidence_ids") or []),
+ len(result.get("gaps") or []),
+ )
+ 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")
+ if comps:
+ result[kb.id] = comps
+ return result
diff --git a/rag/advanced_rag/harness/orchestrator/decompose.py b/rag/advanced_rag/harness/orchestrator/decompose.py
new file mode 100644
index 0000000000..ea23a17cf7
--- /dev/null
+++ b/rag/advanced_rag/harness/orchestrator/decompose.py
@@ -0,0 +1,112 @@
+"""Medium mode: decompose → parallel search → sufficiency check."""
+
+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.sufficiency import (
+ cross_check_claim,
+ compute_fusion_score,
+ route_sufficiency_verdict,
+)
+from rag.advanced_rag.harness.tools.search import hybrid_search
+
+_LOG = logging.getLogger(__name__)
+
+
+async def decompose_and_search(state: dict, tools) -> dict:
+ """Decompose → parallel search → merge → sufficiency check → iterate."""
+ question = state.get("question", "")
+ keywords = state.get("keywords", "")
+ claims_raw = state.get("claims", [])
+ mode_label = state.get("route", {}).thinking_mode if state.get("route") else "medium"
+ mode = get_mode(mode_label)
+
+ claims = [ClaimTarget(**c) if isinstance(c, dict) else c for c in claims_raw]
+ ctx = OrchestratorContext(question=question, claims=claims, mode=mode_label)
+
+ for cycle in range(mode.max_orchestrator_cycles):
+ ctx.iteration = cycle
+ unverified = [c for c in ctx.claims if not c.is_verified]
+ if not unverified:
+ break
+
+ # Parallel search on unverified claims
+ tasks = []
+ for c in unverified:
+ tasks.append(hybrid_search(tools, query=c.description, keywords=keywords))
+ results = await asyncio.gather(*tasks)
+
+ for c, result in zip(unverified, results):
+ if result.get("chunks"):
+ c.is_verified = True
+ c.confidence = 0.8
+ c.agent_result = AgentResult(
+ claim_id=c.claim_id,
+ report=_summarize(result),
+ is_verified=True,
+ confidence=0.8,
+ evidence_ids=list(range(len(result.get("chunks", [])))),
+ )
+ _merge_kbinfos(tools, result)
+ else:
+ c.agent_result = AgentResult(
+ claim_id=c.claim_id,
+ report="",
+ is_verified=False,
+ confidence=0.0,
+ )
+
+ all_chunks = {i: c for i, c in enumerate(tools.kbinfos.get("chunks", []))}
+ agent_results = [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]
+
+ verdict = compute_fusion_score(agent_results, cross_results, mode)
+
+ action, should_continue = route_sufficiency_verdict(
+ verdict,
+ mode_label,
+ cycle,
+ mode.max_orchestrator_cycles,
+ )
+
+ if action in ("ANSWER", "ANSWER_PARTIAL"):
+ return {
+ "verdict": verdict.__dict__,
+ "partial_answer": action == "ANSWER_PARTIAL",
+ "kbinfos": tools.kbinfos,
+ }
+ if action == "ABSTAIN":
+ tools.kbinfos["chunks"] = []
+ return {"verdict": verdict.__dict__, "abstain": True}
+
+ return {"kbinfos": tools.kbinfos}
+
+
+def _merge_kbinfos(tools, result: dict):
+ if not result or not result.get("chunks"):
+ return
+ seen = {_chunk_key(c) for c in tools.kbinfos.get("chunks", [])}
+ for c in result.get("chunks", []):
+ k = _chunk_key(c)
+ if k in seen:
+ continue
+ seen.add(k)
+ tools.kbinfos.setdefault("chunks", []).append(c)
+ dseen = {d.get("doc_id") for d in tools.kbinfos.get("doc_aggs", [])}
+ for d in result.get("doc_aggs", []):
+ if d.get("doc_id") in dseen:
+ continue
+ dseen.add(d.get("doc_id"))
+ tools.kbinfos.setdefault("doc_aggs", []).append(d)
+
+
+def _chunk_key(ck: dict) -> str:
+ return ck.get("chunk_id") or ck.get("id") or str(id(ck))
+
+
+def _summarize(result: dict) -> str:
+ chunks = result.get("chunks", [])
+ texts = [c.get("content_with_weight", "")[:200] for c in chunks[:3]]
+ return " | ".join(texts)
diff --git a/rag/advanced_rag/harness/orchestrator/direct.py b/rag/advanced_rag/harness/orchestrator/direct.py
new file mode 100644
index 0000000000..0059771a79
--- /dev/null
+++ b/rag/advanced_rag/harness/orchestrator/direct.py
@@ -0,0 +1,49 @@
+"""Low mode: direct single-pass search."""
+
+import logging
+
+from rag.advanced_rag.harness.tools.search import hybrid_search
+
+_LOG = logging.getLogger(__name__)
+
+
+async def direct_search(state: dict, tools) -> dict:
+ """Single hybrid search → merge into kbinfos."""
+ question = state.get("question", "")
+ keywords = state.get("keywords", "")
+ _LOG.info("[direct_search] question=%s | keywords=%s", question, keywords)
+
+ result = await hybrid_search(tools, query=question, keywords=keywords)
+ _merge_kbinfos(tools, result)
+
+ if not _has_chunks(tools):
+ _LOG.info("[direct_search] no results found")
+ return {"empty_result": True, "kbinfos": tools.kbinfos}
+
+ return {"kbinfos": tools.kbinfos}
+
+
+def _merge_kbinfos(tools, result: dict):
+ if not result or not result.get("chunks"):
+ return
+ seen = {_chunk_key(c) for c in tools.kbinfos.get("chunks", [])}
+ for c in result.get("chunks", []):
+ k = _chunk_key(c)
+ if k in seen:
+ continue
+ seen.add(k)
+ tools.kbinfos.setdefault("chunks", []).append(c)
+ dseen = {d.get("doc_id") for d in tools.kbinfos.get("doc_aggs", [])}
+ for d in result.get("doc_aggs", []):
+ if d.get("doc_id") in dseen:
+ continue
+ dseen.add(d.get("doc_id"))
+ tools.kbinfos.setdefault("doc_aggs", []).append(d)
+
+
+def _chunk_key(ck: dict) -> str:
+ return ck.get("chunk_id") or ck.get("id") or str(id(ck))
+
+
+def _has_chunks(tools) -> bool:
+ return bool(tools.kbinfos.get("chunks"))
diff --git a/rag/advanced_rag/harness/pipeline.py b/rag/advanced_rag/harness/pipeline.py
new file mode 100644
index 0000000000..a099436aea
--- /dev/null
+++ b/rag/advanced_rag/harness/pipeline.py
@@ -0,0 +1,125 @@
+"""Pipeline — unified tool execution dispatcher."""
+
+import time
+import logging
+from typing import Any
+
+from rag.advanced_rag.harness.types import ToolResult
+from rag.advanced_rag.harness.tools.registry import TOOL_REGISTRY
+
+_LOG = logging.getLogger(__name__)
+
+
+class Pipeline:
+ """Unified tool execution layer.
+
+ - execute(tool_name, **kwargs): dispatch to registered tool, normalize result
+ - available_tools(mode_tools): return LLM-visible tool definitions (compilation-filtered)
+ - get_chunks(evidence_ids): retrieve raw chunks for sufficiency cross-check
+ - trace: execution history for auditing
+ """
+
+ def __init__(self, rag_tools, compilation_map: dict[str, set[str]] | None = None):
+ self.tools = rag_tools
+ self.compilation_map = compilation_map or {}
+ self.trace: list[dict] = []
+
+ async def execute(self, tool_name: str, **kwargs) -> ToolResult:
+ """Execute a registered tool by name."""
+ tool = TOOL_REGISTRY.get(tool_name)
+ if not tool:
+ return ToolResult(chunks=[], metadata={}, error=f"Unknown tool: {tool_name}")
+
+ fn = tool.get("fn")
+ if not fn:
+ return ToolResult(chunks=[], metadata={}, error=f"Tool {tool_name} has no executor")
+
+ start = time.time()
+ try:
+ raw = await fn(self.tools, **kwargs)
+ elapsed = time.time() - start
+ self.trace.append({"tool": tool_name, "args": kwargs, "elapsed": elapsed, "success": True})
+ result = self._normalize(raw)
+ # Feed the shared citation pool: agent searches go through the
+ # pipeline, so without this their evidence never reaches kbinfos and
+ # the final answer has nothing to cite.
+ self._merge_into_kbinfos(result)
+ return result
+ except Exception as e:
+ elapsed = time.time() - start
+ _LOG.exception("Pipeline.execute(%s) failed", tool_name)
+ self.trace.append({"tool": tool_name, "args": kwargs, "elapsed": elapsed, "success": False, "error": str(e)})
+ return ToolResult(chunks=[], metadata={}, error=str(e))
+
+ def available_tools(self, mode_tools: list[str]) -> list[dict]:
+ """Return LLM-visible tool definitions, filtered by compilation availability."""
+ names = filter_available_tools(mode_tools, self.compilation_map)
+ defs = []
+ for name in names:
+ tool = TOOL_REGISTRY.get(name)
+ if tool and tool.get("function_schema"):
+ defs.append(tool["function_schema"])
+ return defs
+
+ def get_chunks(self, evidence_ids: list[int]) -> dict[int, dict]:
+ """Retrieve raw chunks by ID from current kbinfos."""
+ result = {}
+ chunks = self.tools.kbinfos.get("chunks", [])
+ for eid in evidence_ids:
+ if 0 <= eid < len(chunks):
+ result[eid] = chunks[eid]
+ return result
+
+ def get_trace(self) -> list[dict]:
+ return list(self.trace)
+
+ # ── Private ──
+
+ def _merge_into_kbinfos(self, result: ToolResult) -> None:
+ """Merge a tool result's chunks/doc_aggs into ``tools.kbinfos``, deduped."""
+ if not result or not result.chunks:
+ return
+ kb = self.tools.kbinfos
+ seen = {c.get("chunk_id") or c.get("id") or id(c) for c in kb.get("chunks", [])}
+ for c in result.chunks:
+ k = c.get("chunk_id") or c.get("id") or id(c)
+ if k in seen:
+ continue
+ seen.add(k)
+ kb.setdefault("chunks", []).append(c)
+ aggs = result.metadata.get("aggs") if isinstance(result.metadata, dict) else None
+ if aggs:
+ dseen = {d.get("doc_id") for d in kb.get("doc_aggs", [])}
+ for d in aggs:
+ if d.get("doc_id") in dseen:
+ continue
+ dseen.add(d.get("doc_id"))
+ kb.setdefault("doc_aggs", []).append(d)
+
+ @staticmethod
+ def _normalize(raw: Any) -> ToolResult:
+ if isinstance(raw, ToolResult):
+ return raw
+ if isinstance(raw, dict):
+ return ToolResult(
+ chunks=raw.get("chunks", []),
+ metadata={"aggs": raw.get("doc_aggs", []), "answer": raw.get("answer", "")},
+ )
+ if isinstance(raw, list):
+ return ToolResult(chunks=raw, metadata={})
+ return ToolResult(chunks=[], metadata={"raw": str(raw)})
+
+
+def filter_available_tools(tool_names: list[str], compilation_map: dict[str, set[str]]) -> list[str]:
+ """Filter tool list by compilation artifact availability."""
+ available = []
+ for name in tool_names:
+ tool = TOOL_REGISTRY.get(name)
+ if not tool:
+ continue
+ if tool.get("requires_compilation"):
+ comp_type = tool.get("compilation_type")
+ if comp_type and not any(comp_type in comps for comps in compilation_map.values()):
+ continue
+ available.append(name)
+ return available
diff --git a/rag/advanced_rag/harness/planner.py b/rag/advanced_rag/harness/planner.py
new file mode 100644
index 0000000000..072db5b739
--- /dev/null
+++ b/rag/advanced_rag/harness/planner.py
@@ -0,0 +1,147 @@
+"""Planner node — question-type-aware claim decomposition."""
+
+import json
+import logging
+import re
+
+from rag.advanced_rag.agentic_rag_graph import _snip
+from rag.advanced_rag.harness.types import ClaimTarget, WorkflowPlan, RouteDecision
+from rag.advanced_rag.harness.config import get_mode
+from rag.advanced_rag.harness.prompts.decompose_prompts import (
+ DECOMPOSE_FACTUAL,
+ DECOMPOSE_COMPARATIVE,
+ DECOMPOSE_PROCEDURAL,
+ DECOMPOSE_EXPLORATORY,
+)
+
+_LOG = logging.getLogger(__name__)
+
+
+def _extract_json(text: str) -> dict:
+ text = re.sub(r"^.*", "", text, flags=re.DOTALL).strip()
+ text = re.sub(r"```(?:json)?\s*|\s*```", "", text).strip()
+ try:
+ import json_repair
+
+ return json_repair.loads(text)
+ except Exception:
+ try:
+ return json.loads(text)
+ except Exception:
+ _LOG.warning("planner: failed to parse LLM output: %s", text[:200])
+ return {}
+
+
+async def planner_node(state: dict, tools) -> dict:
+ """Planner node — decompose question into claims based on question type."""
+ route: RouteDecision = state.get("route")
+ if not route:
+ _LOG.warning("planner: no route found, using defaults")
+ return _default_plan(state.get("question", ""))
+
+ _LOG.info("[Planner] IN | question=%s type=%s", _snip(route.question), route.question_type)
+ if not route.requires_decomposition:
+ # Direct mode: single coarse claim
+ return _direct_plan(route.question)
+
+ # Select decompose prompt by question type
+ prompt_map = {
+ "factual": DECOMPOSE_FACTUAL,
+ "comparative": DECOMPOSE_COMPARATIVE,
+ "procedural": DECOMPOSE_PROCEDURAL,
+ "analytical": DECOMPOSE_EXPLORATORY,
+ "exploratory": DECOMPOSE_EXPLORATORY,
+ }
+ decompose_prompt = prompt_map.get(route.question_type, DECOMPOSE_FACTUAL)
+
+ mode = get_mode(route.thinking_mode)
+ max_claims = _get_max_claims(mode.label)
+ detail_level = _get_detail_level(mode.label)
+ retrieved = _format_seed_chunks(state.get("seed_chunks"), tools)
+
+ try:
+ prompt = decompose_prompt.format(
+ question=route.question,
+ max_claims=max_claims,
+ detail_level=detail_level,
+ retrieved=retrieved,
+ )
+ system, user = prompt.split("Output format", 1)
+ system = system.strip()
+ user = "Output format" + user
+ msg = await tools._fit_messages(system, user)
+ ans = await tools.chat_mdl.async_chat(msg[0]["content"], msg[1:], {"temperature": 0.2})
+ if isinstance(ans, tuple):
+ ans = ans[0]
+ result = _extract_json(ans)
+ except Exception:
+ _LOG.exception("planner_node failed")
+ return _direct_plan(route.question)
+
+ claims_raw = result.get("claims", [])
+ plan_type = {
+ "factual": "fact_decomposition",
+ "comparative": "comparative_decomposition",
+ "procedural": "procedural_decomposition",
+ }.get(route.question_type, "exploratory_decomposition")
+
+ claims = []
+ for i, c in enumerate(claims_raw):
+ if isinstance(c, dict) and c.get("description"):
+ claims.append(
+ ClaimTarget(
+ claim_id=c.get("claim_id", f"c{i}"),
+ description=c["description"],
+ priority=c.get("priority", 0),
+ suggested_tools=c.get("suggested_tools", []),
+ )
+ )
+
+ if not claims:
+ return _direct_plan(route.question)
+
+ plan = WorkflowPlan(
+ plan_type=plan_type,
+ claims=claims,
+ max_iterations=mode.max_orchestrator_cycles,
+ )
+ _LOG.info("[Planner] OUT | plan type=%s | claims=%d", plan_type, len(plan.claims))
+
+ return {"plan": plan, "claims": plan.claims}
+
+
+def _format_seed_chunks(seed_chunks, tools) -> str:
+ """Render preliminary-search chunks as grounding context for the planner."""
+ if not seed_chunks:
+ return "(no preliminary results)"
+ try:
+ from rag.prompts.generator import kb_prompt
+
+ blocks = kb_prompt({"chunks": seed_chunks, "doc_aggs": []}, tools.chat_mdl.max_length)
+ text = "\n".join(blocks).strip()
+ return text or "(no preliminary results)"
+ except Exception:
+ _LOG.exception("planner: failed to format seed chunks")
+ return "(no preliminary results)"
+
+
+def _direct_plan(question: str) -> dict:
+ """Single-claim plan for non-decomposed mode."""
+ plan = WorkflowPlan(
+ plan_type="direct",
+ claims=[ClaimTarget(claim_id="c0", description=question, priority=0)],
+ max_iterations=1,
+ )
+ return {"plan": plan, "claims": plan.claims}
+
+
+def _default_plan(question: str) -> dict:
+ return _direct_plan(question)
+
+
+def _get_max_claims(mode_label: str) -> int:
+ return {"low": 1, "medium": 3, "high": 5, "ultra": 8}.get(mode_label, 3)
+
+
+def _get_detail_level(mode_label: str) -> str:
+ return {"low": "coarse", "medium": "normal", "high": "fine", "ultra": "extra_fine"}.get(mode_label, "normal")
diff --git a/rag/advanced_rag/harness/prompts/__init__.py b/rag/advanced_rag/harness/prompts/__init__.py
new file mode 100644
index 0000000000..2c759e9a86
--- /dev/null
+++ b/rag/advanced_rag/harness/prompts/__init__.py
@@ -0,0 +1 @@
+"""Prompt templates for Agentic RAG harness."""
diff --git a/rag/advanced_rag/harness/prompts/decompose_prompts.py b/rag/advanced_rag/harness/prompts/decompose_prompts.py
new file mode 100644
index 0000000000..db5e15548d
--- /dev/null
+++ b/rag/advanced_rag/harness/prompts/decompose_prompts.py
@@ -0,0 +1,121 @@
+"""Planner decompose prompts: one per question type.
+
+Each prompt is grounded in a preliminary hybrid search: ``{retrieved}`` carries
+the (keyword-narrowed) chunks retrieved for the user's question, so the
+decomposition reflects what the corpus actually contains rather than guessing.
+"""
+
+DECOMPOSE_FACTUAL = """This is a factual question. List all atomic facts that need to be retrieved.
+If there are multiple facts, list them one by one. If there is only one fact, output exactly one item.
+Base the decomposition on BOTH the question and the preliminary retrieved context below.
+
+Question: {question}
+Maximum number of claims: {max_claims}
+Detail level: {detail_level}
+
+Preliminary retrieved context for this question (use it to ground each claim in what the corpus actually contains; do not invent facts it cannot support):
+{retrieved}
+
+Output format (JSON):
+{{
+ "claims": [
+ {{
+ "claim_id": "c1",
+ "description": "The year Apple acquired Beats",
+ "priority": 1
+ }}
+ ]
+}}
+"""
+
+
+DECOMPOSE_COMPARATIVE = """This is a comparative question. It needs to be decomposed into:
+1. Information about entity A for the comparison dimension.
+2. Information about entity B for the comparison dimension.
+3. Optional information that directly compares the two entities.
+Base the decomposition on BOTH the question and the preliminary retrieved context below.
+
+Question: {question}
+Maximum number of claims: {max_claims}
+Detail level: {detail_level}
+
+Preliminary retrieved context for this question (use it to ground each claim in what the corpus actually contains; do not invent facts it cannot support):
+{retrieved}
+
+Output format (JSON):
+{{
+ "claims": [
+ {{
+ "claim_id": "c1",
+ "description": "The distance from Hangzhou to Beijing",
+ "priority": 1
+ }},
+ {{
+ "claim_id": "c2",
+ "description": "The distance from Shanghai to Beijing",
+ "priority": 1
+ }},
+ {{
+ "claim_id": "c3",
+ "description": "Which city is closer to Beijing",
+ "priority": 2
+ }}
+ ]
+}}
+"""
+
+
+DECOMPOSE_PROCEDURAL = """This is a procedural question. Decompose it into the information needed for each step required to complete the operation.
+Base the decomposition on BOTH the question and the preliminary retrieved context below.
+
+Question: {question}
+Maximum number of claims: {max_claims}
+Detail level: {detail_level}
+
+Preliminary retrieved context for this question (use it to ground each claim in what the corpus actually contains; do not invent facts it cannot support):
+{retrieved}
+
+Output format (JSON):
+{{
+ "claims": [
+ {{
+ "claim_id": "c1",
+ "description": "Information needed for the first step",
+ "priority": 1
+ }},
+ {{
+ "claim_id": "c2",
+ "description": "Information needed for the second step",
+ "priority": 2
+ }}
+ ]
+}}
+"""
+
+
+DECOMPOSE_EXPLORATORY = """This is an analytical or exploratory question. Decompose it into the main aspects or dimensions that need to be researched.
+Base the decomposition on BOTH the question and the preliminary retrieved context below.
+
+Question: {question}
+Maximum number of claims: {max_claims}
+Detail level: {detail_level}
+
+Preliminary retrieved context for this question (use it to ground each aspect in what the corpus actually contains; do not invent aspects it cannot support):
+{retrieved}
+
+Output format (JSON):
+{{
+ "claims": [
+ {{
+ "claim_id": "c1",
+ "description": "The first aspect that needs to be researched",
+ "priority": 1
+ }},
+ {{
+ "claim_id": "c2",
+ "description": "The second aspect that needs to be researched",
+ "priority": 2
+ }}
+ ]
+}}
+"""
diff --git a/rag/advanced_rag/harness/prompts/report_prompt.py b/rag/advanced_rag/harness/prompts/report_prompt.py
new file mode 100644
index 0000000000..f6de3d191c
--- /dev/null
+++ b/rag/advanced_rag/harness/prompts/report_prompt.py
@@ -0,0 +1,16 @@
+"""Report synthesis prompts."""
+
+FINAL_ANSWER_SYSTEM = """You are a smart agent. Answer the user's question using ONLY the evidence provided below. Do not invent facts: if the evidence cannot support a claim, say so plainly instead of guessing.
+
+# Citation rules
+{cite_rules}
+
+# Language
+Answer in the SAME language as the question. Translate retrieved evidence into that language as part of composing the answer; only verbatim quoted snippets may stay in their source language.
+
+# Fallback
+If the evidence does not answer the question, reply with a clear statement that you don't have enough information based on the available sources (in the user's language).
+"""
+
+
+PARTIAL_ANSWER_PREAMBLE = "Note: the following answer is based on partial information and may be incomplete."
diff --git a/rag/advanced_rag/harness/prompts/research_agent_prompt.py b/rag/advanced_rag/harness/prompts/research_agent_prompt.py
new file mode 100644
index 0000000000..92d3cb84d4
--- /dev/null
+++ b/rag/advanced_rag/harness/prompts/research_agent_prompt.py
@@ -0,0 +1,58 @@
+"""Research Agent prompts.
+
+``RESEARCH_AGENT_PROMPT`` — native tool-calling: the tool schemas are bound
+ onto the chat model via ``bind_tools``, so the
+ prompt only describes the task, not the tools.
+``RESEARCH_AGENT_TEXT_PROMPT`` — fallback for models without native tool-calling:
+ the tools are described in-prompt and the model
+ emits ```` JSON that the loop parses.
+"""
+
+RESEARCH_AGENT_PROMPT = """You are a research assistant. Investigate the given research task by calling the provided tools.
+
+Research task: {claim_description}
+
+Current phase: {phase}
+Phase hint: {phase_hint}
+
+Rules:
+1. Prefer search / navigation tools to gather evidence for the task.
+2. Use think_tool to analyze results and plan the next step after each search.
+3. When you have gathered enough evidence, call generate_report with your findings
+ (report, is_verified, confidence, evidence_ids, gaps, discovered_claims).
+
+You have at most {max_cycles} tool-calling rounds. Call exactly one tool per round,
+and do not write a plain-text answer until you call generate_report.
+"""
+
+
+RESEARCH_AGENT_TEXT_PROMPT = """You are a research assistant. For the given research task, use the available tools to search for information.
+
+Research task: {claim_description}
+
+Current phase: {phase}
+Phase hint: {phase_hint}
+
+Available tools:
+{tool_list}
+
+Rules:
+1. Prefer search tools to gather information.
+2. Use think_tool to analyze results after each search.
+3. When you are confident enough to answer the research task, call generate_report.
+
+Tool call format: output exactly one JSON tool call per round:
+{{"name": "tool_name", "arguments": {{"parameter_name": "value"}} }}
+
+generate_report argument format:
+{{
+ "report": "Research result report, factual and unformatted",
+ "is_verified": true/false,
+ "confidence": 0.0-1.0,
+ "evidence_ids": [0, 3],
+ "gaps": ["Information that was not found"],
+ "discovered_claims": ["New research directions discovered during research"]
+}}
+
+Maximum {max_cycles} rounds. Output one tag in each round and no other text.
+"""
diff --git a/rag/advanced_rag/harness/prompts/route_prompt.py b/rag/advanced_rag/harness/prompts/route_prompt.py
new file mode 100644
index 0000000000..ea3af15b4e
--- /dev/null
+++ b/rag/advanced_rag/harness/prompts/route_prompt.py
@@ -0,0 +1,19 @@
+"""Route node prompt: classify query type."""
+
+ROUTE_PROMPT = """Analyze the following question and output a structured query analysis.
+
+Question: {question}
+
+Analyze it across these dimensions:
+1. Question type: factual / comparative / analytical / procedural / exploratory / verification / summarization.
+2. Whether it needs decomposition into atomic facts, meaning whether multiple independent pieces of information must be retrieved separately before answering: true/false.
+3. Suggested knowledge compilation tool: null (none) / toc (document table of contents) / graph (knowledge graph) / wiki (compiled domain knowledge).
+
+Output format (JSON):
+{{
+ "question_type": "comparative",
+ "requires_decomposition": true,
+ "suggests_compilation": null,
+ "reasoning": "This is a comparative question, so it needs to be decomposed into two independent facts and one comparison relation."
+}}
+"""
diff --git a/rag/advanced_rag/harness/prompts/sufficiency_prompt.py b/rag/advanced_rag/harness/prompts/sufficiency_prompt.py
new file mode 100644
index 0000000000..a941e62c47
--- /dev/null
+++ b/rag/advanced_rag/harness/prompts/sufficiency_prompt.py
@@ -0,0 +1,31 @@
+"""Sufficiency judge prompt: verdict with claim-level assessment."""
+
+SUFFICIENCY_JUDGE_PROMPT = """You are an expert judge of information retrieval sufficiency. Decide whether the currently collected evidence is sufficient to answer the question.
+
+Question: {question}
+
+Claim-level evidence:
+{evidence_summary}
+
+Judgment tasks:
+1. Evaluate each claim one by one and decide whether it has been sufficiently verified.
+2. Make an overall judgment about whether the evidence is sufficient to answer the user's question.
+3. If it is not sufficient, provide targeted feedback.
+
+Output format (JSON):
+{{
+ "status": "SUFFICIENT" | "USEFUL_BUT_INCOMPLETE" | "INSUFFICIENT" | "UNANSWERABLE",
+ "score": 0.85,
+ "claim_assessments": [
+ {{
+ "claim_id": "c1",
+ "is_verified": true,
+ "confidence": 0.95,
+ "reason": "Consistent data was found in three chunks."
+ }}
+ ],
+ "missing": ["Some data for c2 was not found."],
+ "feedback": "Use web_search for c2 to supplement the latest data.",
+ "overall_reason": "The main facts are covered, but some details still need supplementation."
+}}
+"""
diff --git a/rag/advanced_rag/harness/route.py b/rag/advanced_rag/harness/route.py
new file mode 100644
index 0000000000..d3d4cbb7bf
--- /dev/null
+++ b/rag/advanced_rag/harness/route.py
@@ -0,0 +1,77 @@
+"""Route node — query classification (one-time, no KB dependency)."""
+
+import json
+import logging
+import re
+
+from rag.advanced_rag.harness.types import RouteDecision
+from rag.advanced_rag.harness.config import get_mode
+from rag.advanced_rag.harness.prompts.route_prompt import ROUTE_PROMPT
+
+_LOG = logging.getLogger(__name__)
+
+
+def _extract_json(text: str) -> dict:
+ """Extract JSON from LLM response, handling markdown fences and think tags."""
+ text = re.sub(r"^.*", "", text, flags=re.DOTALL).strip()
+ text = re.sub(r"```(?:json)?\s*|\s*```", "", text).strip()
+ try:
+ import json_repair
+
+ return json_repair.loads(text)
+ except Exception:
+ try:
+ return json.loads(text)
+ except Exception:
+ _LOG.warning("route: failed to parse LLM output: %s", text[:200])
+ return {}
+
+
+async def route_node(state: dict, tools) -> dict:
+ """Route node — analyze the question, produce RouteDecision."""
+ question = state.get("question", "")
+ if not question:
+ return _fallback_route(question)
+
+ mode_label = getattr(tools, "thinking_mode", "medium")
+ mode = get_mode(mode_label)
+
+ try:
+ system = ROUTE_PROMPT.format(question=question)
+ msg = await tools._fit_messages(system, question)
+ ans = await tools.chat_mdl.async_chat(msg[0]["content"], msg[1:], {"temperature": 0.1})
+ if isinstance(ans, tuple):
+ ans = ans[0]
+ result = _extract_json(ans)
+ except Exception:
+ _LOG.exception("route_node failed")
+ result = {}
+
+ question_type = result.get("question_type", "factual")
+ requires_decomp = result.get("requires_decomposition", True)
+ suggests_comp = result.get("suggests_compilation")
+
+ route = RouteDecision(
+ question=question,
+ thinking_mode=mode_label,
+ question_type=question_type,
+ requires_decomposition=mode.requires_decomposition and requires_decomp,
+ suggests_compilation=suggests_comp,
+ execution_strategy=mode.execution_strategy,
+ reasoning=result.get("reasoning", ""),
+ )
+
+ return {"route": route}
+
+
+def _fallback_route(question: str) -> dict:
+ route = RouteDecision(
+ question=question,
+ thinking_mode="medium",
+ question_type="factual",
+ requires_decomposition=False,
+ suggests_compilation=None,
+ execution_strategy="direct_search",
+ reasoning="fallback: empty question",
+ )
+ return {"route": route}
diff --git a/rag/advanced_rag/harness/sufficiency.py b/rag/advanced_rag/harness/sufficiency.py
new file mode 100644
index 0000000000..9dab9d2947
--- /dev/null
+++ b/rag/advanced_rag/harness/sufficiency.py
@@ -0,0 +1,188 @@
+"""Sufficiency check — cross-check + fusion score + 5-way verdict."""
+
+import logging
+
+from rag.advanced_rag.harness.types import (
+ AgentResult,
+ ClaimCrossCheckResult,
+ SufficiencyVerdict,
+ ExecutionStrategy,
+)
+from rag.advanced_rag.harness.config import get_mode
+
+_LOG = logging.getLogger(__name__)
+
+
+# ═══════════════════════════════════════════════════════════════
+# Cross-check: code-only
+# ═══════════════════════════════════════════════════════════════
+
+import re
+
+
+def extract_numbers(text: str) -> list[float]:
+ """Extract numeric values from text."""
+ return [float(m) for m in re.findall(r"\d+\.?\d*", text)]
+
+
+def extract_named_entities(text: str) -> list[str]:
+ """Simple entity extraction — looks for capitalized multi-word sequences."""
+ entities = re.findall(r"\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b", text)
+ return list(set(entities))
+
+
+def cross_check_claim(agent_result: AgentResult, all_chunks: dict) -> ClaimCrossCheckResult:
+ """Code-level cross-check: number matching + entity presence."""
+ report = agent_result.report
+ claimed = agent_result.is_verified
+
+ if not claimed:
+ return ClaimCrossCheckResult(
+ claim_id=agent_result.claim_id,
+ cross_check_passed=False,
+ cross_check_score=0.0,
+ mismatches=["agent self-reported as unverified"],
+ )
+
+ numbers = extract_numbers(report)
+ entities = extract_named_entities(report)
+
+ mismatches = []
+ matches = []
+
+ for eid in agent_result.evidence_ids or []:
+ chunk = all_chunks.get(eid)
+ if not chunk:
+ mismatches.append(f"evidence_id={eid}: chunk not found")
+ continue
+ text = chunk.get("content_with_weight", chunk.get("text", ""))
+ text_lower = text.lower()
+
+ for num in numbers:
+ if str(num) not in text_lower:
+ mismatches.append(f"number {num} not found in chunk {eid}")
+ else:
+ matches.append(f"number {num} found in chunk {eid}")
+
+ for ent in entities:
+ if ent.lower() not in text_lower:
+ mismatches.append(f"entity '{ent}' not found in chunk {eid}")
+
+ total = len(matches) + len(mismatches)
+ cross_score = len(matches) / max(total, 1) if total > 0 else 0.0
+ cross_passed = len(mismatches) < len(matches) * 0.5
+
+ return ClaimCrossCheckResult(
+ claim_id=agent_result.claim_id,
+ cross_check_passed=cross_passed,
+ cross_check_score=cross_score,
+ evidence_matches=matches,
+ mismatches=mismatches,
+ )
+
+
+# ═══════════════════════════════════════════════════════════════
+# Fusion score
+# ═══════════════════════════════════════════════════════════════
+
+
+def compute_fusion_score(
+ agent_results: list[AgentResult],
+ cross_check_results: list[ClaimCrossCheckResult],
+ mode: ExecutionStrategy,
+) -> SufficiencyVerdict:
+ """Dual-signal fusion: agent confidence + cross-check pass rate."""
+ # Signal A: agent self-assessment
+ verified_count = sum(1 for r in agent_results if r.is_verified)
+ agent_score = verified_count / max(len(agent_results), 1)
+
+ # Signal B: cross-check
+ passed_count = sum(1 for r in cross_check_results if r.cross_check_passed)
+ cross_score = passed_count / max(len(cross_check_results), 1)
+
+ # Fusion strategy by mode
+ fusion = {
+ "ultra": lambda a, c: min(a, c),
+ "high": lambda a, c: (a + c) / 2,
+ "medium": lambda a, c: max(a, c),
+ "low": lambda a, c: max(a, c),
+ }.get(mode.label, lambda a, c: max(a, c))
+ fusion_score = fusion(agent_score, cross_score)
+
+ # Conflict detection
+ has_conflicts = any(len(r.mismatches) > 0 for r in cross_check_results)
+
+ # 5-way verdict
+ if has_conflicts and fusion_score < mode.partial_threshold:
+ status = "CONFLICTING"
+ elif fusion_score >= mode.sufficiency_threshold:
+ status = "SUFFICIENT"
+ elif fusion_score >= mode.partial_threshold:
+ status = "USEFUL_BUT_INCOMPLETE"
+ elif not any(r.cross_check_passed for r in cross_check_results):
+ status = "UNANSWERABLE"
+ else:
+ status = "INSUFFICIENT"
+
+ missing = [r.claim_id for r in cross_check_results if not r.cross_check_passed]
+
+ return SufficiencyVerdict(
+ status=status,
+ score=fusion_score,
+ agent_score=agent_score,
+ cross_score=cross_score,
+ claim_assessments=[{"claim_id": r.claim_id, "is_verified": r.cross_check_passed, "score": r.cross_check_score, "mismatches": r.mismatches} for r in cross_check_results],
+ has_conflicts=has_conflicts,
+ missing_claims=missing,
+ feedback=_build_feedback(missing, cross_check_results),
+ overall_reason=_format_reason(status, fusion_score, missing),
+ )
+
+
+# ═══════════════════════════════════════════════════════════════
+# Helpers
+# ═══════════════════════════════════════════════════════════════
+
+
+def _build_feedback(missing: list[str], results: list[ClaimCrossCheckResult]) -> str:
+ if not missing:
+ return "all claims verified"
+ hints = []
+ for r in results:
+ if not r.cross_check_passed:
+ hints.append(f"claim {r.claim_id}: {len(r.mismatches)} mismatch(es)")
+ return "missing: " + "; ".join(hints)
+
+
+def _format_reason(status: str, score: float, missing: list[str]) -> str:
+ return f"{status} score={score:.2f} missing={missing}"
+
+
+def route_sufficiency_verdict(verdict: SufficiencyVerdict, mode_label: str, cycle: int, max_cycles: int) -> tuple:
+ """Return (action, should_continue)."""
+ mode = get_mode(mode_label)
+
+ if verdict.status == "SUFFICIENT":
+ return ("ANSWER", False)
+
+ if verdict.status == "USEFUL_BUT_INCOMPLETE":
+ if mode.requires_selective_gen:
+ return ("ANSWER_PARTIAL", False)
+ return ("CONTINUE", False)
+
+ if verdict.status == "INSUFFICIENT":
+ if cycle >= max_cycles * 0.8:
+ return ("ANSWER_PARTIAL", False)
+ return ("CONTINUE", True)
+
+ if verdict.status == "CONFLICTING":
+ if mode.allows_replan and cycle < max_cycles * 0.5:
+ return ("REPLAN", True)
+ return ("ANSWER_PARTIAL", False)
+
+ if verdict.status == "UNANSWERABLE":
+ if mode.fallback_to_direct_llm:
+ return ("FALLBACK_LLM", False)
+ return ("ABSTAIN", False)
+
+ return ("CONTINUE", True)
diff --git a/rag/advanced_rag/harness/tools/__init__.py b/rag/advanced_rag/harness/tools/__init__.py
new file mode 100644
index 0000000000..5be55f746c
--- /dev/null
+++ b/rag/advanced_rag/harness/tools/__init__.py
@@ -0,0 +1,46 @@
+"""Tool system: register all tools with the registry on import."""
+
+from rag.advanced_rag.harness.tools.registry import register_tool, _search_schema, _navigate_schema, _inspector_schema
+
+# Register tools
+
+# Search tools
+from rag.advanced_rag.harness.tools.search import hybrid_search, vector_search, bm25_search, web_search, structured_query
+
+register_tool("hybrid_search", _search_schema("hybrid_search", "Embedding + Keywords search"), hybrid_search)
+register_tool("vector_search", _search_schema("vector_search", "Embedding search"), vector_search)
+register_tool("bm25_search", _search_schema("bm25_search", "Keywords search"), bm25_search)
+register_tool("web_search", _search_schema("web_search", "Internet search"), web_search)
+register_tool("structured_query", _search_schema("structured_query", "SQL search"), structured_query)
+
+# Navigation tools (require compilation)
+from rag.advanced_rag.harness.tools.navigation import toc_navigate, page_index_navigate, mindmap_navigate
+
+register_tool("toc_navigate", _navigate_schema("toc_navigate", "Navigation with table of content"), toc_navigate, requires_compilation=True, compilation_type="toc")
+register_tool("page_index_navigate", _navigate_schema("page_index_navigate", "Navigation with extracted titles"), page_index_navigate, requires_compilation=True, compilation_type="page_index")
+register_tool("mindmap_navigate", _navigate_schema("mindmap_navigate", "Navigate by mindmap"), mindmap_navigate, requires_compilation=True, compilation_type="mindmap")
+
+# Exploration tools (require compilation)
+from rag.advanced_rag.harness.tools.exploration import graph_explore, wiki_query
+
+register_tool("graph_explore", _search_schema("graph_explore", "Knowledge graph exploration"), graph_explore, requires_compilation=True, compilation_type="knowledge_graph")
+register_tool("wiki_query", _search_schema("wiki_query", "Wiki search"), wiki_query, requires_compilation=True, compilation_type="wiki")
+
+# Inspector tools
+from rag.advanced_rag.harness.tools.inspector import open_context, compare_sources, grep_within, request_adjacent
+
+register_tool(
+ "inspector_open_context",
+ _inspector_schema("open_context", "Expand the original context around a chunk", {"chunk_id": {"type": "string"}, "width": {"type": "integer", "description": "Number of characters to expand"}}),
+ open_context,
+)
+register_tool("inspector_compare", _inspector_schema("compare_sources", "Compare multiple chunks to find common points and contradictions", {"chunk_ids": {"type": "array", "items": {"type": "string"}}}), compare_sources)
+register_tool("inspector_grep_within", _inspector_schema("grep_within", "Search for an exact keyword or pattern within a document", {"doc_id": {"type": "string"}, "pattern": {"type": "string"}}), grep_within)
+register_tool(
+ "inspector_request_adjacent",
+ _inspector_schema("request_adjacent", "Get adjacent entries before or after a chunk", {"chunk_id": {"type": "string"}, "direction": {"type": "string", "enum": ["prev", "next"]}, "count": {"type": "integer"}}),
+ request_adjacent,
+)
+
+# Built-in agent tools
+# (generate_report and think_tool are handled by the agent loop itself, not by Pipeline)
diff --git a/rag/advanced_rag/harness/tools/exploration.py b/rag/advanced_rag/harness/tools/exploration.py
new file mode 100644
index 0000000000..9969b30a6a
--- /dev/null
+++ b/rag/advanced_rag/harness/tools/exploration.py
@@ -0,0 +1,32 @@
+"""Exploration tools: knowledge graph and wiki lookup."""
+
+import logging
+
+_LOG = logging.getLogger(__name__)
+
+
+async def graph_explore(tools, entity: str, relation: str | None = None, depth: int = 1) -> dict:
+ """Explore relationships in the knowledge graph.
+
+ This is currently a placeholder. The final implementation should call the
+ knowledge graph store.
+ """
+ _LOG.info("graph_explore: entity=%s relation=%s depth=%d", entity, relation, depth)
+ # TODO: implement actual KG walk
+ from rag.advanced_rag.harness.tools.search import hybrid_search
+
+ query = f"{entity} {relation or ''}"
+ return await hybrid_search(tools, query=query.strip())
+
+
+async def wiki_query(tools, topic: str) -> dict:
+ """Query compiled wiki knowledge.
+
+ This is currently a placeholder. The final implementation should call the
+ compiled wiki store.
+ """
+ _LOG.info("wiki_query: topic=%s", topic)
+ # TODO: implement actual wiki lookup
+ from rag.advanced_rag.harness.tools.search import hybrid_search
+
+ return await hybrid_search(tools, query=topic)
diff --git a/rag/advanced_rag/harness/tools/gating.py b/rag/advanced_rag/harness/tools/gating.py
new file mode 100644
index 0000000000..22ee3292a7
--- /dev/null
+++ b/rag/advanced_rag/harness/tools/gating.py
@@ -0,0 +1,125 @@
+"""Tool selection gating: phase-based filtering and fallback chain."""
+
+from rag.advanced_rag.harness.types import OrchestratorContext
+from rag.advanced_rag.harness.tools.registry import TOOL_REGISTRY
+
+
+# Search phase definitions
+
+SEARCH_PHASES = {
+ "locate": {
+ "goal": "Locate documents or regions that may contain the answer.",
+ "tools_priority": [
+ "toc_navigate",
+ "mindmap_navigate",
+ "page_index_navigate",
+ "wiki_query",
+ "hybrid_search",
+ "bm25_search",
+ ],
+ "max_returned": 5,
+ "tool_hint": "Prefer navigation tools to locate document regions before directly searching keywords.",
+ },
+ "explore": {
+ "goal": "Explore deeply within the already located region.",
+ "tools_priority": [
+ "hybrid_search",
+ "vector_search",
+ "bm25_search",
+ "graph_explore",
+ "inspector_open_context",
+ "inspector_request_adjacent",
+ ],
+ "max_returned": 4,
+ "tool_hint": "Prefer retrieval tools to gather detailed information within the located region.",
+ },
+ "verify": {
+ "goal": "Verify consistency across multiple sources.",
+ "tools_priority": [
+ "inspector_open_context",
+ "inspector_compare",
+ "inspector_grep_within",
+ "hybrid_search",
+ "web_search",
+ ],
+ "max_returned": 4,
+ "tool_hint": "Prefer inspector tools to compare existing evidence before searching for new content.",
+ },
+ "cross_domain": {
+ "goal": "Explore cross-domain relationships for discovered entities.",
+ "tools_priority": [
+ "graph_explore",
+ "wiki_query",
+ "hybrid_search",
+ "web_search",
+ ],
+ "max_returned": 3,
+ "tool_hint": "Prefer walking the graph to discover cross-domain relationships.",
+ },
+}
+
+
+def compilation_available(tool_name: str, compilation_map: dict) -> bool:
+ """Check if any KB provides the required compilation artifact."""
+ tool = TOOL_REGISTRY.get(tool_name)
+ if not tool or not tool.get("requires_compilation"):
+ return True
+ comp_type = tool["compilation_type"]
+ if not compilation_map:
+ return False
+ return any(comp_type in comps for comps in compilation_map.values())
+
+
+def tool_fits_context(tool_name: str, context: OrchestratorContext) -> bool:
+ """Check if a tool is sensible given current search context."""
+ if tool_name.startswith("inspector_") and not context.has_any_chunks():
+ return False
+ if tool_name == "toc_navigate" and not context.current_claim:
+ return False
+ if tool_name == "graph_explore" and not context.last_entity:
+ return False
+ if tool_name == "mindmap_navigate" and not context.current_claim:
+ return False
+ return True
+
+
+def get_gated_tools(
+ phase: str,
+ available_tools: list[str],
+ compilation_map: dict[str, set[str]],
+ context: OrchestratorContext,
+) -> list[dict]:
+ """Filter, sort, and gate tools by phase priority and context."""
+ phase_config = SEARCH_PHASES.get(phase)
+ if not phase_config:
+ return _default_defs(available_tools)
+
+ sorted_tools = []
+ for tool_name in phase_config["tools_priority"]:
+ if tool_name not in available_tools:
+ continue
+ if not compilation_available(tool_name, compilation_map):
+ continue
+ if not tool_fits_context(tool_name, context):
+ continue
+ sorted_tools.append(tool_name)
+
+ selected = sorted_tools[: phase_config["max_returned"]]
+ defs = [TOOL_REGISTRY[n]["function_schema"] for n in selected if n in TOOL_REGISTRY]
+ for d in defs:
+ d["x_phase"] = phase
+ d["x_phase_hint"] = phase_config["tool_hint"]
+ return defs
+
+
+def _default_defs(tool_names: list[str]) -> list[dict]:
+ return [TOOL_REGISTRY[n]["function_schema"] for n in tool_names if n in TOOL_REGISTRY]
+
+
+def determine_current_phase(context: OrchestratorContext) -> str:
+ """Determine the current search phase based on context."""
+ if not context.has_any_chunks():
+ return "locate"
+ if context.verdict and context.verdict.has_conflicts:
+ return "verify"
+ return "explore"
diff --git a/rag/advanced_rag/harness/tools/inspector.py b/rag/advanced_rag/harness/tools/inspector.py
new file mode 100644
index 0000000000..cc7fcaf067
--- /dev/null
+++ b/rag/advanced_rag/harness/tools/inspector.py
@@ -0,0 +1,87 @@
+"""Inspector tools: operate on already-returned results."""
+
+import logging
+from copy import deepcopy
+
+_LOG = logging.getLogger(__name__)
+
+
+async def open_context(tools, chunk_id: str, width: int = 500) -> dict:
+ """Expand context around a chunk by looking up adjacent chunks in kbinfos."""
+ chunks = tools.kbinfos.get("chunks", [])
+ idx = _find_chunk_index(chunks, chunk_id)
+ if idx is None:
+ return {"chunks": [], "doc_aggs": []}
+ start = max(0, idx - 2)
+ end = min(len(chunks), idx + 2)
+ context = chunks[start:end]
+ _LOG.info("open_context: chunk=%s index=%d context=%d chunks", chunk_id, idx, len(context))
+ return {"chunks": context, "doc_aggs": _collect_doc_aggs(tools, context)}
+
+
+async def compare_sources(tools, chunk_ids: list[str]) -> dict:
+ """Find chunks in kbinfos and list the document sources they come from."""
+ if not chunk_ids:
+ return {"chunks": [], "doc_aggs": []}
+ chunks = tools.kbinfos.get("chunks", [])
+ matched = [c for c in chunks if _chunk_id(c) in chunk_ids]
+ return {"chunks": matched, "doc_aggs": _collect_doc_aggs(tools, matched)}
+
+
+async def grep_within(tools, doc_id: str, pattern: str) -> dict:
+ """Find a keyword within a document and return its chunks narrowed to the
+ matching sentences (+/- 1 neighbour).
+
+ ``pattern`` is treated as the keyword string (comma-separate for several).
+ Delegates to :func:`_narrow_by_keywords`, which keeps only keyword-bearing
+ sentences and drops chunks with no match. Operates on copies so the shared
+ ``kbinfos`` citation pool is never mutated.
+ """
+ from rag.advanced_rag.harness.tools.search import _narrow_by_keywords
+
+ chunks = [deepcopy(c) for c in tools.kbinfos.get("chunks", []) if c.get("doc_id") == doc_id]
+ matched = _narrow_by_keywords(chunks, pattern)
+ return {"chunks": matched, "doc_aggs": _collect_doc_aggs(tools, matched)}
+
+
+async def request_adjacent(tools, chunk_id: str, direction: str = "next", count: int = 3) -> dict:
+ """Get adjacent entries before or after a chunk."""
+ chunks = tools.kbinfos.get("chunks", [])
+ idx = _find_chunk_index(chunks, chunk_id)
+ if idx is None:
+ return {"chunks": [], "doc_aggs": []}
+
+ if direction == "prev":
+ start = max(0, idx - count)
+ end = idx
+ else:
+ start = idx + 1
+ end = min(len(chunks), start + count)
+
+ adjacent = chunks[start:end]
+ return {"chunks": adjacent, "doc_aggs": _collect_doc_aggs(tools, adjacent)}
+
+
+# Helpers
+
+
+def _find_chunk_index(chunks: list[dict], chunk_id: str) -> int | None:
+ for i, c in enumerate(chunks):
+ if _chunk_id(c) == chunk_id:
+ return i
+ return None
+
+
+def _chunk_id(ck: dict) -> str:
+ return str(ck.get("chunk_id") or ck.get("id") or "")
+
+
+def _collect_doc_aggs(tools, chunks: list[dict]) -> list[dict]:
+ seen = set()
+ aggs = []
+ for c in chunks:
+ doc_id = c.get("doc_id")
+ if doc_id and doc_id not in seen:
+ seen.add(doc_id)
+ aggs.append({"doc_id": doc_id, "doc_name": c.get("docnm_kwd", "")})
+ return aggs
diff --git a/rag/advanced_rag/harness/tools/navigation.py b/rag/advanced_rag/harness/tools/navigation.py
new file mode 100644
index 0000000000..bb2ce25a9b
--- /dev/null
+++ b/rag/advanced_rag/harness/tools/navigation.py
@@ -0,0 +1,36 @@
+"""Navigation tools: TOC, page index, and mindmap."""
+
+import logging
+
+_LOG = logging.getLogger(__name__)
+
+
+async def toc_navigate(tools, topic: str, doc_scope: list[str] | None = None) -> dict:
+ """Locate by document table of contents.
+
+ Requires a KB with a compiled TOC artifact. This is currently a placeholder.
+ Replace it with the real implementation when the knowledge compilation TOC
+ tool is available. Falls back to hybrid search for now.
+ """
+ _LOG.info("toc_navigate called for topic=%s", topic)
+ # TODO: replace with actual TOC navigation when compilation layer is ready
+ # For now, fallback to hybrid search
+ from rag.advanced_rag.harness.tools.search import hybrid_search
+
+ return await hybrid_search(tools, query=topic, doc_scope=doc_scope)
+
+
+async def page_index_navigate(tools, topic: str, kb_ids: list[str] | None = None) -> dict:
+ """Navigate by page index."""
+ _LOG.info("page_index_navigate called for topic=%s", topic)
+ from rag.advanced_rag.harness.tools.search import hybrid_search
+
+ return await hybrid_search(tools, query=topic, kb_ids=kb_ids)
+
+
+async def mindmap_navigate(tools, concept: str, kb_ids: list[str] | None = None) -> dict:
+ """Locate by concept mindmap."""
+ _LOG.info("mindmap_navigate called for concept=%s", concept)
+ from rag.advanced_rag.harness.tools.search import hybrid_search
+
+ return await hybrid_search(tools, query=concept, kb_ids=kb_ids)
diff --git a/rag/advanced_rag/harness/tools/registry.py b/rag/advanced_rag/harness/tools/registry.py
new file mode 100644
index 0000000000..072e0b2ba0
--- /dev/null
+++ b/rag/advanced_rag/harness/tools/registry.py
@@ -0,0 +1,141 @@
+"""Tool registry: all available tools with metadata and function schemas."""
+
+from typing import Any
+
+# Tool registry: tool_name -> {metadata, function_schema, fn}
+# 'fn' filled at registration time; schema used for LLM tool definitions.
+TOOL_REGISTRY: dict[str, dict[str, Any]] = {}
+
+# Executor interface
+# Each tool registers a callable with signature:
+# async def fn(tools, **kwargs) -> dict # {"chunks": [...], ...}
+
+
+def register_tool(name: str, schema: dict, fn: callable, requires_compilation: bool = False, compilation_type: str | None = None, processing_time: str = "fast") -> None:
+ TOOL_REGISTRY[name] = {
+ "name": name,
+ "function_schema": schema,
+ "fn": fn,
+ "requires_compilation": requires_compilation,
+ "compilation_type": compilation_type,
+ "processing_time": processing_time,
+ }
+
+
+def get_tool(tool_name: str) -> dict | None:
+ return TOOL_REGISTRY.get(tool_name)
+
+
+def get_function_schemas(tool_names: list[str]) -> list[dict]:
+ """Return function schemas for the given tool names, if registered."""
+ return [TOOL_REGISTRY[n]["function_schema"] for n in tool_names if n in TOOL_REGISTRY]
+
+
+# Common schema builders
+
+def _search_schema(name: str, desc: str) -> dict:
+ return {
+ "type": "function",
+ "function": {
+ "name": name,
+ "description": desc,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "the original user's question."},
+ "keywords": {"type": "string", "description": "the keywords used for searching split by space or ','."},
+ },
+ "required": ["query"],
+ },
+ },
+ }
+
+
+def _navigate_schema(name: str, desc: str) -> dict:
+ return {
+ "type": "function",
+ "function": {
+ "name": name,
+ "description": desc,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "topic": {"type": "string", "description": "navigate by topic"},
+ },
+ "required": ["topic"],
+ },
+ },
+ }
+
+
+def _inspector_schema(name: str, desc: str, props: dict = None) -> dict:
+ schema = {
+ "type": "function",
+ "function": {
+ "name": name,
+ "description": desc,
+ "parameters": {
+ "type": "object",
+ "properties": props
+ or {
+ "chunk_id": {"type": "string", "description": "chunk ID"},
+ },
+ "required": list((props or {"chunk_id": {}}).keys()),
+ },
+ },
+ }
+ return schema
+
+
+def _think_schema() -> dict:
+ return {
+ "type": "function",
+ "function": {
+ "name": "think_tool",
+ "description": "Internal reasoning. Analyze the collected results and plan the next step. Do not output final user-facing content while reasoning.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "reasoning": {
+ "type": "string",
+ "description": "Reasoning content: what has been found, what is still missing, and what to do next.",
+ },
+ },
+ "required": ["reasoning"],
+ },
+ },
+ }
+
+
+def _generate_report_schema() -> dict:
+ return {
+ "type": "function",
+ "function": {
+ "name": "generate_report",
+ "description": "Call when the research is complete. Output the research report and claim-level verification results.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "report": {"type": "string", "description": "Research result report, factual and unformatted."},
+ "is_verified": {"type": "boolean", "description": "Whether sufficient evidence was found."},
+ "confidence": {"type": "number", "description": "Confidence from 0 to 1."},
+ "evidence_ids": {
+ "type": "array",
+ "items": {"type": "integer"},
+ "description": "Referenced chunk IDs.",
+ },
+ "gaps": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Information that was not found.",
+ },
+ "discovered_claims": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "New research directions discovered during research.",
+ },
+ },
+ "required": ["report", "is_verified", "confidence"],
+ },
+ },
+ }
diff --git a/rag/advanced_rag/harness/tools/search.py b/rag/advanced_rag/harness/tools/search.py
new file mode 100644
index 0000000000..4014cf190c
--- /dev/null
+++ b/rag/advanced_rag/harness/tools/search.py
@@ -0,0 +1,289 @@
+"""Search tools: hybrid, vector, BM25, web, structured."""
+
+import logging
+import re
+import hashlib
+from common import settings
+
+_LOG = logging.getLogger(__name__)
+
+
+# Sentence terminators: Chinese 。!?;, English ! ? ;, newline, and a
+# digit-guarded English period (so "3.14" / "v1.2" don't split).
+_SENT_END = re.compile(r"[。!?;!?;]+|(?]*>.*?", re.IGNORECASE | re.DOTALL)
+# Markdown table: a header row with a pipe, a separator row of dashes/colons/
+# pipes, then zero+ body rows with a pipe.
+_MD_TABLE = re.compile(
+ r"^[ \t]*\|?[^\n]*\|[^\n]*\r?\n"
+ r"[ \t]*\|?[ \t]*:?-{1,}:?[ \t]*(?:\|[ \t]*:?-{1,}:?[ \t]*)+\|?[ \t]*\r?\n"
+ r"(?:[ \t]*\|?[^\n]*\|[^\n]*\r?\n?)*",
+ re.MULTILINE,
+)
+
+
+def _protected_spans(text: str) -> list[tuple[int, int]]:
+ """Non-overlapping ``(start, end)`` spans of table blocks, in order."""
+ spans = [(m.start(), m.end()) for m in _HTML_TABLE.finditer(text)]
+ spans += [(m.start(), m.end()) for m in _MD_TABLE.finditer(text)]
+ spans.sort()
+ merged: list[tuple[int, int]] = []
+ last_end = -1
+ for s, e in spans:
+ if s < last_end: # overlaps an already-kept span -> skip
+ continue
+ merged.append((s, e))
+ last_end = e
+ return merged
+
+
+def _split_plain(text: str) -> list[str]:
+ """Terminator-based sentence split, keeping each terminator attached."""
+ sents: list[str] = []
+ start = 0
+ for m in _SENT_END.finditer(text):
+ end = m.end()
+ seg = text[start:end]
+ if seg.strip():
+ sents.append(seg)
+ start = end
+ if start < len(text):
+ tail = text[start:]
+ if tail.strip():
+ sents.append(tail)
+ return sents
+
+
+def _split_sentences(text: str) -> list[str]:
+ """Split ``text`` into sentences, keeping each terminator attached.
+
+ Table blocks — HTML ```` and markdown tables — are treated
+ as a single atomic sentence and are never split internally.
+ """
+ if not text:
+ return []
+ spans = _protected_spans(text)
+ if not spans:
+ return _split_plain(text)
+
+ sents: list[str] = []
+ pos = 0
+ for s, e in spans:
+ if s > pos:
+ sents.extend(_split_plain(text[pos:s]))
+ block = text[s:e]
+ if block.strip():
+ sents.append(block)
+ pos = e
+ if pos < len(text):
+ sents.extend(_split_plain(text[pos:]))
+ return sents
+
+
+def _narrow_content(content: str, kwds: list[str]) -> str | None:
+ """Return ``content`` narrowed to keyword sentences +/- 1 neighbour.
+
+ Returns ``None`` when no keyword occurs anywhere in ``content``.
+ """
+ sents = _split_sentences(content)
+ if not sents:
+ return None
+ keep: set[int] = set()
+ matched = False
+ for i, s in enumerate(sents):
+ low = s.lower()
+ if any(kw in low for kw in kwds):
+ matched = True
+ if i > 0:
+ keep.add(i - 1)
+ keep.add(i)
+ if i + 1 < len(sents):
+ keep.add(i + 1)
+ if not matched:
+ return None
+ narrowed = "".join(sents[i] for i in sorted(keep)).strip()
+ return "..." + _highlight_keywords(narrowed, kwds) + "..."
+
+
+def _highlight_keywords(text: str, kwds: list[str]) -> str:
+ terms = sorted({kw for kw in kwds if kw}, key=len, reverse=True)
+ if not terms:
+ return text
+ pattern = re.compile("|".join(re.escape(term) for term in terms), re.IGNORECASE)
+ return pattern.sub(lambda m: f"{m.group(0)}", text)
+
+
+def _narrow_by_keywords(chunks: list[dict], keywords: str) -> list[dict]:
+ """Narrow each chunk to its keyword-bearing sentences (+/- 1 neighbour) and
+ drop keyword-less chunks.
+
+ Keywords are the comma-separated terms (with close synonyms) produced by
+ ``formalize``; matching is case-insensitive substring.
+ """
+ kwds = [k.strip().lower() for k in (keywords or "").split(",") if k.strip()]
+ if not kwds or not chunks:
+ return chunks
+ if len(kwds) < 3:
+ kwds = [k.strip().lower() for k in (keywords or "").split(" ") if k.strip()]
+ _kwds = []
+ for i in range(len(kwds)-1):
+ _kwds.append(kwds[i] + " "+ kwds[i+1])
+ kwds = _kwds
+
+ scored = [(ck, _narrow_content(ck.get("content_with_weight") or ck.get("content") or "", kwds)) for ck in chunks]
+ out: list[dict] = []
+ dedup: set[str] = set()
+ for ck, nc in scored:
+ if nc is not None:
+ nc_hash = hashlib.md5(nc.encode("utf-8")).hexdigest()
+ if nc_hash in dedup:
+ continue
+ dedup.add(nc_hash)
+ ck["content_with_weight"] = nc
+ if "content" in ck:
+ ck["content"] = nc
+ ck.pop("highlight", None)
+ out.append(ck)
+ return out
+
+
+def _normalize(kbinfos: dict, tenant_ids: list[str] | str | None) -> dict:
+ if not kbinfos:
+ return {"chunks": [], "doc_aggs": []}
+ if not tenant_ids:
+ _LOG.warning("search: skip child retrieval because tenant_ids is empty")
+ return kbinfos
+ if isinstance(tenant_ids, str):
+ tenant_ids = [tenant_ids]
+ kbinfos["chunks"] = settings.retriever.retrieval_by_children(
+ kbinfos.get("chunks", []),
+ tenant_ids,
+ )
+ return kbinfos
+
+
+async def hybrid_search(tools, query: str, kb_ids: list[str] | None = None, top_n: int = 12, doc_scope: list[str] | None = None, keywords: str = "") -> dict:
+ if not tools.kb_ids and not kb_ids:
+ return {"chunks": [], "doc_aggs": []}
+ target_ids = kb_ids or tools.kb_ids
+ _LOG.info(f"[Hybrid search]: {query} -> {keywords}")
+
+ # Query expansion: append the formalized-question keywords + close synonyms
+ # so hybrid/BM25 retrieval gets extra recall signal.
+ effective_query = f"{query} {keywords}".strip() if keywords else query
+
+ embd_mdl = tools.embed_mdl
+ vector_weight = 0.3 if embd_mdl else 0
+
+ kbinfos = await settings.retriever.retrieval(
+ effective_query,
+ embd_mdl,
+ tools.tenant_ids,
+ target_ids,
+ 1,
+ top_n,
+ 0.2,
+ vector_similarity_weight=vector_weight,
+ aggs=True,
+ highlight=False,
+ doc_ids=doc_scope,
+ )
+ kbinfos = _normalize(kbinfos, tools.tenant_ids)
+ if keywords:
+ length = len(kbinfos["chunks"])
+ kbinfos["chunks"] = _narrow_by_keywords(kbinfos.get("chunks", []), keywords)
+ _LOG.info(f"[Hybrid search]({keywords}): snippet {length} -> {len(kbinfos['chunks'])}")
+ return kbinfos
+
+
+async def vector_search(tools, query: str, kb_ids: list[str] | None = None, top_n: int = 12, keywords: str = "") -> dict:
+ if not tools.embed_mdl:
+ _LOG.warning("vector_search: no embed_mdl available")
+ return {"chunks": [], "doc_aggs": []}
+
+ _LOG.info(f"[Vector search]: {query} -> {keywords}")
+ effective_query = f"{query} {keywords}".strip() if keywords else query
+ target_ids = kb_ids or tools.kb_ids
+ kbinfos = await settings.retriever.retrieval(
+ effective_query,
+ tools.embed_mdl,
+ tools.tenant_ids,
+ target_ids,
+ 1,
+ top_n,
+ 0.2,
+ vector_similarity_weight=1.0,
+ aggs=False,
+ highlight=False,
+ )
+ if keywords:
+ length = len(kbinfos["chunks"])
+ kbinfos["chunks"] = _narrow_by_keywords(kbinfos.get("chunks", []), keywords)
+ _LOG.info(f"[Vector search]({keywords}): snippet {length} -> {len(kbinfos['chunks'])}")
+ return _normalize(kbinfos, tools.tenant_ids)
+
+
+async def bm25_search(tools, query: str, kb_ids: list[str] | None = None, top_n: int = 12, keywords: str = "") -> dict:
+ _LOG.info(f"[BM25 search]: {query} -> {keywords}")
+ target_ids = kb_ids or tools.kb_ids
+ effective_query = f"{query} {keywords}".strip() if keywords else query
+ kbinfos = await settings.retriever.retrieval(
+ effective_query,
+ None,
+ tools.tenant_ids,
+ target_ids,
+ 1,
+ top_n,
+ 0.0,
+ vector_similarity_weight=0,
+ aggs=False,
+ highlight=False,
+ )
+ if keywords:
+ length = len(kbinfos["chunks"])
+ kbinfos["chunks"] = _narrow_by_keywords(kbinfos.get("chunks", []), keywords)
+ _LOG.info(f"[BM25 search]({keywords}): snippet {length} -> {len(kbinfos['chunks'])}")
+ return _normalize(kbinfos, tools.tenant_ids)
+
+
+async def web_search(tools, query: str, keywords: str = "") -> dict:
+ if not tools.has_web():
+ return {"chunks": [], "doc_aggs": []}
+
+ _LOG.info(f"[Web search]: {query} -> {keywords}")
+ try:
+ from common.misc_utils import thread_pool_exec
+
+ effective_query = f"{query} {keywords}".strip() if keywords else query
+ tav_res = await thread_pool_exec(tools.tav.retrieve_chunks, effective_query)
+ return {"chunks": tav_res.get("chunks", []), "doc_aggs": tav_res.get("doc_aggs", [])}
+ except Exception:
+ _LOG.exception("web_search failed")
+ return {"chunks": [], "doc_aggs": []}
+
+
+async def structured_query(tools, question: str, kb_ids: list[str] | None = None) -> dict:
+ _LOG.info(f"[Structured search]: {question}")
+ sql_kbs = [kb for kb in tools.sql_kbs if kb_ids is None or kb.id in kb_ids]
+ if not sql_kbs:
+ return {"answer": "", "chunks": [], "doc_aggs": []}
+ from api.db.services.dialog_service import use_sql
+
+ tenant_id = sql_kbs[0].tenant_id
+ sql_kb_ids = [kb.id for kb in sql_kbs]
+ try:
+ ans = await use_sql(question, tools.field_map, tenant_id, tools.chat_mdl, quota=True, kb_ids=sql_kb_ids)
+ except Exception:
+ _LOG.exception("structured_query failed")
+ return {"answer": "", "chunks": [], "doc_aggs": []}
+ if not ans:
+ return {"answer": "", "chunks": [], "doc_aggs": []}
+ ref = ans.get("reference") or {}
+ return {
+ "answer": ans.get("answer", "") or "",
+ "chunks": ref.get("chunks") or [],
+ "doc_aggs": ref.get("doc_aggs") or [],
+ }
diff --git a/rag/advanced_rag/harness/types.py b/rag/advanced_rag/harness/types.py
new file mode 100644
index 0000000000..3bd2328b39
--- /dev/null
+++ b/rag/advanced_rag/harness/types.py
@@ -0,0 +1,162 @@
+"""Data types for Agentic RAG harness."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Literal
+
+
+# ═══════════════════════════════════════════════════════════════
+# Route
+# ═══════════════════════════════════════════════════════════════
+
+
+@dataclass
+class RouteDecision:
+ question: str
+ thinking_mode: str
+ question_type: str # factual | comparative | analytical | procedural | exploratory | verification | summarization
+ requires_decomposition: bool
+ suggests_compilation: str | None
+ execution_strategy: str
+ reasoning: str = ""
+
+
+# ═══════════════════════════════════════════════════════════════
+# Thinking Mode
+# ═══════════════════════════════════════════════════════════════
+
+
+@dataclass
+class ExecutionStrategy:
+ label: Literal["low", "medium", "high", "ultra"]
+ execution_strategy: Literal["direct_search", "decompose_and_search", "agentic_research", "deep_research"]
+ requires_decomposition: bool
+ requires_agent_loop: bool
+ requires_sufficiency_judge: bool
+ requires_selective_gen: bool
+ allows_dynamic_claims: bool
+ allows_replan: bool
+ max_orchestrator_cycles: int
+ max_agent_cycles: int
+ max_parallel_agents: int
+ available_tools: list[str]
+ sufficiency_threshold: float
+ partial_threshold: float
+ fallback_to_direct_llm: bool
+
+
+# ═══════════════════════════════════════════════════════════════
+# Plan & Claims
+# ═══════════════════════════════════════════════════════════════
+
+
+@dataclass
+class ClaimTarget:
+ claim_id: str
+ description: str
+ priority: int = 0
+ is_verified: bool = False
+ confidence: float = 0.0
+ suggested_tools: list[str] = field(default_factory=list)
+ agent_result: dict | None = None
+
+
+@dataclass
+class WorkflowPlan:
+ plan_type: str # direct | fact_decomposition | comparative_decomposition | procedural_decomposition | exploratory_decomposition
+ claims: list[ClaimTarget]
+ max_iterations: int
+
+
+# ═══════════════════════════════════════════════════════════════
+# Agent Result
+# ═══════════════════════════════════════════════════════════════
+
+
+@dataclass
+class AgentResult:
+ claim_id: str
+ report: str
+ is_verified: bool
+ confidence: float
+ evidence_ids: list[int] = field(default_factory=list)
+ gaps: list[str] = field(default_factory=list)
+ discovered_claims: list[str] = field(default_factory=list)
+
+
+# ═══════════════════════════════════════════════════════════════
+# Cross Check
+# ═══════════════════════════════════════════════════════════════
+
+
+@dataclass
+class ClaimCrossCheckResult:
+ claim_id: str
+ cross_check_passed: bool
+ cross_check_score: float
+ evidence_matches: list[str] = field(default_factory=list)
+ mismatches: list[str] = field(default_factory=list)
+
+
+# ═══════════════════════════════════════════════════════════════
+# Sufficiency Verdict
+# ═══════════════════════════════════════════════════════════════
+
+
+@dataclass
+class SufficiencyVerdict:
+ status: str # SUFFICIENT | USEFUL_BUT_INCOMPLETE | INSUFFICIENT | CONFLICTING | UNANSWERABLE
+ score: float
+ agent_score: float
+ cross_score: float
+ claim_assessments: list[dict]
+ has_conflicts: bool
+ missing_claims: list[str]
+ feedback: str
+ overall_reason: str
+
+
+# ═══════════════════════════════════════════════════════════════
+# Pipeline
+# ═══════════════════════════════════════════════════════════════
+
+
+@dataclass
+class ToolResult:
+ chunks: list[dict]
+ metadata: dict = field(default_factory=dict)
+ error: str | None = None
+
+
+# ═══════════════════════════════════════════════════════════════
+# Orchestrator Context
+# ═══════════════════════════════════════════════════════════════
+
+
+@dataclass
+class OrchestratorContext:
+ question: str
+ claims: list[ClaimTarget]
+ mode: str
+ iteration: int = 0
+ current_phase: str = "locate"
+ agent_results: dict[str, Any] = field(default_factory=dict)
+ verdict: SufficiencyVerdict | None = None
+ history: list[dict] = field(default_factory=list)
+ _last_entity: str | None = None
+
+ @property
+ def last_entity(self) -> str | None:
+ return self._last_entity
+
+ @property
+ def current_claim(self) -> str | None:
+ unverified = [c for c in self.claims if not c.is_verified]
+ return unverified[0].description if unverified else None
+
+ def has_any_chunks(self) -> bool:
+ return any(r.get("evidence_ids") for r in self.agent_results.values())
+
+ def record_fallback(self, tool_name: str, fallback_from: str | None = None):
+ pass
diff --git a/rag/advanced_rag/think_log.py b/rag/advanced_rag/think_log.py
new file mode 100644
index 0000000000..87790501b3
--- /dev/null
+++ b/rag/advanced_rag/think_log.py
@@ -0,0 +1,94 @@
+#
+# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+"""Surface selected internal INFO logs to the client as ```` content.
+
+During an agentic ``rag_agent`` turn we attach a context-scoped logging sink so
+the pipeline's bracket-tagged progress logs — ``[agentic-rag]``,
+``[formalize_question]``, ``[pre_search]``, ``[Planner]``, ``[orchestrator]``,
+``[agentic]``, ``[Hybrid search]``, ``[BM25 search]``, ``[Web search]``,
+``[ToolLoop]``, ``[FunctionTool]`` … — can be streamed to the front end as
+reasoning without instrumenting every call site.
+
+The sink is stored in a :class:`contextvars.ContextVar`, so only the async task
+tree of the current request (which inherits the context) forwards its logs —
+concurrent requests stay isolated. Logs emitted from ``thread_pool_exec``
+workers that did not inherit the context are simply skipped.
+"""
+
+from __future__ import annotations
+
+import contextvars
+import logging
+from typing import Callable
+
+# Per-request sink: a callable(str) that forwards one log line, or None when no
+# agentic turn is streaming in the current context.
+_think_log_sink: contextvars.ContextVar[Callable[[str], None] | None] = contextvars.ContextVar(
+ "think_log_sink", default=None
+)
+
+# Only bracket-tagged INFO lines from these logger namespaces are surfaced.
+_SCOPED_PREFIXES = ("rag.advanced_rag", "rag.llm.chat_model", "rag.llm.tool_decorator")
+
+_installed = False
+
+
+class ThinkLogHandler(logging.Handler):
+ """Forward in-scope, bracket-tagged records to the active per-request sink."""
+
+ def emit(self, record: logging.LogRecord) -> None:
+ sink = _think_log_sink.get()
+ if sink is None:
+ return
+ name = record.name or ""
+ if not name.startswith(_SCOPED_PREFIXES):
+ return
+ try:
+ msg = record.getMessage()
+ except Exception:
+ return
+ # Only the bracket-tagged progress lines ("[Hybrid search] ...").
+ if not msg or not msg.lstrip().startswith("["):
+ return
+ try:
+ sink("
"+msg.strip())
+ except Exception:
+ # Never let think-log forwarding break the request or the logging
+ # subsystem itself.
+ pass
+
+
+def install_think_log_handler() -> None:
+ """Install the forwarding handler on the root logger exactly once."""
+ global _installed
+ if _installed:
+ return
+ handler = ThinkLogHandler(level=logging.INFO)
+ logging.getLogger().addHandler(handler)
+ _installed = True
+
+
+def set_think_log_sink(sink: Callable[[str], None] | None):
+ """Activate ``sink`` for the current context; returns the reset token."""
+ return _think_log_sink.set(sink)
+
+
+def reset_think_log_sink(token) -> None:
+ try:
+ _think_log_sink.reset(token)
+ except Exception:
+ pass
diff --git a/rag/llm/chat_model.py b/rag/llm/chat_model.py
index 55a81d414a..9a9161a416 100644
--- a/rag/llm/chat_model.py
+++ b/rag/llm/chat_model.py
@@ -679,8 +679,22 @@ class Base(ABC):
args = json_repair.loads(tc.function.arguments)
except Exception:
args = {}
- yield self._verbose_tool_use(tc.function.name, args, "Begin to call...")
+ yield f"Executing {tc.function.name} with args: {tc.function.arguments}"
results = await asyncio.gather(*[_exec_tool(tc) for tc in tcs])
+
+ # Terminal-tool short-circuit: stream a terminal tool's
+ # result (already the final answer) and stop the loop.
+ _terminal = getattr(self, "terminal_tools", None)
+ if _terminal:
+ for tc, name, args, result, err in results:
+ if name in _terminal and not err:
+ logging.info(f"[ToolLoop] terminal tool {name!r} called; streaming result and stopping")
+ out = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
+ if out:
+ yield out
+ yield total_tokens
+ return
+
history = self._append_history_batch(history, results)
for tc, name, args, result, err in results:
yield self._verbose_tool_use(name, args, err if err else result)
@@ -1925,7 +1939,7 @@ class LiteLLMBase(ABC):
history = deepcopy(hist)
try:
for _ in range(self.max_rounds + 1):
- logging.info(f"{self.tools=}")
+ logging.info(f"HAS TOOL:{len(self.tools)}\n{history=}")
completion_args = self._construct_completion_args(history=history, stream=False, tools=True, **gen_conf)
response = await litellm.acompletion(
@@ -2122,8 +2136,23 @@ class LiteLLMBase(ABC):
args = json_repair.loads(tc.function.arguments)
except Exception:
args = {}
- yield self._verbose_tool_use(tc.function.name, args, "Begin to call...")
+ yield f"Executing {tc.function.name} with args: {tc.function.arguments}"
results = await asyncio.gather(*[_exec_tool(tc) for tc in tcs])
+
+ # Terminal-tool short-circuit: a terminal tool already
+ # produces the final answer, so stream its result and stop
+ # instead of feeding it back for another LLM round.
+ _terminal = getattr(self, "terminal_tools", None)
+ if _terminal:
+ for tc, name, args, result, err in results:
+ if name in _terminal and not err:
+ logging.info(f"[ToolLoop] terminal tool {name!r} called; streaming result and stopping")
+ out = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
+ if out:
+ yield out
+ yield total_tokens
+ return
+
history = self._append_history_batch(
history,
results,
diff --git a/rag/llm/tool_decorator.py b/rag/llm/tool_decorator.py
index 2870f94ed8..3f1a421eb0 100644
--- a/rag/llm/tool_decorator.py
+++ b/rag/llm/tool_decorator.py
@@ -91,14 +91,33 @@ def _py_type_to_json(py_type: Any) -> dict[str, Any]:
return {"type": "string"}
-_PARAM_RE = re.compile(r"^\s*:param\s+(?P\w+)\s*:\s*(?P.+?)\s*$")
+_PARAM_RE = re.compile(r"^\s*:param\s+(?P\w+)\s*:\s*(?P.*?)\s*$")
+_GOOGLE_ARGS_HDR_RE = re.compile(r"^(Args|Arguments|Parameters)\s*:\s*$")
+_GOOGLE_SECTION_HDR_RE = re.compile(
+ r"^(Returns?|Yields?|Raises|Notes?|Examples?|Attributes?|Todo|See Also|Warning|Warnings|Tip)\s*:\s*$"
+)
+# Google-style parameter line: leading indent, identifier, optional ``(type)``,
+# then ``: description``. The description can be empty (continuation lines fill it).
+_GOOGLE_PARAM_RE = re.compile(
+ r"^(?P\s+)(?P\w+)\s*(?:\([^)]*\))?\s*:\s*(?P.*)$"
+)
def _parse_param_docs(docstring: str | None) -> tuple[str, dict[str, str]]:
- """Pull a short function description and ``:param name:`` lines out of a docstring.
+ """Pull a function description and per-parameter descriptions out of a docstring.
- Intentionally minimal — Google/NumPy styles are not parsed. Anything
- before the first ``:param`` line becomes the function description.
+ Recognises two conventions and handles multi-line descriptions in both:
+
+ * **reST / Sphinx**: ``:param name: description`` followed by deeper-indented
+ continuation lines.
+ * **Google**: an ``Args:`` (or ``Arguments:`` / ``Parameters:``) section
+ whose body is `` name: description`` lines, with deeper-indented
+ continuation lines folded onto the same entry. Other Google sections
+ (``Returns:``, ``Raises:``, ...) terminate the description but are
+ otherwise dropped — they aren't sent to the LLM.
+
+ Both styles can co-exist in one docstring. Anything before the first
+ parameter entry / section header becomes the function description.
"""
if not docstring:
return "", {}
@@ -106,12 +125,65 @@ def _parse_param_docs(docstring: str | None) -> tuple[str, dict[str, str]]:
lines = inspect.cleandoc(docstring).splitlines()
desc_lines: list[str] = []
param_docs: dict[str, str] = {}
+ state = "desc" # "desc" | "rst_param" | "google_args" | "other_section"
+ current_param: str | None = None
+ current_indent = 0
+ after_first_param = False
+
+ def _append_continuation(name: str, text: str) -> None:
+ param_docs[name] = (param_docs[name] + " " + text).strip() if param_docs.get(name) else text
+
for line in lines:
+ stripped = line.strip()
+ line_indent = len(line) - len(line.lstrip())
+
+ # reST :param: line — works in any state, resets it.
m = _PARAM_RE.match(line)
if m:
- param_docs[m.group("name")] = m.group("desc")
- elif not param_docs:
+ current_param = m.group("name")
+ current_indent = line_indent
+ param_docs[current_param] = m.group("desc").strip()
+ state = "rst_param"
+ after_first_param = True
+ continue
+
+ # Google section headers.
+ if _GOOGLE_ARGS_HDR_RE.match(stripped):
+ state = "google_args"
+ current_param = None
+ after_first_param = True
+ continue
+ if _GOOGLE_SECTION_HDR_RE.match(stripped):
+ state = "other_section"
+ current_param = None
+ after_first_param = True
+ continue
+
+ # Google `` name: desc`` entry inside an Args block.
+ if state == "google_args":
+ gm = _GOOGLE_PARAM_RE.match(line)
+ if gm:
+ current_param = gm.group("name")
+ current_indent = line_indent
+ param_docs[current_param] = gm.group("desc").strip()
+ continue
+
+ # Continuation line for the most recent reST or Google param.
+ if state in ("rst_param", "google_args") and current_param and stripped:
+ if line_indent > current_indent:
+ _append_continuation(current_param, stripped)
+ continue
+
+ # Blank line ends the current param's continuation but stays in-state.
+ if not stripped:
+ current_param = None
+ continue
+
+ # Lines outside any param block, before the first param/section,
+ # accumulate as the function description.
+ if not after_first_param:
desc_lines.append(line)
+
return "\n".join(desc_lines).strip(), param_docs
@@ -153,19 +225,58 @@ def _build_openai_schema(fn: Callable[..., Any]) -> dict[str, Any]:
}
-def tool(fn: Callable[..., Any]) -> Callable[..., Any]:
+# Sentinel separating "caller did not pass a timeout" from "caller passed None
+# (= run forever)". Plain ``None`` is a legal value for the kwarg.
+_TIMEOUT_UNSET: Any = object()
+
+
+def tool(
+ fn: Callable[..., Any] | None = None,
+ *,
+ timeout: float | int | None = _TIMEOUT_UNSET,
+) -> Callable[..., Any]:
"""Mark ``fn`` as an LLM tool and attach an OpenAI-format schema to it.
- The wrapped callable is the same callable — we only set two attributes:
+ Usable in two styles:
+
+ * Bare: ``@tool`` — no per-tool timeout; the session
+ falls back to its caller-supplied
+ ``request_timeout`` (default 10s).
+ * Parameterised: ``@tool(timeout=60)`` — 60s timeout, overrides the
+ session's default for this tool.
+ Pass ``timeout=None`` to disable
+ the timeout entirely (the tool
+ runs until it completes).
+
+ The wrapped callable is the same callable — we only set attributes on it:
* ``fn._is_tool = True`` — sentinel so :meth:`Base.bind_tools` can tell a
``@tool`` callable apart from a raw schema dict.
* ``fn.openai_schema`` — the schema dict passed verbatim to the LLM
provider in the ``tools=[...]`` request field.
+ * ``fn._tool_timeout`` (only when ``timeout=`` was passed) — read by
+ :class:`FunctionToolSession` to override its default timeout for this
+ tool. May be ``None`` to mean "no timeout".
"""
- fn.openai_schema = _build_openai_schema(fn) # type: ignore[attr-defined]
- fn._is_tool = True # type: ignore[attr-defined]
- return fn
+
+ def decorate(f: Callable[..., Any]) -> Callable[..., Any]:
+ f.openai_schema = _build_openai_schema(f) # type: ignore[attr-defined]
+ f._is_tool = True # type: ignore[attr-defined]
+ if timeout is not _TIMEOUT_UNSET:
+ f._tool_timeout = timeout # type: ignore[attr-defined]
+ return f
+
+ # ``@tool`` (no parens) — ``fn`` is the function being decorated.
+ if fn is not None:
+ if not callable(fn):
+ raise TypeError(
+ "@tool used incorrectly. Use `@tool` or `@tool(timeout=N)`; "
+ f"got first positional argument of type {type(fn).__name__}."
+ )
+ return decorate(fn)
+
+ # ``@tool(timeout=N)`` — return the decorator that will receive the function.
+ return decorate
def is_tool(obj: Any) -> bool:
@@ -188,21 +299,25 @@ class FunctionToolSession:
self.tools_map: dict[str, Callable[..., Any]] = {}
for fn in tools:
if not is_tool(fn):
- raise TypeError(f"{getattr(fn, '__name__', fn)!r} is not a @tool-decorated callable")
+ raise TypeError(
+ f"{getattr(fn, '__name__', fn)!r} is not a @tool-decorated callable"
+ )
self.tools_map[fn.openai_schema["function"]["name"]] = fn
@property
def schemas(self) -> list[dict[str, Any]]:
return [fn.openai_schema for fn in self.tools_map.values()]
- def tool_call(self, name: str, arguments: dict[str, Any], timeout: float | int = 10) -> Any:
+ def tool_call(self, name: str, arguments: dict[str, Any], timeout: float | int = 300) -> Any:
return asyncio.run(self.tool_call_async(name, arguments, request_timeout=timeout))
- async def tool_call_async(self, name: str, arguments: dict[str, Any], request_timeout: float | int = 10) -> Any:
+ async def tool_call_async(self, name: str, arguments: dict[str, Any], request_timeout: float | int = 300) -> Any:
if name not in self.tools_map:
raise KeyError(f"Tool {name!r} is not registered")
if not isinstance(arguments, Mapping):
- raise TypeError(f"Tool arguments for {name} must be an object, got {type(arguments).__name__}")
+ raise TypeError(
+ f"Tool arguments for {name} must be an object, got {type(arguments).__name__}"
+ )
fn = self.tools_map[name]
logging.info(f"[FunctionTool] invoke name={name} args={str(arguments)[:200]}")
if asyncio.iscoroutinefunction(fn):
@@ -214,4 +329,9 @@ class FunctionToolSession:
# background until it returns. Callers should treat sync tools
# that block on I/O accordingly.
coro = thread_pool_exec(fn, **arguments)
- return await asyncio.wait_for(coro, timeout=request_timeout)
+ # Per-tool timeout set via ``@tool(timeout=N)`` overrides the
+ # session-default. ``None`` is a legal explicit choice meaning
+ # "wait forever" — ``asyncio.wait_for(..., timeout=None)`` handles it.
+ configured = getattr(fn, "_tool_timeout", _TIMEOUT_UNSET)
+ effective_timeout = request_timeout if configured is _TIMEOUT_UNSET else configured
+ return await asyncio.wait_for(coro, timeout=effective_timeout)
\ No newline at end of file
diff --git a/rag/prompts/generator.py b/rag/prompts/generator.py
index 30a5848b19..2ef35fdbdb 100644
--- a/rag/prompts/generator.py
+++ b/rag/prompts/generator.py
@@ -967,6 +967,23 @@ async def sufficiency_check(chat_mdl, question: str, ret_content: str):
return {}
+SUFFICIENCY_SELECT = load_prompt("sufficiency_select")
+
+
+async def sufficiency_select(chat_mdl, question: str, ret_content: str):
+ """Sufficiency judgement that also returns the IDs of the useful chunks.
+
+ ``ret_content`` must label each chunk with an ``ID: n`` marker (as
+ :func:`kb_prompt` does). Returns a dict with ``is_sufficient``,
+ ``reasoning``, ``missing_information`` and ``useful_chunk_ids``.
+ """
+ try:
+ return await gen_json(PROMPT_JINJA_ENV.from_string(SUFFICIENCY_SELECT).render(question=question, retrieved_docs=ret_content), "Output:\n", chat_mdl)
+ except Exception as e:
+ logging.exception(e)
+ return {}
+
+
MULTI_QUERIES_GEN = load_prompt("multi_queries_gen")
diff --git a/rag/prompts/sufficiency_select.md b/rag/prompts/sufficiency_select.md
new file mode 100644
index 0000000000..c0435b9190
--- /dev/null
+++ b/rag/prompts/sufficiency_select.md
@@ -0,0 +1,28 @@
+You are an information retrieval evaluation expert. Assess whether the currently retrieved content is sufficient to answer the user's question(s), and identify exactly which retrieved chunks are useful.
+
+Each retrieved chunk is labeled with an integer ID on a line like `ID: 3`.
+
+User question(s):
+{{ question }}
+
+Retrieved content:
+{{ retrieved_docs }}
+
+Determine whether these contents are sufficient to answer the user's question(s), and list the IDs of the chunks that actually contribute useful information toward answering them.
+
+Output format (JSON):
+```json
+{
+ "is_sufficient": true/false,
+ "reasoning": "Your reasoning for the judgment",
+ "missing_information": ["Missing information 1", "Missing information 2"],
+ "useful_chunk_ids": [0, 3, 7]
+}
+```
+
+Requirements:
+1. If the retrieved content contains the key information needed to answer the question(s), judge as sufficient (true).
+2. If key information is missing, judge as insufficient (false), and list the missing information.
+3. `useful_chunk_ids` must contain ONLY the integer IDs (taken from the `ID:` labels above) of chunks that provide information useful for answering the question(s). Exclude irrelevant or redundant chunks. Use an empty array when none are useful.
+4. The `missing_information` should only be filled when insufficient, otherwise an empty array.
+5. The `reasoning` should be concise and clear.
diff --git a/rag/utils/es_conn.py b/rag/utils/es_conn.py
index b69cca2570..99239c6de5 100644
--- a/rag/utils/es_conn.py
+++ b/rag/utils/es_conn.py
@@ -230,7 +230,7 @@ class ESConnection(ESConnectionBase):
if bool_query:
s = s.query(bool_query)
for field in highlight_fields:
- s = s.highlight(field)
+ s = s.highlight(field, fragment_size=50, number_of_fragments=5)
if order_by:
orders = list()
diff --git a/test/testcases/restful_api/test_chats.py b/test/testcases/restful_api/test_chats.py
index b86fda7830..5d5a9b0882 100644
--- a/test/testcases/restful_api/test_chats.py
+++ b/test/testcases/restful_api/test_chats.py
@@ -702,6 +702,7 @@ def _load_chat_routes_unit_module(monkeypatch):
dialog_service_mod.async_ask = lambda *_args, **_kwargs: None
dialog_service_mod.async_chat = lambda *_args, **_kwargs: None
dialog_service_mod.gen_mindmap = lambda *_args, **_kwargs: None
+ dialog_service_mod.rag_agent = lambda *_args, **_kwargs: None
monkeypatch.setitem(sys.modules, "api.db.services.dialog_service", dialog_service_mod)
conversation_service_mod = ModuleType("api.db.services.conversation_service")
diff --git a/test/testcases/restful_api/test_user_tenant_routes_unit.py b/test/testcases/restful_api/test_user_tenant_routes_unit.py
index f983f40fc3..1076acd4ac 100644
--- a/test/testcases/restful_api/test_user_tenant_routes_unit.py
+++ b/test/testcases/restful_api/test_user_tenant_routes_unit.py
@@ -1471,6 +1471,7 @@ def _load_chat_routes_unit_module(monkeypatch):
dialog_service_mod.async_ask = lambda *_args, **_kwargs: None
dialog_service_mod.async_chat = lambda *_args, **_kwargs: None
dialog_service_mod.gen_mindmap = lambda *_args, **_kwargs: None
+ dialog_service_mod.rag_agent = lambda *_args, **_kwargs: None
monkeypatch.setitem(sys.modules, "api.db.services.dialog_service", dialog_service_mod)
conversation_service_mod = ModuleType("api.db.services.conversation_service")
diff --git a/uv.lock b/uv.lock
index 478f174c34..1e0db4f334 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3114,12 +3114,33 @@ wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/32/1f/2a2b5eea8ef5762a86ad3f8fddddaaba2c0d76dd44e644b9158900868bec/json_repair-0.60.1-py3-none-any.whl", hash = "sha256:ba6ff974f2a8bef2f7768144a7f03f870a816443f03da27a49cdd0ec31a78049" },
]
+[[package]]
+name = "jsonpatch"
+version = "1.33"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "jsonpointer" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade" },
+]
+
[[package]]
name = "jsonpath"
version = "0.82.2"
source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cf/a1/693351acd0a9edca4de9153372a65e75398898ea7f8a5c722ab00f464929/jsonpath-0.82.2.tar.gz", hash = "sha256:d87ef2bcbcded68ee96bc34c1809b69457ecec9b0c4dd471658a12bd391002d1" }
+[[package]]
+name = "jsonpointer"
+version = "3.1.1"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca" },
+]
+
[[package]]
name = "jsonschema"
version = "4.26.0"
@@ -3189,6 +3210,38 @@ wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c" },
]
+[[package]]
+name = "langchain-core"
+version = "1.4.9"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "jsonpatch" },
+ { name = "langchain-protocol" },
+ { name = "langsmith" },
+ { name = "packaging" },
+ { name = "pydantic" },
+ { name = "pyyaml" },
+ { name = "tenacity" },
+ { name = "typing-extensions" },
+ { name = "uuid-utils" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2a/b9/e937d0a90b26540bff07e7a7c64349f3b29c2dcc36257cd1cd3fdce17f2a/langchain_core-1.4.9.tar.gz", hash = "sha256:f8078901145bed0466755277500a5a22822a7b628808c4c0a28d4fc88895fcf2" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/84/70/ade2fada52772798ef815b6352b59e71b116aa0c32c3aef5be3dc2cbed12/langchain_core-1.4.9-py3-none-any.whl", hash = "sha256:28e3909e2a10cc81504952d795ac0a9e014c0018121ef89d48dd396fa09ec624" },
+]
+
+[[package]]
+name = "langchain-protocol"
+version = "0.0.18"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a" },
+]
+
[[package]]
name = "langfuse"
version = "4.7.1"
@@ -3208,6 +3261,87 @@ wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/9f/9a/bd3368f46b6c72ee2068b80536826b02ae86df53eff1c79941344503098f/langfuse-4.7.1-py3-none-any.whl", hash = "sha256:a4e59c81ad5e5b16a65d3849f4923ebc3ad6e67ec803ada83d50c0cb66149490" },
]
+[[package]]
+name = "langgraph"
+version = "1.2.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "langchain-core" },
+ { name = "langgraph-checkpoint" },
+ { name = "langgraph-prebuilt" },
+ { name = "langgraph-sdk" },
+ { name = "pydantic" },
+ { name = "xxhash" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/58/61/d5d25e783035aa307d289b37e082258a6061c0fb4caa4a284f3bf1e87169/langgraph-1.2.0.tar.gz", hash = "sha256:4a9baaf62afc5d5f63144a50095140a34b9aa9b7cea695d25326d564775348e7" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/f6/e8/e3304ac0015c2bdb04ad9785e4ed65c788855ce7857ce6104dd2f5d322db/langgraph-1.2.0-py3-none-any.whl", hash = "sha256:03fd5895a8d4b70db1ff63ebc3bacead29dd20cd794a8b1a483e7ec9018f7a65" },
+]
+
+[[package]]
+name = "langgraph-checkpoint"
+version = "4.1.1"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "langchain-core" },
+ { name = "ormsgpack" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e" },
+]
+
+[[package]]
+name = "langgraph-prebuilt"
+version = "1.1.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "langchain-core" },
+ { name = "langgraph-checkpoint" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9" },
+]
+
+[[package]]
+name = "langgraph-sdk"
+version = "0.3.6"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "httpx" },
+ { name = "orjson" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3e/ec/477fa8b408f948b145d90fd935c0a9f37945fa5ec1dfabfc71e7cafba6d8/langgraph_sdk-0.3.6.tar.gz", hash = "sha256:7650f607f89c1586db5bee391b1a8754cbe1fc83b721ff2f1450f8906e790bd7" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/d8/61/12508e12652edd1874327271a5a8834c728a605f53a1a1c945f13ab69664/langgraph_sdk-0.3.6-py3-none-any.whl", hash = "sha256:7df2fd552ad7262d0baf8e1f849dce1d62186e76dcdd36db9dc5bdfa5c3fc20f" },
+]
+
+[[package]]
+name = "langsmith"
+version = "0.10.5"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "httpx" },
+ { name = "orjson", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "packaging" },
+ { name = "pydantic" },
+ { name = "requests" },
+ { name = "requests-toolbelt" },
+ { name = "sniffio" },
+ { name = "typing-extensions" },
+ { name = "uuid-utils" },
+ { name = "websockets" },
+ { name = "xxhash" },
+ { name = "zstandard" },
+]
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1f/65/3867765976e4d43b98a4ea6a41c0712dda17a600ad998e02976b445874d7/langsmith-0.10.5.tar.gz", hash = "sha256:60053c1d88dc332a002cbac38601cc8b912466e7fc2a86bc9e690fa4d5bc1c78" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/a9/3e/213d9bb122f97d89987bd4c175cc4be9f2fa090e868ac8b5156c3265d8dd/langsmith-0.10.5-py3-none-any.whl", hash = "sha256:116adf2c30dfc1d0daf16919879b90c4093aad6122f44b80cc1f035b874dc9d6" },
+]
+
[[package]]
name = "lark"
version = "1.3.1"
@@ -3905,12 +4039,12 @@ name = "onnxruntime-gpu"
version = "1.23.2"
source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
dependencies = [
- { name = "coloredlogs" },
- { name = "flatbuffers" },
- { name = "numpy" },
- { name = "packaging" },
- { name = "protobuf" },
- { name = "sympy" },
+ { name = "coloredlogs", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
+ { name = "flatbuffers", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
+ { name = "numpy", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
+ { name = "packaging", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
+ { name = "protobuf", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
+ { name = "sympy", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
]
wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/03/05/40d561636e4114b54aa06d2371bfbca2d03e12cfdf5d4b85814802f18a75/onnxruntime_gpu-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e8f75af5da07329d0c3a5006087f4051d8abd133b4be7c9bae8cdab7bea4c26" },
@@ -7940,6 +8074,7 @@ dependencies = [
{ name = "jira" },
{ name = "json-repair" },
{ name = "langfuse" },
+ { name = "langgraph" },
{ name = "lark-oapi" },
{ name = "line-bot-sdk" },
{ name = "litellm" },
@@ -8095,6 +8230,7 @@ requires-dist = [
{ name = "jira", specifier = "==3.10.5" },
{ name = "json-repair", specifier = "==0.60.1" },
{ name = "langfuse", specifier = ">=4.0.1" },
+ { name = "langgraph", specifier = "==1.2.0" },
{ name = "lark-oapi", specifier = ">=1.2.0" },
{ name = "line-bot-sdk", specifier = ">=3.0.0" },
{ name = "litellm", specifier = "==1.82.5" },
@@ -9537,6 +9673,29 @@ socks = [
{ name = "pysocks" },
]
+[[package]]
+name = "uuid-utils"
+version = "0.17.0"
+source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
+sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e7/91/63938e0e7e7876658e5e40178e7c0735b53527886fe11797a11699c55edd/uuid_utils-0.17.0.tar.gz", hash = "sha256:abb5667a36119019b3fa320c4d10c21ebccfcc87c8a739e6a0056cee7f48dde2" }
+wheels = [
+ { url = "https://mirrors.aliyun.com/pypi/packages/d2/dd/614fb9912157ac0128e6050859ccf06d9f13df9a944a803e8f80f6157e38/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d11a7bc1e02da8984d32e6de9e0826c6edac00eac17de270f372bf32f9a0af63" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/3e/11/d072711704de3d21bec08b6c2f36a215200ca1d5e01a390ea1ac434080a0/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7a49f47ac26df3e431c56b825c1bae8e6d3d591fdbb7438c227cc9845a7e3d73" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/18/6d/8a63e5eb2d5a6ba69a6c2036e305075bd6f5a022e7ea25fc6ce0eb7c51d2/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32df1944808877702ceea398c103881c09a679bb672a215e01c2a84231266bf9" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/f7/2d/bdc2caf9719d9090d7c46043242ae6136cba4f7a7ee384992ab905ad9aa1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98c88d3edd08e7245562e9815996dbc6f0bd4745e1c76462f24af5ae4e187dd1" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b6/33/9219d09d51ead282b578b2a4e0a515c2cce3ec52076cada8bfb7e35727d5/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a4370089c8b2e42f1db51d76408c7fa8eaa2934bf854d17983d16179c07c098" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d8/79/e8e0f8b3955f2081c116157119d87659937893242eb834aa170da04d660b/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09a55b7a5ae764985cb46467496a1787678d0a1400356157a080ad95b1a36869" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d5/5e/d1ceddc430ff04b6e21704b2030d4438074a2f478b265dab43da957791c1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56aa6488b931246fae11924e4bd0e2b32677e63945eecb71c29e3c2ca0dc3131" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/d5/62/89438e12f389a843e626b7e37691319a057b3d6b80914609106891faadda/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:309a35f12d99dde19032bc2259cda6431c85eeac0879134dc777cc3087d7e1cb" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/87/d2/eedcd99f522d60e238ead03844f0d51743ba84d33044959e230b756bf212/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:21c79b61ff750abcf057163dd764ccb6196cde7a26cda1b31b45cd97769e03b3" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/0e/a8/bb1b38aaddd7243b6e562c6694f499bf094800918316192fd8cb2cdc2620/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4134353bfe3026ddab8e886002dc52bc5a0ab04611aabb0eaae23c32e6e57f64" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/b4/77/5f7ed930dc105e293845c09e4d5bd84076318a12f45a46783e1af64906d7/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7c89359affecebe2e39e6a116d069b363c936511a9572b308402489a26957d89" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/fd/25/1b55697adf6811a6f92cff6340e6b03e31fd6bc51066a5c10698c29b3679/uuid_utils-0.17.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:6a019a31bc4db89a0903a3e4f6b218571f3a6ff0ad4b3d3fe1c8f91a05ff6e3e" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/26/bf/cd729343de4684230be8a966bad7bfc2cf10ce3e643b1189a8b5370dbe35/uuid_utils-0.17.0-cp313-cp313-win32.whl", hash = "sha256:b3131a82d0c7611f0aa480a6d36929e001a3f54ba0fc029a8118a5863cce513c" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/76/f0/e602ae0a1b139a7826e5189b93d91902564def06d5006324fd2faf82c8fc/uuid_utils-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:9e311f908d2f842fca4c7dcebc4f10306b8089b204ef04cf6704b4332c9ff6ff" },
+ { url = "https://mirrors.aliyun.com/pypi/packages/1a/52/024ebece265b387154115dc4f1d9727174ef82623069f4bec8b7ed7e73f7/uuid_utils-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:c351737e2e65497c7200ab4ffb8af97e9f48be6488309abdd265fe08d66ee92f" },
+]
+
[[package]]
name = "uuid7"
version = "0.1.0"