Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

2845 lines
106 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ResearchPipeline — agentic-engine-based replacement for the legacy
multi-agent ``ResearchPipeline``.
Phase shape:
* **Phase 1 (Rephrase)** — a mini agentic loop over ``THINK`` / ``TOOL``
/ ``FINISH`` whose only available tool is ``ask_user``. Up to 3
ask_user rounds (each with 1-4 questions on one card). FINISH text
is the refined research topic. May FINISH early when the user is
unambiguous.
* **Phase 2 (Decompose)** — one ``OUTLINE`` labeled step turning the
refined topic into N sub-topics. When ``confirmed_outline=None``
ResearchPipeline returns the outline and exits so the capability can
surface it as a preview; the same pipeline is invoked again with
``confirmed_outline`` once the user confirms.
* **Phase 3 (Research blocks)** — for each ``TopicBlock`` in the
dynamic queue, one ``run_agentic_loop`` over ``THINK`` / ``TOOL`` /
``APPEND`` / ``FINISH``. ``APPEND`` is an intermediate label that
mutates the queue (via ``_BlockLoopHost.on_intermediate``). The outer
scheduler drains the queue in series or parallel; APPEND-added
blocks naturally get picked up by subsequent batches.
* **Phase 4 (Reporting)** — sequence of one-shot labeled steps:
``OUTLINE`` (report structure) → ``INTRO`` → for each section
``SECTION`` → ``CONCLUSION`` → assemble. Each emits its own trace
card and pulls evidence from :class:`CitationManager` for anchor
injection.
Citations and the dynamic topic queue — the two features that make
deep research distinct — are preserved.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable
from dataclasses import dataclass
import html
import logging
import re
from typing import Any
from deeptutor.agents._shared.capability_result import emit_capability_result
from deeptutor.agents._shared.tool_composition import (
ToolMountFlags,
compose_enabled_tools,
default_optional_tools,
user_has_memory,
user_has_notebooks,
)
from deeptutor.agents.research.data_structures import (
DynamicTopicQueue,
ToolTrace,
TopicBlock,
TopicStatus,
)
from deeptutor.agents.research.utils.citation_manager import CitationManager
from deeptutor.core.agentic import (
DispatchOutcome,
LabeledStepResult,
LabelProtocol,
LLMClientConfig,
UsageTracker,
build_completion_kwargs,
build_openai_client,
can_use_native_tool_calling,
classify_label,
dispatch_tool_calls,
run_agentic_loop,
run_labeled_step,
)
from deeptutor.core.agentic.tool_dispatch import (
MAX_PARALLEL_TOOL_CALLS,
)
from deeptutor.core.context import Attachment, UnifiedContext
from deeptutor.core.stream_bus import StreamBus
from deeptutor.core.trace import (
build_trace_metadata,
derive_trace_metadata,
merge_trace_metadata,
new_call_id,
)
from deeptutor.runtime.registry.tool_registry import get_tool_registry
from deeptutor.services.config import parse_language
from deeptutor.services.llm import get_llm_config, prepare_multimodal_messages
from deeptutor.services.path_service import get_path_service
from deeptutor.services.prompt import get_prompt_manager
from deeptutor.services.prompt.language import append_language_directive
from deeptutor.services.sandbox import exec_capability_available
from deeptutor.utils.json_parser import parse_json_response
logger = logging.getLogger(__name__)
SOURCE = "deep_research"
# Per-block research uses the same shared composition policy as chat
# (``compose_enabled_tools``), then narrows the result to tools that can
# produce evidence for a block-level research summary.
RESEARCH_OPTIONAL_TOOLS: list[str] = default_optional_tools()
RESEARCH_BLOCK_TOOL_ALLOWLIST: frozenset[str] = frozenset(
{"rag", "web_search", "paper_search", "code_execution"}
)
# ---------------------------------------------------------------------------
# Label vocabulary
# ---------------------------------------------------------------------------
LABEL_THINK = "THINK"
LABEL_TOOL = "TOOL"
LABEL_APPEND = "APPEND"
LABEL_FINISH = "FINISH"
LABEL_OUTLINE = "OUTLINE"
LABEL_INTRO = "INTRO"
LABEL_SECTION = "SECTION"
LABEL_CONCLUSION = "CONCLUSION"
# Rephrase loop: agent talks to the user through ``ask_user`` and then
# FINISHes with a refined topic statement.
_PROTOCOL_REPHRASE = LabelProtocol(
allowed=(LABEL_THINK, LABEL_TOOL, LABEL_FINISH),
terminal=frozenset({LABEL_FINISH}),
intermediate=frozenset({LABEL_THINK}),
final=frozenset({LABEL_FINISH}),
tool_label=LABEL_TOOL,
)
# Decompose: one-shot ``OUTLINE`` payload (JSON list of sub-topics).
_PROTOCOL_DECOMPOSE = LabelProtocol(
allowed=(LABEL_OUTLINE,),
terminal=frozenset({LABEL_OUTLINE}),
intermediate=frozenset(),
final=frozenset({LABEL_OUTLINE}),
tool_label=None,
)
# Per-block research: the headline agentic loop. ``APPEND`` is the
# intermediate label that extends the dynamic queue (handled by the
# host's ``on_intermediate`` hook).
_PROTOCOL_BLOCK = LabelProtocol(
allowed=(LABEL_THINK, LABEL_TOOL, LABEL_APPEND, LABEL_FINISH),
terminal=frozenset({LABEL_FINISH}),
intermediate=frozenset({LABEL_THINK, LABEL_APPEND}),
final=frozenset({LABEL_FINISH}),
tool_label=LABEL_TOOL,
)
# Reporting sub-phases — each is one labeled step with one terminal
# label. Distinct names so the LLM and the trace UI both recognise
# which phase the call belongs to.
_PROTOCOL_REPORT_OUTLINE = LabelProtocol(
allowed=(LABEL_OUTLINE,),
terminal=frozenset({LABEL_OUTLINE}),
intermediate=frozenset(),
final=frozenset({LABEL_OUTLINE}),
tool_label=None,
)
_PROTOCOL_REPORT_INTRO = LabelProtocol(
allowed=(LABEL_INTRO,),
terminal=frozenset({LABEL_INTRO}),
intermediate=frozenset(),
final=frozenset({LABEL_INTRO}),
tool_label=None,
)
_PROTOCOL_REPORT_SECTION = LabelProtocol(
allowed=(LABEL_SECTION,),
terminal=frozenset({LABEL_SECTION}),
intermediate=frozenset(),
final=frozenset({LABEL_SECTION}),
tool_label=None,
)
_PROTOCOL_REPORT_CONCLUSION = LabelProtocol(
allowed=(LABEL_CONCLUSION,),
terminal=frozenset({LABEL_CONCLUSION}),
intermediate=frozenset(),
final=frozenset({LABEL_CONCLUSION}),
tool_label=None,
)
_PROTOCOL_ANSWER_NOW = LabelProtocol(
allowed=(LABEL_FINISH,),
terminal=frozenset({LABEL_FINISH}),
intermediate=frozenset(),
final=frozenset({LABEL_FINISH}),
tool_label=None,
)
# Note Agent — single ``FINISH`` labeled step that compresses a raw tool
# result into a short dense summary for the citation sidecar.
_PROTOCOL_NOTE = LabelProtocol(
allowed=(LABEL_FINISH,),
terminal=frozenset({LABEL_FINISH}),
intermediate=frozenset(),
final=frozenset({LABEL_FINISH}),
tool_label=None,
)
# Tools whose results get summarised + recorded in the citation manager.
# Any tool whose results carry source documents / external evidence
# should be added here.
CITABLE_TOOLS: frozenset[str] = frozenset({"rag", "web_search", "paper_search", "code_execution"})
# Token budget for the note summarization sidecar.
DEFAULT_NOTE_MAX_TOKENS = 1500
# How much of the raw tool output we feed into the note summarizer.
NOTE_RAW_INPUT_TRUNCATE_CHARS = 8000
# ---------------------------------------------------------------------------
# Defaults — preserved from the legacy presets so refactor is config-stable.
# ---------------------------------------------------------------------------
DEFAULT_REPHRASE_MAX_ITERATIONS = (
8 # Inner loop iter cap; ask_user round cap is enforced separately.
)
DEFAULT_REPHRASE_MAX_ROUNDS = 3
DEFAULT_REPHRASE_MAX_QUESTIONS_PER_ROUND = 3
DEFAULT_BLOCK_MAX_ITERATIONS = 5
DEFAULT_BLOCK_MAX_TOKENS = 6000
DEFAULT_OUTLINE_MAX_TOKENS = 2000
DEFAULT_REPORT_OUTLINE_MAX_TOKENS = 2000
DEFAULT_REPORT_INTRO_MAX_TOKENS = 3000
DEFAULT_REPORT_SECTION_MAX_TOKENS = 6000
DEFAULT_REPORT_CONCLUSION_MAX_TOKENS = 3000
DEFAULT_INITIAL_SUBTOPICS = 5
DEFAULT_MAX_PARALLEL_TOPICS = 3
DEFAULT_QUEUE_MAX_LENGTH = 8
# ---------------------------------------------------------------------------
# Data shapes
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SubTopicItem:
"""One entry in the decompose / outline preview output."""
title: str
overview: str = ""
@dataclass
class ResearchedBlock:
"""A finished topic block ready for reporting."""
block: TopicBlock
knowledge: str # accumulated FINISH text from the block's agentic loop
@dataclass(frozen=True)
class ReportSectionPlan:
"""Per-section instruction in the report outline."""
id: str
title: str
intent: str # what this section should cover
block_ids: tuple[str, ...] # which TopicBlocks supply evidence
@dataclass(frozen=True)
class ReportOutline:
title: str
sections: tuple[ReportSectionPlan, ...]
# ---------------------------------------------------------------------------
# ResearchPipeline
# ---------------------------------------------------------------------------
class ResearchPipeline:
"""One-shot orchestrator: instantiate per turn, call :meth:`run` once.
The pipeline owns control flow and per-phase prompt assembly; every
LLM call goes through :mod:`deeptutor.core.agentic` primitives. The
legacy ``DynamicTopicQueue`` + :class:`CitationManager` are reused
verbatim as the in-flight scratchpad and citation registry.
"""
def __init__(
self,
*,
language: str = "en",
runtime_config: dict[str, Any] | None = None,
kb_name: str | None = None,
enabled_tools: list[str] | None = None,
) -> None:
self.language = parse_language(language)
self.kb_name = (kb_name or "").strip() or None
self.enabled_tools = list(enabled_tools or [])
self.runtime_config: dict[str, Any] = dict(runtime_config or {})
# Read structured policy sub-dicts produced by
# :func:`build_research_runtime_config`. All keys are best-effort —
# missing values fall back to module-level defaults so the pipeline
# also runs against a minimal direct-instantiation in unit tests.
researching = (
self.runtime_config.get("researching")
if isinstance(self.runtime_config.get("researching"), dict)
else {}
)
planning = (
self.runtime_config.get("planning")
if isinstance(self.runtime_config.get("planning"), dict)
else {}
)
reporting = (
self.runtime_config.get("reporting")
if isinstance(self.runtime_config.get("reporting"), dict)
else {}
)
queue_cfg = (
self.runtime_config.get("queue")
if isinstance(self.runtime_config.get("queue"), dict)
else {}
)
self.rephrase_max_iterations = _read_int(
planning.get("rephrase"),
key="max_iterations",
default=DEFAULT_REPHRASE_MAX_ITERATIONS,
)
self.rephrase_max_rounds = DEFAULT_REPHRASE_MAX_ROUNDS
self.rephrase_max_questions_per_round = DEFAULT_REPHRASE_MAX_QUESTIONS_PER_ROUND
self.rephrase_enabled = bool(
planning.get("rephrase", {}).get("enabled", True)
if isinstance(planning.get("rephrase"), dict)
else True
)
self.initial_subtopics = _read_int(
planning.get("decompose"),
key="initial_subtopics",
default=DEFAULT_INITIAL_SUBTOPICS,
)
self.block_max_iterations = _read_int(
researching,
key="max_iterations",
default=DEFAULT_BLOCK_MAX_ITERATIONS,
)
self.max_parallel_topics = max(
1,
_read_int(
researching,
key="max_parallel_topics",
default=DEFAULT_MAX_PARALLEL_TOPICS,
),
)
self.execution_mode = str(researching.get("execution_mode", "parallel"))
self.research_mode = str(reporting.get("mode", "report"))
self.queue_max_length = _read_int(
queue_cfg, key="max_length", default=DEFAULT_QUEUE_MAX_LENGTH
)
# Block-loop tool composition runs through the same shared policy
# chat uses (``compose_enabled_tools``) — see :meth:`_block_tool_names`.
# There is no per-source enable_* gating here: the per-block loop
# sees exactly the user-toggled tools plus auto-mounts (``rag``
# when a KB is attached). Citations, ``ask_user`` is excluded by
# the block prompt itself.
# LLM client setup mirrors solve's pattern so the same engine
# primitives behave identically across capabilities.
self.llm_config = get_llm_config()
self.binding = getattr(self.llm_config, "binding", None) or "openai"
self.model = getattr(self.llm_config, "model", None)
self.api_key = getattr(self.llm_config, "api_key", None)
self.base_url = getattr(self.llm_config, "base_url", None)
self.api_version = getattr(self.llm_config, "api_version", None)
self.extra_headers = getattr(self.llm_config, "extra_headers", None) or {}
self.reasoning_effort = getattr(self.llm_config, "reasoning_effort", None)
self.client_config = LLMClientConfig(
binding=self.binding,
model=self.model,
api_key=self.api_key,
base_url=self.base_url,
api_version=self.api_version,
extra_headers=self.extra_headers or None,
reasoning_effort=self.reasoning_effort,
)
self.registry = get_tool_registry()
self.usage = UsageTracker(model=self.model)
# Default sampling temperature — slightly higher than solve so
# the research loop can take initiative on APPEND decisions.
self._temperature = 0.3
try:
self._prompts: dict[str, Any] = (
get_prompt_manager().load_prompts(
module_name="research",
agent_name="pipeline",
language=self.language,
)
or {}
)
except Exception as exc:
logger.warning("Failed to load research pipeline prompts: %s", exc)
self._prompts = {}
# ------------------------------------------------------------------
# Public entry points
# ------------------------------------------------------------------
async def run(
self,
*,
context: UnifiedContext,
topic: str,
confirmed_outline: list[SubTopicItem] | None = None,
attachments: list[Attachment] | None = None,
stream: StreamBus,
) -> dict[str, Any]:
"""Drive the four phases.
When ``confirmed_outline`` is ``None`` and rephrase + decompose
complete, the pipeline emits an ``outline_preview`` result and
returns so the capability can surface the outline for the user
to edit / confirm. A subsequent call with ``confirmed_outline``
skips Phase 1+2 (the user already saw the questions and approved
a structure) and runs Phase 3+4 directly.
"""
attachments = list(attachments or [])
image_attachments = [a for a in attachments if getattr(a, "type", "") == "image"]
client = self._build_client()
try:
return await self._run_inner(
context=context,
topic=topic,
image_attachments=image_attachments,
confirmed_outline=confirmed_outline,
stream=stream,
client=client,
)
except Exception as exc:
logger.exception("ResearchPipeline.run failed: %s", exc)
await self._emit_visible_failure(stream, exc)
raise
async def _run_inner(
self,
*,
context: UnifiedContext,
topic: str,
image_attachments: list[Attachment],
confirmed_outline: list[SubTopicItem] | None,
stream: StreamBus,
client: Any,
) -> dict[str, Any]:
logger.info(
"ResearchPipeline.run start: lang=%s kb=%s requested_tools=%s "
"max_iter/block=%d max_parallel=%d",
self.language,
self.kb_name,
self.enabled_tools,
self.block_max_iterations,
self.max_parallel_topics,
)
# ----- Phase 1 + Phase 2 (planning) — skipped when an outline is
# already confirmed so the user isn't asked clarifying questions a
# second time on the same logical research task.
if confirmed_outline is None:
async with stream.stage("rephrasing", source=SOURCE):
refined_topic = (
await self._rephrase(
topic=topic,
context=context,
image_attachments=image_attachments,
stream=stream,
client=client,
)
if self.rephrase_enabled
else topic.strip()
)
async with stream.stage(
"decomposing",
source=SOURCE,
metadata={"research_status_key": "decompose_target"},
):
outline = await self._decompose(
topic=refined_topic,
context=context,
image_attachments=image_attachments,
stream=stream,
client=client,
)
# Flat top-level keys: ``stream.result(data)`` merges ``data``
# into the event's ``metadata`` field, and the frontend reads
# ``event.metadata.outline_preview`` (not ``…metadata.metadata.…``).
#
# No ``stream.result`` here — the capability emits a single
# result event after augmenting this payload with
# ``research_config`` (which the frontend needs to send back
# on confirm). Emitting here too would land first and the
# frontend's ``find(type="result")`` would pick this one,
# leaving ``research_config`` undefined.
preview_payload: dict[str, Any] = {
"response": "",
"output_dir": "",
"outline_preview": True,
"topic": refined_topic,
"sub_topics": [{"title": st.title, "overview": st.overview} for st in outline],
}
return preview_payload
# ----- Phase 3 (research blocks) -----
refined_topic = topic.strip()
queue = DynamicTopicQueue(
f"research_{context.session_id or 'adhoc'}",
max_length=self.queue_max_length,
)
citations = CitationManager(queue.research_id, cache_dir=None)
for sub in confirmed_outline:
queue.add_block(sub.title, sub.overview)
async with stream.stage("researching", source=SOURCE):
researched = await self._drive_queue(
queue=queue,
citations=citations,
topic=refined_topic,
context=context,
image_attachments=image_attachments,
stream=stream,
client=client,
)
# A planned block that didn't reach COMPLETED (raised, or exhausted its
# iteration budget without a FINISH) is backfilled with empty knowledge
# so the surviving blocks can still produce a useful report. But the run
# must not then look like a clean success — surface the shortfall both
# visibly (a warning notice) and in the result envelope so callers can
# tell the report is partial (issue #595).
incomplete = [rb for rb in researched if rb.block.status != TopicStatus.COMPLETED]
failed_block_titles = [rb.block.sub_topic for rb in incomplete]
if incomplete:
logger.warning(
"Deep Research partial: %d/%d blocks did not complete: %s",
len(incomplete),
len(researched),
failed_block_titles,
)
await stream.progress(
self._t(
"notices.partial_results",
default=(
"{failed} of {total} research subtopics could not be completed; "
"the report is based on the remaining evidence."
),
failed=len(incomplete),
total=len(researched),
),
source=SOURCE,
stage="researching",
metadata={"trace_kind": "warning"},
)
# ----- Phase 4 (iterative reporting) -----
async with stream.stage(
"reporting",
source=SOURCE,
metadata={
"research_status_key": "report_outline",
"report_part": "outline",
},
):
report_text = await self._write_report(
topic=refined_topic,
blocks=researched,
citations=citations,
stream=stream,
client=client,
)
result_payload: dict[str, Any] = {
"response": report_text,
"output_dir": "",
"metadata": {
"mode": "agentic_research",
"topic": refined_topic,
"block_count": len(researched),
"citation_count": len(citations.get_all_citations()),
"partial": bool(incomplete),
"failed_block_count": len(incomplete),
"failed_block_titles": failed_block_titles,
},
}
await emit_capability_result(stream, result_payload, source=SOURCE, usage=self.usage)
return result_payload
async def _emit_visible_failure(self, stream: StreamBus, exc: BaseException) -> None:
"""Surface a runtime exception as a labelled error trace card so the
user sees what went wrong instead of an empty assistant message."""
call_id = new_call_id("research-failure")
meta = build_trace_metadata(
call_id=call_id,
phase="researching",
label=self._t("labels.research_step", default="Research step"),
call_kind="llm_final_response",
trace_id=call_id,
trace_role="response",
trace_group="stage",
)
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
await stream.error(
message,
source=SOURCE,
stage="researching",
metadata=merge_trace_metadata(meta, {"trace_kind": "error"}),
)
prefix = self._t("system.warning_prefix", default="⚠ ")
await stream.content(
f"{prefix}{message}",
source=SOURCE,
stage="researching",
metadata=merge_trace_metadata(meta, {"trace_kind": "llm_output"}),
)
# ------------------------------------------------------------------
# Phase 1: rephrase
# ------------------------------------------------------------------
async def _rephrase(
self,
*,
topic: str,
context: UnifiedContext,
image_attachments: list[Attachment] | None = None,
stream: StreamBus,
client: Any,
) -> str:
"""Mini agentic loop over ``THINK / TOOL / FINISH`` with only
``ask_user`` exposed.
The host (``_RephraseLoopHost``) enforces the round cap, rejects
any non-``ask_user`` tool call inline, and routes pause/resume
through the runtime-supplied ``wait_for_user_reply``. When the
LLM emits ``FINISH`` the post-label text is the refined topic.
If the loop exits without a valid FINISH (e.g. ``ask_user`` is
not available, or the LLM bails early) the original topic is
returned unchanged so the rest of the pipeline can proceed.
"""
if not self._tool_in_registry("ask_user"):
return topic.strip()
system_prompt = self._t(
"rephrase.system",
max_rounds=self.rephrase_max_rounds,
max_questions_per_round=self.rephrase_max_questions_per_round,
topic=topic,
)
user_prompt = self._t("rephrase.user_template", topic=topic)
messages = self._build_system_user_messages(
system_prompt, user_prompt, image_attachments=image_attachments
)
tool_names = ["ask_user"]
tool_schemas = (
self.registry.build_openai_schemas(tool_names)
if can_use_native_tool_calling(binding=self.binding, model=self.model)
else None
)
host = _RephraseLoopHost(
pipeline=self,
stream=stream,
context=context,
client=client,
max_rounds=self.rephrase_max_rounds,
)
try:
outcome = await run_agentic_loop(
initial_messages=messages,
protocol=_PROTOCOL_REPHRASE,
client=client,
model=self.model,
completion_kwargs=self._completion_kwargs(DEFAULT_BLOCK_MAX_TOKENS),
binding=self.binding,
tool_schemas=tool_schemas,
stream=stream,
source=SOURCE,
stage="rephrasing",
max_iterations=self.rephrase_max_iterations,
host=host,
usage=self.usage,
# FINISH text is the model's brief user-facing confirmation
# of what it's about to research — stream it live to the
# chat bubble body so the user sees the summary forming
# before the outline editor appears. It also doubles as
# the refined topic input for the decompose phase.
stream_body_live=True,
# Lazy sub-trace open so the FINISH iteration (which
# streams to the chat body, not into a reasoning card)
# doesn't leave a near-empty "Reasoning" card behind.
# THINK iters still get a card the moment their first
# thinking chunk arrives.
eager_sub_trace=False,
)
except Exception as exc:
logger.warning("Rephrase loop failed; falling back to raw topic: %s", exc)
return topic.strip()
refined = (outcome.final_text or "").strip()
return refined or topic.strip()
# ------------------------------------------------------------------
# Phase 2: decompose
# ------------------------------------------------------------------
async def _decompose(
self,
*,
topic: str,
context: UnifiedContext,
image_attachments: list[Attachment] | None = None,
stream: StreamBus,
client: Any,
) -> list[SubTopicItem]:
"""One-shot ``OUTLINE`` labeled step.
The LLM is asked to emit a JSON array of ``{title, overview}``
objects (any object with a ``title`` field is accepted). Parsing
is lenient — if JSON parse fails, the topic itself becomes the
single fallback sub-topic so the pipeline can still drive the
outline-preview UX.
"""
system_prompt = self._t("decompose.system")
user_prompt = self._t(
"decompose.user_template",
topic=topic,
num_subtopics=self.initial_subtopics,
)
messages = self._build_system_user_messages(
system_prompt, user_prompt, image_attachments=image_attachments
)
iter_meta = self._build_simple_trace_meta(
call_id_root="research-decompose",
label=self._t("labels.decompose", default="Decompose"),
stage="decomposing",
call_kind="llm_planning",
trace_role="plan",
trace_group="plan",
research_status_key="decompose_target",
)
step = await self._run_labeled_step(
client=client,
messages=messages,
tool_schemas=None,
protocol=_PROTOCOL_DECOMPOSE,
stream=stream,
stage="decomposing",
iter_meta=iter_meta,
max_tokens=DEFAULT_OUTLINE_MAX_TOKENS,
eager_sub_trace=False,
)
return self._parse_outline(topic, step.text)
def _parse_outline(self, topic: str, raw: str) -> list[SubTopicItem]:
data = parse_json_response(raw, logger_instance=logger, fallback={})
if isinstance(data, list):
iterable: list[Any] = data
elif isinstance(data, dict):
iterable = list(data.get("sub_topics") or data.get("subtopics") or [])
else:
iterable = []
items: list[SubTopicItem] = []
for entry in iterable:
if isinstance(entry, dict):
title = str(entry.get("title") or entry.get("topic") or "").strip()
overview = str(entry.get("overview") or entry.get("description") or "").strip()
elif isinstance(entry, str):
title = entry.strip()
overview = ""
else:
continue
if title:
items.append(SubTopicItem(title=title, overview=overview))
if len(items) >= self.initial_subtopics:
break
if not items:
items = [SubTopicItem(title=topic, overview="")]
return items
# ------------------------------------------------------------------
# Phase 3: research one block
# ------------------------------------------------------------------
async def _research_block(
self,
*,
block: TopicBlock,
queue: DynamicTopicQueue,
citations: CitationManager,
topic: str,
context: UnifiedContext,
stream: StreamBus,
client: Any,
) -> ResearchedBlock:
"""Run one block of the dynamic research queue through an agentic
loop with the ``THINK`` / ``TOOL`` / ``APPEND`` / ``FINISH``
protocol. ``FINISH``'s post-label text becomes the block's
consolidated knowledge for the report layer; ``APPEND`` triggers
a queue mutation via :meth:`_BlockLoopHost.on_intermediate`.
"""
queue.mark_researching(block.block_id)
block_tool_names = self._block_tool_names()
native_block_tools = self._use_native_block_tools(block_tool_names)
prompt_tool_names = block_tool_names if native_block_tools else []
effective_max_iterations = (
max(self.block_max_iterations, 4) if prompt_tool_names else self.block_max_iterations
)
tool_schemas = (
self._build_block_tool_schemas(prompt_tool_names) if native_block_tools else None
)
tool_list = (
self.registry.build_prompt_text(
prompt_tool_names,
format="list_with_usage",
language=self.language,
)
or self._fallback_empty_tool_list()
)
kb_note = self._kb_system_note()
system_prompt = self._t(
"research_step.system",
topic=topic,
block_title=block.sub_topic,
block_overview=block.overview or "(no overview)",
mode=self.research_mode,
max_iterations=effective_max_iterations,
kb_note=kb_note,
tool_list=tool_list,
)
system_prompt = append_language_directive(system_prompt, self.language)
sibling_topics = self._render_sibling_topics(queue, block)
user_prompt = self._t(
"research_step.user_template",
accumulated_knowledge=self._t(
"empty.no_evidence", default="(no evidence collected yet)"
),
sibling_topics=sibling_topics,
)
messages = self._build_system_user_messages(system_prompt, user_prompt)
host = _BlockLoopHost(
pipeline=self,
block=block,
queue=queue,
citations=citations,
topic=topic,
stream=stream,
context=context,
client=client,
)
try:
outcome = await run_agentic_loop(
initial_messages=messages,
protocol=_PROTOCOL_BLOCK,
client=client,
model=self.model,
completion_kwargs=self._completion_kwargs(DEFAULT_BLOCK_MAX_TOKENS),
binding=self.binding,
tool_schemas=tool_schemas,
stream=stream,
source=SOURCE,
stage="researching",
max_iterations=effective_max_iterations,
host=host,
usage=self.usage,
stream_body_live=False,
eager_sub_trace=True,
)
except Exception as exc:
logger.exception("Research block %s failed: %s", block.block_id, exc)
queue.mark_failed(block.block_id)
return ResearchedBlock(block=block, knowledge="")
knowledge = (outcome.final_text or "").strip()
if outcome.completed:
queue.mark_completed(block.block_id)
else:
queue.mark_failed(block.block_id)
block.iteration_count = outcome.iterations
return ResearchedBlock(block=block, knowledge=knowledge)
async def _force_finish_block(
self,
*,
client: Any,
messages: list[dict[str, Any]],
stream: StreamBus,
start_iteration: int,
block: TopicBlock,
) -> tuple[str, bool, int]:
"""Per-block ``LoopHost.force_finalize`` recovery: prompt the model
to emit a final ``FINISH`` consolidating what was learned so far,
with a small retry budget for protocol-repair attempts."""
calls = 0
messages.append({"role": "user", "content": self._t("protocol.force_finish")})
await stream.progress(
self._t("notices.max_iterations_reached", default="Max iterations reached."),
source=SOURCE,
stage="researching",
metadata={"trace_kind": "warning"},
)
for attempt in range(3):
iter_meta = self._build_simple_trace_meta(
call_id_root=f"research-{block.block_id}-force-{start_iteration + attempt}",
label=self._t("labels.reasoning", default="Reasoning"),
stage="researching",
block_id=block.block_id,
**_research_topic_status_meta(block),
)
result = await self._run_labeled_step(
client=client,
messages=messages,
tool_schemas=None,
protocol=LabelProtocol(
allowed=(LABEL_FINISH,),
terminal=frozenset({LABEL_FINISH}),
intermediate=frozenset(),
final=frozenset({LABEL_FINISH}),
tool_label=None,
),
stream=stream,
stage="researching",
iter_meta=iter_meta,
)
calls += 1
if result.label == LABEL_FINISH and result.text.strip():
return result.text, True, calls
messages.append({"role": "assistant", "content": result.text[:500]})
messages.append({"role": "user", "content": self._t("protocol.force_finish_repair")})
return self._t("protocol.fallback_final"), False, calls
# ------------------------------------------------------------------
# Per-block rendering helpers
# ------------------------------------------------------------------
def _render_sibling_topics(self, queue: DynamicTopicQueue, current_block: TopicBlock) -> str:
"""Compact list of other topics in the queue so APPEND can dedup
against them at prompt-time (in addition to the runtime check)."""
siblings = [
f" - [{b.block_id}] {b.sub_topic}"
for b in queue.blocks
if b.block_id != current_block.block_id
]
if not siblings:
return self._t("empty.no_subtopics", default="(none)")
return "\n".join(siblings)
def _kb_system_note(self) -> str:
if not self.kb_name:
return ""
return self._t(
"system.kb_system_note",
default=(
f"Attached knowledge bases: {self.kb_name}. When calling rag, "
f"kb_name must be {self.kb_name!r}."
),
kb_name=self.kb_name,
kb_name_repr=repr(self.kb_name),
)
def _fallback_empty_tool_list(self) -> str:
return self._t("empty.no_tools", default="- none")
# ------------------------------------------------------------------
# Phase 4: reporting
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# Queue scheduler — drains the dynamic topic queue. Series vs.
# parallel selected from ``researching.execution_mode``.
# ------------------------------------------------------------------
async def _drive_queue(
self,
*,
queue: DynamicTopicQueue,
citations: CitationManager,
topic: str,
context: UnifiedContext,
image_attachments: list[Attachment] | None,
stream: StreamBus,
client: Any,
) -> list[ResearchedBlock]:
researched_by_id: dict[str, ResearchedBlock] = {}
rounds = 0
safety_cap = max(20, (self.queue_max_length or 0) * 4)
while True:
pending = queue.get_all_pending_blocks()
if not pending:
break
batch_size = (
1 if str(self.execution_mode).lower() == "series" else self.max_parallel_topics
)
batch = pending[:batch_size]
results = await asyncio.gather(
*[
self._research_block(
block=block,
queue=queue,
citations=citations,
topic=topic,
context=context,
stream=stream,
client=client,
)
for block in batch
],
return_exceptions=True,
)
for block, result in zip(batch, results, strict=True):
if isinstance(result, BaseException):
logger.exception("Block %s research failed: %s", block.block_id, result)
if block.status == TopicStatus.RESEARCHING:
queue.mark_failed(block.block_id)
researched_by_id[block.block_id] = ResearchedBlock(block=block, knowledge="")
continue
researched_by_id[block.block_id] = result
rounds += 1
if rounds > safety_cap:
logger.warning(
"Research scheduler aborted after %d rounds — queue size %d",
rounds,
len(queue.blocks),
)
break
# Preserve queue order so parents come before APPENDed children.
ordered: list[ResearchedBlock] = []
for block in queue.blocks:
ordered.append(
researched_by_id.get(block.block_id) or ResearchedBlock(block=block, knowledge="")
)
return ordered
# ------------------------------------------------------------------
# Note-agent sidecar — summarise + record citation for one tool call.
# ------------------------------------------------------------------
async def _summarise_tool_result(
self,
*,
tool_name: str,
query: str,
raw_answer: str,
client: Any,
) -> str:
"""One LLM call per citable tool result: condense long retrievals
into a short, citation-ready summary. Failures fall back to a
leading slice of the raw answer so the loop never stalls on a
flaky summariser call.
"""
cleaned = (raw_answer or "").strip()
if not cleaned:
return ""
system_prompt = self._t("note.system")
user_prompt = self._t(
"note.user_template",
tool_name=tool_name,
query=query,
raw_answer=cleaned[:NOTE_RAW_INPUT_TRUNCATE_CHARS],
)
messages = self._build_system_user_messages(system_prompt, user_prompt)
try:
kwargs = self._completion_kwargs(DEFAULT_NOTE_MAX_TOKENS)
response = await client.chat.completions.create(
model=self.model, messages=messages, stream=False, **kwargs
)
content = (response.choices[0].message.content if response.choices else "") or ""
parsed = classify_label(
content,
allowed_labels=(LABEL_FINISH,),
final=True,
)
if parsed is not None:
_label, content = parsed
return content.strip() or cleaned[:600]
except Exception as exc:
logger.warning("Note summarisation failed (%s): %s", tool_name, exc)
return cleaned[:600]
# ------------------------------------------------------------------
# Phase 4: iterative reporting
#
# Each sub-phase is one labeled step:
# _gen_report_outline → _write_intro → for sec: _write_section →
# _write_conclusion. Bodies stream live (``stream_body_live``)
# chunk-by-chunk for the section-level steps so the user sees the
# report assemble in real time.
# ------------------------------------------------------------------
async def _write_report(
self,
*,
topic: str,
blocks: list[ResearchedBlock],
citations: CitationManager,
stream: StreamBus,
client: Any,
) -> str:
outline = await self._gen_report_outline(
topic=topic,
blocks=blocks,
citations=citations,
stream=stream,
client=client,
)
# Global numbering: introduction is 1, sub-topic sections are 2..N+1,
# conclusion is N+2 (where N == len(outline.sections)). Both the
# rendered ``##`` heading and any ``### N.x`` subsection prefixes the
# LLM emits reference these numbers, so we resolve them here and pass
# them into every report-writing step.
section_count = len(outline.sections)
conclusion_number = section_count + 2
section_texts: list[str] = []
# Emit the report's H1 title before the introduction so the rendered
# markdown opens with the report name, then the numbered ``## 1.``
# Introduction heading. The LLM is not asked to write the title — we
# already have it on the outline.
title_block = self._render_report_title_block(outline.title)
if title_block:
await stream.content(
title_block,
source=SOURCE,
stage="reporting",
metadata={"trace_kind": "report_title"},
)
section_texts.append(title_block)
intro = await self._write_intro(topic=topic, outline=outline, stream=stream, client=client)
if intro:
if section_texts:
await self._stream_report_separator(stream)
section_texts.append(intro)
section_bodies: list[str] = []
for section_index, section in enumerate(outline.sections, start=1):
if section_texts:
await self._stream_report_separator(stream)
body = await self._write_section(
section=section,
section_index=section_index,
section_count=section_count,
section_number=section_index + 1,
topic=topic,
outline=outline,
blocks=blocks,
citations=citations,
stream=stream,
client=client,
)
if body:
section_texts.append(body)
section_bodies.append(body)
if section_texts:
await self._stream_report_separator(stream)
conclusion = await self._write_conclusion(
topic=topic,
outline=outline,
section_bodies=section_bodies,
section_number=conclusion_number,
stream=stream,
client=client,
)
if conclusion:
section_texts.append(conclusion)
body = self._normalise_report_markdown(
"\n\n".join(part for part in section_texts if part.strip()),
citations,
)
used_citation_ids = _citation_ids_in_first_appearance(body, citations)
citation_numbers = {
citation_id: index for index, citation_id in enumerate(used_citation_ids, start=1)
}
body = self._linkify_report_citations(body, citations, citation_numbers=citation_numbers)
references = self._render_reference_list(
citations,
citation_ids=used_citation_ids,
citation_numbers=citation_numbers,
)
if references:
await self._stream_report_separator(stream)
await stream.content(
references,
source=SOURCE,
stage="reporting",
metadata={"trace_kind": "reference_list"},
)
return "\n\n".join(part for part in (body, references) if part.strip())
def _render_reference_list(
self,
citations: CitationManager,
*,
citation_ids: list[str] | None = None,
citation_numbers: dict[str, int] | None = None,
) -> str:
"""Append a collapsible reference appendix with stable anchors.
Numbering follows first appearance in the final report body. That
keeps body links and the appendix compact even when research collected
additional tool traces that did not survive synthesis.
"""
entries = []
ordered_ids = citation_ids if citation_ids is not None else _sorted_citation_ids(citations)
numbers = citation_numbers or {
citation_id: index for index, citation_id in enumerate(ordered_ids, start=1)
}
for citation_id in ordered_ids:
formatted = self._format_reference_entry(citations, citation_id)
if formatted:
anchor = _citation_anchor_id(citation_id)
ref_number = numbers.get(citation_id, len(entries) + 1)
entries.append(
f'<li id="{anchor}" data-citation-id="{html.escape(citation_id)}">'
f'<span data-ref-number="{ref_number}">{formatted}</span>'
"</li>"
)
if not entries:
return ""
heading = self._t("labels.references_heading", default="References")
return (
'<details id="references" open>\n'
f"<summary>{heading}</summary>\n"
"<ol>\n" + "\n".join(entries) + "\n</ol>\n"
"</details>"
)
def _linkify_report_citations(
self,
text: str,
citations: CitationManager,
*,
citation_numbers: dict[str, int] | None = None,
) -> str:
"""Turn generated ``[CIT-...]`` markers into same-page anchor links.
The section writer is intentionally instructed to copy plain markers,
because that is easier for models to follow. This renderer pass makes
the final persisted Markdown navigable without asking the model to
manufacture link syntax.
"""
numbers = citation_numbers or _citation_number_map(citations)
if not text or not numbers:
return text
def _replace(match: re.Match[str]) -> str:
citation_id = match.group("id")
ref_number = numbers.get(citation_id)
if ref_number is None:
return ""
return _citation_markdown_link(citation_id, ref_number)
return _REPORT_CITATION_MARKER_RE.sub(_replace, text)
def _normalise_report_markdown(
self,
text: str,
citations: CitationManager,
) -> str:
"""Clean model-authored report markdown before persistence.
The report writer is instructed to copy raw ``[CIT-...]`` markers,
but models sometimes pre-link them or repeat heading markers. This
pass converts pre-linked markers back to canonical form, strips
unknown citation ids, and normalises duplicate headings like
``## ## Title``.
"""
if not text:
return ""
known = set(citations.get_all_citations())
def _replace_link(match: re.Match[str]) -> str:
citation_id = match.group("id")
return f"[{citation_id}]" if citation_id in known else ""
cleaned = _REPORT_CITATION_LINK_RE.sub(_replace_link, text)
def _replace_bare(match: re.Match[str]) -> str:
citation_id = match.group("id")
return match.group(0) if citation_id in known else ""
cleaned = _REPORT_CITATION_MARKER_RE.sub(_replace_bare, cleaned)
return _normalise_markdown_headings(cleaned).strip()
def _format_reference_entry(
self,
citations: CitationManager,
citation_id: str,
) -> str | None:
"""Return a reference-list entry as HTML-safe HTML.
``CitationManager.format_citation_for_report`` already produces escaped
HTML for known tool types; the fallback path below also escapes
user-controlled fields so the caller can drop the result into the page
without re-escaping.
"""
formatted = citations.format_citation_for_report(citation_id)
if formatted:
return formatted
citation = citations.get_citation(citation_id)
if not citation:
return None
tool = str(citation.get("tool_type") or "tool")
query = str(citation.get("query") or "").strip()
summary = str(citation.get("summary") or "").strip()
parts = [html.escape(tool.replace("_", " ").title())]
if query:
parts.append(f"query: {html.escape(query)}")
if summary:
parts.append(f"note: {html.escape(summary[:180])}")
return " — ".join(parts)
async def _stream_report_separator(self, stream: StreamBus) -> None:
await stream.content("\n\n", source=SOURCE, stage="reporting")
def _render_report_title_block(self, title: str) -> str:
"""Build the ``# <title>`` H1 block emitted before the introduction.
Strips any leading ``#`` the model may have left on the outline title
so the rendered output is always a single H1 line.
"""
clean = _clean_report_heading_text(title or "").strip()
if not clean:
return ""
return f"# {clean}"
async def _gen_report_outline(
self,
*,
topic: str,
blocks: list[ResearchedBlock],
citations: CitationManager,
stream: StreamBus,
client: Any,
) -> ReportOutline:
"""One ``OUTLINE`` labeled step that proposes the report sections.
The model sees the topic + each researched block's title and a
short knowledge snippet, then emits a JSON ``{title, sections:
[{id, title, intent, block_ids}]}`` payload mapping each
section to one or more blocks that supply its evidence.
"""
block_summaries = []
for rb in blocks:
preview = (rb.knowledge or "").strip().split("\n\n")[0][:400]
block_summaries.append(f"- [{rb.block.block_id}] {rb.block.sub_topic}\n {preview}")
system_prompt = self._t("report.outline.system")
user_prompt = self._t(
"report.outline.user_template",
topic=topic,
block_summaries="\n".join(block_summaries) or "(no researched blocks)",
)
messages = self._build_system_user_messages(system_prompt, user_prompt)
iter_meta = self._build_simple_trace_meta(
call_id_root="research-report-outline",
label=self._t("labels.report_outline", default="Report outline"),
stage="reporting",
call_kind="llm_planning",
trace_role="plan",
trace_group="plan",
research_status_key="report_outline",
report_part="outline",
)
step = await self._run_labeled_step(
client=client,
messages=messages,
tool_schemas=None,
protocol=_PROTOCOL_REPORT_OUTLINE,
stream=stream,
stage="reporting",
iter_meta=iter_meta,
max_tokens=DEFAULT_REPORT_OUTLINE_MAX_TOKENS,
eager_sub_trace=False,
)
return self._parse_report_outline(topic, step.text, blocks)
def _parse_report_outline(
self, topic: str, raw: str, blocks: list[ResearchedBlock]
) -> ReportOutline:
data = parse_json_response(raw, logger_instance=logger, fallback={})
valid_ids = {rb.block.block_id for rb in blocks}
title_to_id = {rb.block.sub_topic.lower(): rb.block.block_id for rb in blocks}
if isinstance(data, dict):
title = _clean_report_heading_text(str(data.get("title") or topic)) or topic
raw_sections = data.get("sections") or []
elif isinstance(data, list):
title = _clean_report_heading_text(topic) or topic
raw_sections = data
else:
title = _clean_report_heading_text(topic) or topic
raw_sections = []
sections: list[ReportSectionPlan] = []
for i, item in enumerate(raw_sections if isinstance(raw_sections, list) else []):
if not isinstance(item, dict):
continue
sec_title = _clean_report_heading_text(str(item.get("title") or ""))
if not sec_title:
continue
sec_id = str(item.get("id") or f"R{i + 1}").strip() or f"R{i + 1}"
intent = str(item.get("intent") or item.get("summary") or "").strip()
raw_block_ids = item.get("block_ids") or item.get("blocks") or []
resolved_ids: list[str] = []
if isinstance(raw_block_ids, list):
for entry in raw_block_ids:
if not isinstance(entry, str):
continue
if entry in valid_ids:
resolved_ids.append(entry)
else:
bid = title_to_id.get(entry.strip().lower())
if bid:
resolved_ids.append(bid)
sections.append(
ReportSectionPlan(
id=sec_id,
title=sec_title,
intent=intent,
block_ids=tuple(resolved_ids),
)
)
if not sections:
# Fallback: one section per researched block in queue order.
for rb in blocks:
sections.append(
ReportSectionPlan(
id=rb.block.block_id,
title=rb.block.sub_topic,
intent=rb.block.overview or "",
block_ids=(rb.block.block_id,),
)
)
sections = self._repair_report_section_coverage(sections, blocks)
return ReportOutline(title=title, sections=tuple(sections))
def _repair_report_section_coverage(
self,
sections: list[ReportSectionPlan],
blocks: list[ResearchedBlock],
) -> list[ReportSectionPlan]:
"""Make report planning robust to partial / invalid block maps.
The prompt asks the model to map every researched block into at
least one report section, but outline JSON is still model output.
This deterministic pass keeps the report from silently dropping a
block when the LLM omits its id, misspells it, or leaves a section
with an empty ``block_ids`` list.
"""
if not sections or not blocks:
return sections
valid_ids = {rb.block.block_id for rb in blocks}
covered: set[str] = set()
repaired: list[ReportSectionPlan] = []
for section in sections:
ids = tuple(dict.fromkeys(bid for bid in section.block_ids if bid in valid_ids))
if not ids:
best = _best_block_for_section(section, blocks, exclude=covered)
if best is not None:
ids = (best.block.block_id,)
covered.update(ids)
repaired.append(_section_with_block_ids(section, ids))
missing = [rb for rb in blocks if rb.block.block_id not in covered]
if not missing:
return repaired
addendum: list[str] = []
for rb in missing:
section_index = _best_section_for_block(rb, repaired)
if section_index is None:
addendum.append(rb.block.block_id)
continue
section = repaired[section_index]
repaired[section_index] = _section_with_block_ids(
section,
tuple(dict.fromkeys((*section.block_ids, rb.block.block_id))),
)
if addendum:
addendum_title = self._t("labels.addendum_title", default="Additional findings")
addendum_intent = self._t(
"labels.addendum_intent",
default=(
"Covers researched blocks that did not fit cleanly into the earlier sections."
),
)
repaired.append(
ReportSectionPlan(
id=f"S{len(repaired) + 1}",
title=addendum_title,
intent=addendum_intent,
block_ids=tuple(addendum),
)
)
return repaired
async def _write_intro(
self,
*,
topic: str,
outline: ReportOutline,
stream: StreamBus,
client: Any,
) -> str:
system_prompt = self._t("report.intro.system", section_number=1)
user_prompt = self._t(
"report.intro.user_template",
topic=topic,
title=outline.title,
section_number=1,
sections_overview="\n".join(
f"- {s.title}: {s.intent}".rstrip(": ") for s in outline.sections
),
)
return await self._stream_report_step(
system_prompt=system_prompt,
user_prompt=user_prompt,
protocol=_PROTOCOL_REPORT_INTRO,
stream=stream,
client=client,
label=self._t("labels.report_intro", default="Introduction"),
call_id_root="research-report-intro",
max_tokens=DEFAULT_REPORT_INTRO_MAX_TOKENS,
extra_meta={
"research_status_key": "report_intro",
"report_part": "intro",
},
)
async def _write_section(
self,
*,
section: ReportSectionPlan,
section_index: int,
section_count: int,
section_number: int,
topic: str,
outline: ReportOutline,
blocks: list[ResearchedBlock],
citations: CitationManager,
stream: StreamBus,
client: Any,
) -> str:
evidence = self._render_section_evidence(
section=section, blocks=blocks, citations=citations
)
system_prompt = self._t("report.section.system", section_number=section_number)
user_prompt = self._t(
"report.section.user_template",
topic=topic,
report_title=outline.title,
section_id=section.id,
section_title=section.title,
section_intent=section.intent or "(no extra guidance)",
section_number=section_number,
evidence=evidence,
)
return await self._stream_report_step(
system_prompt=system_prompt,
user_prompt=user_prompt,
protocol=_PROTOCOL_REPORT_SECTION,
stream=stream,
client=client,
label=(f"{self._t('labels.report_section', default='Section')}: {section.title}"),
call_id_root=f"research-report-section-{section.id}",
max_tokens=DEFAULT_REPORT_SECTION_MAX_TOKENS,
extra_meta={
"research_status_key": "report_section",
"report_part": "section",
"section_index": section_index,
"section_count": section_count,
"section_title": section.title,
},
)
async def _write_conclusion(
self,
*,
topic: str,
outline: ReportOutline,
section_bodies: list[str],
section_number: int,
stream: StreamBus,
client: Any,
) -> str:
# Recap: first paragraph of each rendered sub-topic section so the
# conclusion writer can land the answer without re-reading the full
# raw evidence. ``section_bodies`` contains only the middle sections
# (title block and intro are intentionally excluded).
recap_chunks: list[str] = []
for sec, body in zip(outline.sections, section_bodies, strict=False):
snippet = (body or "").strip().split("\n\n", 1)[0]
recap_chunks.append(f"### {sec.title}\n{snippet[:300]}")
system_prompt = self._t("report.conclusion.system", section_number=section_number)
user_prompt = self._t(
"report.conclusion.user_template",
topic=topic,
title=outline.title,
section_number=section_number,
sections_recap="\n\n".join(recap_chunks) or "(no section bodies)",
)
return await self._stream_report_step(
system_prompt=system_prompt,
user_prompt=user_prompt,
protocol=_PROTOCOL_REPORT_CONCLUSION,
stream=stream,
client=client,
label=self._t("labels.report_conclusion", default="Conclusion"),
call_id_root="research-report-conclusion",
max_tokens=DEFAULT_REPORT_CONCLUSION_MAX_TOKENS,
extra_meta={
"research_status_key": "report_conclusion",
"report_part": "conclusion",
},
)
async def _stream_report_step(
self,
*,
system_prompt: str,
user_prompt: str,
protocol: LabelProtocol,
stream: StreamBus,
client: Any,
label: str,
call_id_root: str,
max_tokens: int,
extra_meta: dict[str, Any] | None = None,
) -> str:
"""Common runner for the four report sub-phases: one labeled step
with body streaming live to the chat bubble + a sub-trace card."""
messages = self._build_system_user_messages(system_prompt, user_prompt)
trace_extra = dict(extra_meta or {})
iter_meta = self._build_simple_trace_meta(
call_id_root=call_id_root,
label=label,
stage="reporting",
call_kind="llm_final_response",
trace_role="response",
trace_group="stage",
**trace_extra,
)
final_call_id = new_call_id(f"{call_id_root}-final")
final_meta = build_trace_metadata(
call_id=final_call_id,
phase="reporting",
label=label,
call_kind="llm_final_response",
trace_id=final_call_id,
trace_role="response",
trace_group="stage",
**trace_extra,
)
step = await self._run_labeled_step(
client=client,
messages=messages,
tool_schemas=None,
protocol=protocol,
stream=stream,
stage="reporting",
iter_meta=iter_meta,
max_tokens=max_tokens,
final_meta=final_meta,
)
return (step.text or "").strip()
def _render_section_evidence(
self,
*,
section: ReportSectionPlan,
blocks: list[ResearchedBlock],
citations: CitationManager,
) -> str:
block_ids = section.block_ids or tuple(rb.block.block_id for rb in blocks)
by_id = {rb.block.block_id: rb for rb in blocks}
chunks: list[str] = []
total = 0
cap_per_block = 4000
cap_total = 12000
for bid in block_ids:
rb = by_id.get(bid)
if rb is None:
continue
title = _clean_report_heading_text(rb.block.sub_topic) or rb.block.sub_topic
lines = [f"### Block [{rb.block.block_id}] {title}"]
block_chars = 0
for trace in rb.block.tool_traces:
cid = trace.citation_id or trace.tool_id
summary = (trace.summary or "").strip()
if not summary:
continue
citation = citations.get_citation(cid) or {}
source_preview = _citation_source_preview(citation)
line_parts = [
f"#### Evidence [{cid}]",
f"- tool: {trace.tool_type}",
f"- query: {trace.query}",
]
if source_preview:
line_parts.append(f"- source hints: {source_preview}")
line_parts.append(f"- note: {summary}")
line = "\n".join(line_parts)
line = line[: cap_per_block - block_chars]
if not line:
break
lines.append(line)
block_chars += len(line)
if block_chars >= cap_per_block:
break
if rb.knowledge:
lines.append(f"#### Block FINISH\n{rb.knowledge[:1500]}")
chunk = "\n\n".join(lines)
if total + len(chunk) > cap_total:
chunk = chunk[: max(0, cap_total - total)]
if chunk:
chunks.append(chunk)
total += len(chunk)
if total >= cap_total:
break
return "\n\n".join(chunks) or "(no evidence available)"
# ------------------------------------------------------------------
# Tool composition for the block loop
# ------------------------------------------------------------------
def _block_tool_names(self) -> list[str]:
"""Tools available inside the per-block research loop.
Uses the same shared composition policy chat uses
(:func:`compose_enabled_tools`): user-toggled tools first, then
the conditional and always-on auto-mounts. Two block-phase-only
adjustments:
* Only evidence-producing research tools are surfaced. Chat's
always-on convenience tools (``write_memory``, ``web_fetch``,
``github``, ``ask_user``) are deliberately not part of the
block loop because they do not provide broad citable retrieval
for an arbitrary sub-topic.
* Each name is filtered through the registry so an inactive tool
(mis-configured backend, missing dep) doesn't end up in the
prompt.
"""
composed = compose_enabled_tools(
registry=self.registry,
requested_tools=self.enabled_tools,
optional_whitelist=RESEARCH_OPTIONAL_TOOLS,
mount_flags=ToolMountFlags(
has_kb=bool(self.kb_name),
has_sources=False,
has_memory=user_has_memory(),
has_notebooks=user_has_notebooks(),
has_code=exec_capability_available(),
),
)
return [
name
for name in composed
if name in RESEARCH_BLOCK_TOOL_ALLOWLIST and self._tool_in_registry(name)
]
def _build_block_tool_schemas(
self,
tool_names: list[str] | None = None,
) -> list[dict[str, Any]]:
names = self._block_tool_names() if tool_names is None else tool_names
schemas = self.registry.build_openai_schemas(names)
kb_choices = [self.kb_name] if self.kb_name else []
for schema in schemas:
function = schema.get("function") if isinstance(schema, dict) else None
if not isinstance(function, dict):
continue
parameters = function.get("parameters")
if not isinstance(parameters, dict):
continue
properties = parameters.get("properties") or {}
if function.get("name") == "rag" and isinstance(properties, dict):
if isinstance(properties.get("query"), dict):
properties["query"].setdefault("minLength", 1)
kb_schema = properties.get("kb_name")
if isinstance(kb_schema, dict) and kb_choices:
kb_schema["enum"] = kb_choices
parameters["additionalProperties"] = False
return schemas
def _augment_tool_kwargs(
self,
tool_name: str,
args: dict[str, Any],
context: UnifiedContext,
) -> dict[str, Any]:
kwargs = dict(args)
turn_id = str(context.metadata.get("turn_id", "") or "").strip()
task_dir = None
if turn_id:
task_dir = get_path_service().get_task_workspace("deep_research", turn_id)
if tool_name == "rag":
kwargs.setdefault("mode", "hybrid")
if self.kb_name:
kwargs.setdefault("kb_name", self.kb_name)
elif tool_name == "code_execution":
from deeptutor.services.sandbox import Mount
if task_dir is not None:
code_dir = task_dir / "code_runs"
code_dir.mkdir(parents=True, exist_ok=True)
kwargs["_sandbox_workdir"] = str(code_dir)
kwargs["_sandbox_mounts"] = (
Mount(host_path=str(code_dir), sandbox_path=str(code_dir), read_only=False),
)
elif tool_name == "web_search":
kwargs.setdefault("query", context.user_message)
if task_dir is not None:
kwargs.setdefault("output_dir", str(task_dir / "web_search"))
return kwargs
def _retrieve_trace_metadata(
self,
tool_meta: dict[str, Any],
*,
tool_name: str,
tool_args: dict[str, Any],
) -> dict[str, Any] | None:
if tool_name != "rag":
return None
return derive_trace_metadata(
tool_meta,
label=self._t("labels.retrieve", default="Retrieve"),
call_kind="rag_retrieval",
trace_role="retrieve",
trace_group="retrieve",
query=str(tool_args.get("query", "") or ""),
)
def _use_native_block_tools(self, tool_names: list[str] | None = None) -> bool:
names = self._block_tool_names() if tool_names is None else tool_names
return bool(names) and can_use_native_tool_calling(binding=self.binding, model=self.model)
def _tool_in_registry(self, name: str) -> bool:
try:
return self.registry.get(name) is not None
except Exception:
return False
# ------------------------------------------------------------------
# LLM call helpers
# ------------------------------------------------------------------
def _build_client(self) -> Any:
return build_openai_client(self.client_config)
def _completion_kwargs(self, max_tokens: int) -> dict[str, Any]:
return build_completion_kwargs(
temperature=self._temperature,
model=self.model,
max_tokens=max_tokens,
binding=self.binding,
reasoning_effort=self.reasoning_effort,
)
async def _run_labeled_step(
self,
*,
client: Any,
messages: list[dict[str, Any]],
tool_schemas: list[dict[str, Any]] | None,
protocol: LabelProtocol,
stream: StreamBus,
stage: str,
iter_meta: dict[str, Any],
max_tokens: int = DEFAULT_BLOCK_MAX_TOKENS,
final_meta: dict[str, Any] | None = None,
eager_sub_trace: bool = True,
) -> LabeledStepResult:
"""Research-flavoured thin wrapper over :func:`run_labeled_step`."""
return await run_labeled_step(
client=client,
model=self.model,
messages=messages,
completion_kwargs=self._completion_kwargs(max_tokens),
tool_schemas=tool_schemas,
allowed_labels=protocol.allowed,
final_labels=protocol.final,
tool_label=protocol.tool_label,
stream=stream,
source=SOURCE,
stage=stage,
iter_meta=iter_meta,
binding=self.binding,
usage=self.usage,
final_meta=final_meta,
eager_sub_trace=eager_sub_trace,
)
# ------------------------------------------------------------------
# Message + trace assembly
# ------------------------------------------------------------------
def _build_system_user_messages(
self,
system_prompt: str,
user_prompt: str,
*,
image_attachments: list[Attachment] | None = None,
) -> list[dict[str, Any]]:
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
if image_attachments:
mm_result = prepare_multimodal_messages(
messages, image_attachments, binding=self.binding, model=self.model
)
return mm_result.messages
return messages
def _build_simple_trace_meta(
self,
*,
call_id_root: str,
label: str,
stage: str,
call_kind: str = "llm_reasoning",
trace_role: str = "thought",
trace_group: str = "stage",
**extra: Any,
) -> dict[str, Any]:
call_id = new_call_id(call_id_root)
return build_trace_metadata(
call_id=call_id,
phase=stage,
label=label,
call_kind=call_kind,
trace_id=call_id,
trace_role=trace_role,
trace_group=trace_group,
**extra,
)
# ------------------------------------------------------------------
# YAML lookup
# ------------------------------------------------------------------
def _t(self, key: str, default: str = "", **kwargs: Any) -> str:
value: Any = self._prompts
for part in key.split("."):
if not isinstance(value, dict) or part not in value:
return default
value = value[part]
if not isinstance(value, str):
return default
if kwargs:
try:
return value.format(**kwargs)
except (KeyError, IndexError, ValueError):
return value
return value
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_REPORT_TOKEN_RE = re.compile(r"[^\W_]+", re.UNICODE)
_REPORT_STOPWORDS = {
"a",
"an",
"and",
"as",
"at",
"by",
"for",
"from",
"in",
"into",
"of",
"on",
"or",
"the",
"to",
"vs",
"with",
}
_REPORT_CITATION_MARKER_RE = re.compile(r"`?\[(?P<id>CIT-\d+-\d+|PLAN-\d+)\]`?(?!\s*\()")
_REPORT_CITATION_LINK_RE = re.compile(r"`?\[(?P<id>CIT-\d+-\d+|PLAN-\d+)\]`?\([^)]*\)")
_MARKDOWN_HEADING_RE = re.compile(r"^(?P<hashes>#{1,6})\s+(?P<title>.+?)\s*$")
_LEADING_HEADING_MARKERS_RE = re.compile(r"^(?:#{1,6}\s+)+")
_LEADING_SECTION_ID_RE = re.compile(r"^(?:[\[\(]?S\d+[\]\)]?\s*[:\-]\s*)+", re.I)
def _clean_report_heading_text(text: str) -> str:
cleaned = _LEADING_HEADING_MARKERS_RE.sub("", (text or "").strip())
cleaned = _LEADING_SECTION_ID_RE.sub("", cleaned).strip()
return cleaned
def _normalise_markdown_headings(text: str) -> str:
lines: list[str] = []
for line in (text or "").splitlines():
match = _MARKDOWN_HEADING_RE.match(line)
if not match:
lines.append(line.rstrip())
continue
title = _clean_report_heading_text(match.group("title"))
hashes = match.group("hashes")
lines.append(f"{hashes} {title}" if title else hashes)
return "\n".join(lines)
def _citation_ids_in_first_appearance(
text: str,
citations: CitationManager,
) -> list[str]:
known = set(citations.get_all_citations())
ordered: list[str] = []
seen: set[str] = set()
for match in _REPORT_CITATION_MARKER_RE.finditer(text or ""):
citation_id = match.group("id")
if citation_id not in known or citation_id in seen:
continue
seen.add(citation_id)
ordered.append(citation_id)
return ordered
def _citation_source_preview(citation: dict[str, Any]) -> str:
if not citation:
return ""
tool_type = str(citation.get("tool_type") or "").lower()
if tool_type == "web_search":
sources = citation.get("web_sources")
if isinstance(sources, list):
hints = []
for source in sources[:3]:
if not isinstance(source, dict):
continue
title = str(source.get("title") or source.get("domain") or "").strip()
url = str(source.get("url") or "").strip()
if title and url:
hints.append(f"{title} <{url}>")
elif url:
hints.append(url)
return "; ".join(hints)
if tool_type == "paper_search":
papers = citation.get("papers")
if isinstance(papers, list):
hints = []
for paper in papers[:3]:
if not isinstance(paper, dict):
continue
title = str(paper.get("title") or "").strip()
year = str(paper.get("year") or "").strip()
if title:
hints.append(f"{title} ({year})" if year else title)
return "; ".join(hints)
if tool_type in {"rag", "rag_naive", "rag_hybrid"}:
sources = citation.get("sources")
if isinstance(sources, list):
hints = []
for source in sources[:3]:
if not isinstance(source, dict):
continue
title = str(source.get("title") or source.get("source_file") or "").strip()
page = str(source.get("page") or "").strip()
if title:
hints.append(f"{title} p.{page}" if page else title)
return "; ".join(hints)
return ""
def _section_with_block_ids(
section: ReportSectionPlan,
block_ids: tuple[str, ...],
) -> ReportSectionPlan:
return ReportSectionPlan(
id=section.id,
title=section.title,
intent=section.intent,
block_ids=block_ids,
)
def _report_tokens(text: str) -> set[str]:
tokens: set[str] = set()
for raw in _REPORT_TOKEN_RE.findall((text or "").lower()):
token = raw.strip()
if not token or token in _REPORT_STOPWORDS:
continue
if len(token) > 4 and token.endswith("ies"):
token = token[:-3] + "y"
elif len(token) > 3 and token.endswith("s"):
token = token[:-1]
tokens.add(token)
return tokens
def _report_overlap_score(left: str, right: str) -> float:
left_tokens = _report_tokens(left)
right_tokens = _report_tokens(right)
if not left_tokens or not right_tokens:
return 0.0
overlap = left_tokens & right_tokens
if not overlap:
return 0.0
return len(overlap) / max(1, min(len(left_tokens), len(right_tokens)))
def _best_block_for_section(
section: ReportSectionPlan,
blocks: list[ResearchedBlock],
*,
exclude: set[str],
) -> ResearchedBlock | None:
candidates = [rb for rb in blocks if rb.block.block_id not in exclude] or blocks
section_text = f"{section.title} {section.intent}"
best: tuple[float, ResearchedBlock] | None = None
for rb in candidates:
block_text = f"{rb.block.sub_topic} {rb.block.overview} {rb.knowledge[:500]}"
score = _report_overlap_score(section_text, block_text)
if best is None or score > best[0]:
best = (score, rb)
return best[1] if best is not None else None
def _best_section_for_block(
block: ResearchedBlock,
sections: list[ReportSectionPlan],
) -> int | None:
block_text = f"{block.block.sub_topic} {block.block.overview} {block.knowledge[:500]}"
best: tuple[float, int] | None = None
for idx, section in enumerate(sections):
section_text = f"{section.title} {section.intent}"
score = _report_overlap_score(block_text, section_text)
if best is None or score > best[0]:
best = (score, idx)
if best is None or best[0] <= 0:
return None
return best[1]
def _citation_anchor_id(citation_id: str) -> str:
return "ref-" + re.sub(r"[^a-z0-9_-]+", "-", citation_id.lower()).strip("-")
def _citation_markdown_link(citation_id: str, ref_number: int) -> str:
return f'[{ref_number}](#{_citation_anchor_id(citation_id)} "citation")'
def _sorted_citation_ids(citations: CitationManager) -> list[str]:
return sorted(citations.get_all_citations(), key=_citation_sort_key)
def _citation_number_map(citations: CitationManager) -> dict[str, int]:
return {
citation_id: index
for index, citation_id in enumerate(_sorted_citation_ids(citations), start=1)
}
def _citation_sort_key(citation_id: str) -> tuple[int, int, int]:
"""Sort PLAN and per-block CIT ids in the same order users see them."""
try:
if citation_id.startswith("PLAN-"):
return (0, 0, int(citation_id.replace("PLAN-", "", 1)))
if citation_id.startswith("CIT-"):
block_num, seq_num = citation_id.replace("CIT-", "", 1).split("-", 1)
return (1, int(block_num), int(seq_num))
except (IndexError, ValueError):
pass
return (999, 999, 999)
def _topic_index_from_block_id(block_id: str) -> int | None:
match = re.search(r"block_(\d+)", block_id or "")
if match is None:
return None
try:
return int(match.group(1))
except ValueError:
return None
def _research_topic_status_meta(block: TopicBlock) -> dict[str, Any]:
return {
"research_status_key": "research_topic",
"topic_index": _topic_index_from_block_id(block.block_id),
"topic_title": block.sub_topic,
}
def _read_int(cfg: Any, *, key: str, default: int) -> int:
if isinstance(cfg, dict):
value = cfg.get(key, default)
else:
value = default
try:
return int(value)
except (TypeError, ValueError):
return default
# ---------------------------------------------------------------------------
# _BlockLoopHost — per-block research loop callbacks
# ---------------------------------------------------------------------------
class _BlockLoopHost:
"""Binds a ``ResearchPipeline`` + one ``TopicBlock`` + queue + citation
store so the generic agentic loop can call back for per-block side
effects.
Three loop hooks are implemented beyond the default protocol:
* :meth:`dispatch_tools` — wraps the standard parallel dispatch with
a note-agent sidecar that summarises citable tool results and
records them in :class:`CitationManager`. The summary then
replaces the raw tool message content sent back to the model so
long retrievals don't blow the context budget.
* :meth:`on_intermediate` — handles the ``APPEND`` label. Parses
``<title>\n<overview?>`` out of the post-label text, runs dedup
+ capacity checks, appends a child block to the queue, and
returns a confirmation / rejection message that the loop injects
as the next iteration's user feedback.
* :meth:`force_finalize` — drives the per-block force-finish
recovery when the iteration budget is exhausted before a
terminal label fires.
"""
def __init__(
self,
*,
pipeline: "ResearchPipeline",
block: TopicBlock,
queue: DynamicTopicQueue,
citations: CitationManager,
topic: str,
stream: StreamBus,
context: UnifiedContext,
client: Any,
) -> None:
self._pipeline = pipeline
self._block = block
self._queue = queue
self._citations = citations
self._topic = topic
self._stream = stream
self._context = context
self._client = client
self._tool_rounds_used = 0
async def guard_context_window(self, messages: list[dict[str, Any]]) -> None:
# Per-block budgets keep messages bounded; no trimming for v1.
return None
def build_iteration_trace_meta(self, iteration: int) -> tuple[dict[str, Any], dict[str, Any]]:
status_meta = _research_topic_status_meta(self._block)
iter_call_id = new_call_id(f"research-{self._block.block_id}-iter-{iteration}")
iter_meta = build_trace_metadata(
call_id=iter_call_id,
phase="researching",
label=self._pipeline._t("labels.reasoning", default="Reasoning"),
call_kind="llm_reasoning",
trace_id=iter_call_id,
trace_role="thought",
trace_group="stage",
block_id=self._block.block_id,
**status_meta,
)
final_call_id = new_call_id(f"research-{self._block.block_id}-final")
final_meta = build_trace_metadata(
call_id=final_call_id,
phase="researching",
label=(
f"{self._pipeline._t('labels.research_step', default='Research step')}: "
f"{self._block.sub_topic}"
),
call_kind="llm_final_response",
trace_id=final_call_id,
trace_role="response",
trace_group="stage",
block_id=self._block.block_id,
**status_meta,
)
return iter_meta, final_meta
async def dispatch_tools(
self,
*,
iteration: int,
tool_calls: list[dict[str, Any]],
) -> DispatchOutcome:
too_many = None
if len(tool_calls) > MAX_PARALLEL_TOOL_CALLS:
too_many = self._pipeline._t(
"notices.too_many_tool_calls",
requested=len(tool_calls),
limit=MAX_PARALLEL_TOOL_CALLS,
)
outcome = await dispatch_tool_calls(
tool_calls=tool_calls,
context=self._context,
stream=self._stream,
source=SOURCE,
stage="researching",
iteration_index=iteration,
registry=self._pipeline.registry,
kwarg_augmenter=self._pipeline._augment_tool_kwargs,
retrieve_meta_factory=lambda meta, tn, ta: self._pipeline._retrieve_trace_metadata(
meta, tool_name=tn, tool_args=ta
),
tool_call_label=self._pipeline._t("labels.tool_call", default="Tool call"),
retrieve_label=self._pipeline._t("labels.retrieve", default="Retrieve"),
empty_tool_result_message=self._pipeline._t("notices.empty_tool_result"),
start_retrieval_message=self._pipeline._t(
"notices.start_retrieval", default="Starting retrieval"
),
too_many_tool_calls_message=too_many,
unknown_error_message_factory=lambda tn: self._pipeline._t(
"notices.tool_unknown_error",
tool=tn,
default=f"Error executing {tn}.",
),
trace_id_prefix=f"research-{self._block.block_id}-iter",
)
if tool_calls:
self._tool_rounds_used += 1
await self._summarise_and_record(tool_calls, outcome)
return outcome
async def validate_terminal(self, label: str, text: str) -> str | None:
"""Reject a first-turn FINISH when native evidence tools are callable.
The prompt tells the model to call a tool before FINISH, but some
models still jump straight to synthesis. This hook makes that rule
executable instead of prompt-only.
"""
if (
label == LABEL_FINISH
and self._tool_rounds_used <= 0
and self._pipeline._use_native_block_tools()
):
return "finish_without_tool"
return None
async def _summarise_and_record(
self,
tool_calls: list[dict[str, Any]],
outcome: DispatchOutcome,
) -> None:
"""Walk every emitted tool message; for citable tools, summarise
the raw answer through ``_summarise_tool_result``, register the
result in :class:`CitationManager`, and substitute the summary
back into the tool message body the LLM sees."""
if not outcome.tool_messages:
return
call_meta_by_id: dict[str, tuple[str, dict[str, Any]]] = {}
for tc in tool_calls:
cid = tc.get("id") or ""
name = str(tc.get("name") or "")
raw_args = tc.get("arguments") or {}
if isinstance(raw_args, str):
parsed = parse_json_response(raw_args, fallback={})
args = parsed if isinstance(parsed, dict) else {}
elif isinstance(raw_args, dict):
args = raw_args
else:
args = {}
call_meta_by_id[cid] = (name, args)
for tm in outcome.tool_messages:
tool_call_id = str(tm.get("tool_call_id") or "")
tool_name, tool_args = call_meta_by_id.get(tool_call_id, ("", {}))
if tool_name not in CITABLE_TOOLS:
continue
raw_answer = str(tm.get("content") or "")
if not raw_answer.strip():
continue
try:
query = str(tool_args.get("query") or "")
summary = await self._pipeline._summarise_tool_result(
tool_name=tool_name,
query=query,
raw_answer=raw_answer,
client=self._client,
)
citation_id = await self._citations.generate_research_citation_id_async(
self._block.block_id
)
trace = ToolTrace.create_with_size_limit(
tool_id=f"{self._block.block_id}-tool-{citation_id}",
citation_id=citation_id,
tool_type=tool_name,
query=query,
raw_answer=raw_answer,
summary=summary,
)
self._block.add_tool_trace(trace)
tool_metadata = outcome.tool_metadata_by_id.get(tool_call_id)
await self._citations.add_citation_async(
citation_id, tool_name, trace, raw_answer, tool_metadata
)
tm["content"] = f"[{citation_id}] {summary}"
except Exception:
logger.exception(
"Failed to record research citation for %s in %s",
tool_name,
self._block.block_id,
)
async def resolve_pause(self, dispatch: DispatchOutcome) -> bool:
# Per-block research never surfaces ask_user; clarification only
# happens in the rephrase phase.
return False
async def emit_terminator(self, payload: dict[str, Any] | None) -> None:
return None
async def emit_final(self, text: str, final_meta: dict[str, Any]) -> None:
# Block FINISH text is consumed by the reporting phase, not
# streamed live as user-facing content — streaming it here would
# dump raw per-block findings into the chat bubble before the
# actual report begins.
return None
def assistant_message_with_tool_calls(
self,
*,
content: str,
tool_calls: list[dict[str, Any]],
) -> dict[str, Any]:
return {
"role": "assistant",
"content": content or None,
"tool_calls": [
{
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": tc.get("arguments") or "{}",
},
}
for tc in tool_calls
],
}
def protocol_retry_notice(self) -> str:
return self._pipeline._t(
"notices.protocol_retry",
default="The model violated the action-label protocol; retrying.",
)
def protocol_repair_message(self, violation: str) -> str:
return self._pipeline._t(
f"protocol.{violation}",
default=f"Protocol violation: {violation}.",
)
async def force_finalize(
self,
*,
messages: list[dict[str, Any]],
start_iteration: int,
) -> tuple[str, bool, int]:
return await self._pipeline._force_finish_block(
client=self._client,
messages=messages,
stream=self._stream,
block=self._block,
start_iteration=start_iteration,
)
async def on_intermediate(self, label: str, text: str) -> str | None:
"""Parse ``APPEND`` payload and extend the queue.
Format: first line = title, remainder (optional) = overview.
Empty / duplicate / over-capacity proposals get a rejection note
the loop injects as the next iteration's user message so the LLM
can adapt rather than spam the same proposal.
"""
if label != LABEL_APPEND:
return None
first_line, _, rest = (text or "").strip().partition("\n")
# Strip leading markdown heading markers (``#``, ``##``, …) so the
# queue stores a clean title even if the LLM rendered the new
# sub-topic as a markdown header.
title = first_line.strip().lstrip("#").strip()
overview = rest.strip()
if not title:
return self._pipeline._t(
"notices.append_rejected_empty",
default="APPEND rejected: missing title.",
)
if self._queue.is_full():
await self._stream.progress(
self._pipeline._t(
"notices.append_rejected_full_progress",
title=title,
default=f"Queue is full; rejected append: {title}",
),
source=SOURCE,
stage="researching",
metadata={
"trace_kind": "queue_append_rejected",
"block_id": self._block.block_id,
"reason": "full",
"title": title,
},
)
return self._pipeline._t(
"notices.append_rejected_full",
default=(
"APPEND rejected: the topic queue is at capacity. Continue "
"researching the current block and emit FINISH when done."
),
)
dup = self._queue.find_similar(title)
if dup is not None:
await self._stream.progress(
self._pipeline._t(
"notices.append_rejected_dup_progress",
title=title,
existing=dup.block_id,
default=(
f"APPEND rejected: '{title}' is too similar to "
f"existing block {dup.block_id}"
),
),
source=SOURCE,
stage="researching",
metadata={
"trace_kind": "queue_append_rejected",
"block_id": self._block.block_id,
"reason": "duplicate",
"title": title,
"existing_id": dup.block_id,
},
)
return self._pipeline._t(
"notices.append_rejected_duplicate",
existing_id=dup.block_id,
existing_title=dup.sub_topic,
default=(
f"APPEND rejected: too similar to existing block "
f"{dup.block_id} ({dup.sub_topic!r})."
),
)
new_block = self._queue.append_child(parent=self._block, sub_topic=title, overview=overview)
if new_block is None:
return self._pipeline._t(
"notices.append_rejected_full",
default="APPEND rejected: queue is full.",
)
await self._stream.progress(
self._pipeline._t(
"notices.append_accepted_progress",
title=title,
new_block_id=new_block.block_id,
default=f"Sub-topic queued: {title}",
),
source=SOURCE,
stage="researching",
metadata={
"trace_kind": "queue_append",
"block_id": self._block.block_id,
"parent_block_id": self._block.block_id,
"new_block_id": new_block.block_id,
"title": title,
},
)
return self._pipeline._t(
"notices.append_accepted",
new_block_id=new_block.block_id,
title=title,
default=f"Appended block {new_block.block_id}: {title}",
)
# ---------------------------------------------------------------------------
# _RephraseLoopHost — ask_user-only mini loop
# ---------------------------------------------------------------------------
class _RephraseLoopHost:
"""Drives the rephrase mini-loop. Only ``ask_user`` is allowed as a
tool; the round cap (``rephrase_max_rounds``) is enforced here. When
exhausted, any further ``ask_user`` call is replied to with a tool
message instructing the model to FINISH with the best refined topic
it has."""
def __init__(
self,
*,
pipeline: "ResearchPipeline",
stream: StreamBus,
context: UnifiedContext,
client: Any,
max_rounds: int,
) -> None:
self._pipeline = pipeline
self._stream = stream
self._context = context
self._client = client
self._max_rounds = max(0, int(max_rounds))
self._rounds_used = 0
# Reuse one call_id across all rephrase iterations so the FE
# groups every THINK trace from before *and* after ``ask_user``
# into a single "Rephrasing" reasoning card. Without this each
# iter would get its own card and the post-answer reasoning
# would appear as a brand-new box below the user's reply.
self._shared_iter_call_id = new_call_id("research-rephrase-iter")
async def guard_context_window(self, messages: list[dict[str, Any]]) -> None:
return None
def build_iteration_trace_meta(self, iteration: int) -> tuple[dict[str, Any], dict[str, Any]]:
iter_meta = build_trace_metadata(
call_id=self._shared_iter_call_id,
phase="rephrasing",
label=self._pipeline._t("labels.rephrase", default="Rephrase"),
call_kind="llm_reasoning",
trace_id=self._shared_iter_call_id,
trace_role="thought",
trace_group="stage",
)
final_call_id = new_call_id(f"research-rephrase-final-{iteration}")
final_meta = build_trace_metadata(
call_id=final_call_id,
phase="rephrasing",
label=self._pipeline._t("labels.rephrase", default="Rephrase"),
call_kind="llm_final_response",
trace_id=final_call_id,
trace_role="response",
trace_group="stage",
)
return iter_meta, final_meta
async def dispatch_tools(
self,
*,
iteration: int,
tool_calls: list[dict[str, Any]],
) -> DispatchOutcome:
allowed: list[dict[str, Any]] = []
rejected: list[dict[str, Any]] = []
for tc in tool_calls:
if tc.get("name") == "ask_user":
allowed.append(tc)
else:
rejected.append(
{
"role": "tool",
"tool_call_id": tc.get("id"),
"name": tc.get("name", ""),
"content": self._pipeline._t(
"notices.rephrase_only_ask_user",
tool=tc.get("name", ""),
default=(
"Only `ask_user` is available in this phase. "
"Use it or emit FINISH with the refined topic."
),
),
}
)
if self._rounds_used >= self._max_rounds and allowed:
cap_messages = [
{
"role": "tool",
"tool_call_id": tc.get("id"),
"name": "ask_user",
"content": self._pipeline._t(
"notices.rephrase_cap_reached",
max_rounds=self._max_rounds,
default=(
f"ask_user limit reached ({self._max_rounds} "
"rounds). Emit FINISH now with the best refined "
"topic you can produce from prior answers."
),
),
}
for tc in allowed
]
return DispatchOutcome(sources=[], tool_messages=cap_messages + rejected)
if not allowed:
return DispatchOutcome(sources=[], tool_messages=rejected)
too_many = None
if len(allowed) > MAX_PARALLEL_TOOL_CALLS:
too_many = self._pipeline._t(
"notices.too_many_tool_calls",
requested=len(allowed),
limit=MAX_PARALLEL_TOOL_CALLS,
)
outcome = await dispatch_tool_calls(
tool_calls=allowed,
context=self._context,
stream=self._stream,
source=SOURCE,
stage="rephrasing",
iteration_index=iteration,
registry=self._pipeline.registry,
kwarg_augmenter=self._pipeline._augment_tool_kwargs,
retrieve_meta_factory=lambda meta, tn, ta: None,
tool_call_label=self._pipeline._t("labels.tool_call", default="Tool call"),
retrieve_label=self._pipeline._t("labels.retrieve", default="Retrieve"),
empty_tool_result_message=self._pipeline._t("notices.empty_tool_result"),
start_retrieval_message=self._pipeline._t(
"notices.start_retrieval", default="Starting retrieval"
),
too_many_tool_calls_message=too_many,
unknown_error_message_factory=lambda tn: self._pipeline._t(
"notices.tool_unknown_error",
tool=tn,
default=f"Error executing {tn}.",
),
trace_id_prefix="research-rephrase-iter",
)
if rejected:
outcome.tool_messages.extend(rejected)
self._rounds_used += 1
return outcome
async def resolve_pause(self, dispatch: DispatchOutcome) -> bool:
from deeptutor.agents.chat.agentic_pipeline import (
_format_user_reply_body,
_normalise_user_reply,
)
ask_user = (dispatch.pause_payload or {}).get("ask_user") or {}
waiter = self._context.metadata.get("wait_for_user_reply")
if not callable(waiter):
return False
raw_reply = await waiter()
if raw_reply is None:
return False
reply_text, answers = _normalise_user_reply(raw_reply)
body_text = _format_user_reply_body(reply_text, answers, ask_user)
for tm in dispatch.tool_messages:
if tm.get("tool_call_id") == dispatch.pause_tool_call_id:
tm["content"] = (
f"{body_text}\n\n[ask_user resolved. Continue to FINISH "
"with the refined research topic when you have enough "
"information.]"
)
break
progress_meta: dict[str, Any] = {
"trace_kind": "user_reply",
"ask_user_resolved": True,
"ask_user_tool_call_id": dispatch.pause_tool_call_id,
"reply_preview": (reply_text or "")[:200],
}
if answers:
progress_meta["answers"] = list(answers)
await self._stream.progress("", source=SOURCE, stage="rephrasing", metadata=progress_meta)
return True
async def emit_terminator(self, payload: dict[str, Any] | None) -> None:
return None
async def emit_final(self, text: str, final_meta: dict[str, Any]) -> None:
# The refined topic is internal; not streamed as user content.
return None
def assistant_message_with_tool_calls(
self,
*,
content: str,
tool_calls: list[dict[str, Any]],
) -> dict[str, Any]:
return {
"role": "assistant",
"content": content or None,
"tool_calls": [
{
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": tc.get("arguments") or "{}",
},
}
for tc in tool_calls
],
}
def protocol_retry_notice(self) -> str:
return self._pipeline._t(
"notices.protocol_retry",
default="The model violated the action-label protocol; retrying.",
)
def protocol_repair_message(self, violation: str) -> str:
return self._pipeline._t(
f"protocol.{violation}",
default=f"Protocol violation: {violation}.",
)
async def force_finalize(
self,
*,
messages: list[dict[str, Any]],
start_iteration: int,
) -> tuple[str, bool, int]:
# Rephrase exhaustion falls back to the raw topic (handled by
# the caller); we report no extra calls and "not completed" so
# the loop returns with an empty final_text.
return ("", False, 0)
# Re-export ``Awaitable`` so host implementations can type their
# coroutine return values without an extra import (mirrors solve).
_ = Awaitable # type: ignore[assignment]
__all__ = [
"CITABLE_TOOLS",
"LABEL_APPEND",
"LABEL_CONCLUSION",
"LABEL_FINISH",
"LABEL_INTRO",
"LABEL_OUTLINE",
"LABEL_SECTION",
"LABEL_THINK",
"LABEL_TOOL",
"ResearchPipeline",
"ResearchedBlock",
"ReportOutline",
"ReportSectionPlan",
"SOURCE",
"SubTopicItem",
]