Sfoglia il codice sorgente

feat: make AgentPlatformBase standalone

SHL 1 mese fa
parent
commit
81877aaadd
23 ha cambiato i file con 2026 aggiunte e 35 eliminazioni
  1. 4 4
      Co-creation-projects/huailishang-AgentPlatformBase/.env.example
  2. 7 2
      Co-creation-projects/huailishang-AgentPlatformBase/README.md
  3. 13 2
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/README.md
  4. 18 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/__init__.py
  5. 551 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/agent.py
  6. 143 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/config.py
  7. 190 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/main.py
  8. 51 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/models.py
  9. 110 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/prompts.py
  10. 2 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/__init__.py
  11. 59 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/notes.py
  12. 160 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/planner.py
  13. 77 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/reporter.py
  14. 109 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/search.py
  15. 125 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/summarizer.py
  16. 16 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/text_processing.py
  17. 215 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/tool_events.py
  18. 84 0
      Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/utils.py
  19. 54 23
      Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/adapters/deep_research.py
  20. 22 4
      Co-creation-projects/huailishang-AgentPlatformBase/backend/config.py
  21. 9 0
      Co-creation-projects/huailishang-AgentPlatformBase/backend/main.py
  22. 1 0
      Co-creation-projects/huailishang-AgentPlatformBase/main.py
  23. 6 0
      Co-creation-projects/huailishang-AgentPlatformBase/requirements.txt

+ 4 - 4
Co-creation-projects/huailishang-AgentPlatformBase/.env.example

@@ -2,14 +2,14 @@ APP_NAME=Agent Platform Base
 APP_HOST=127.0.0.1
 APP_PORT=8016
 
-# LLM settings. Copy real values from chapter14/chapter15 .env when needed.
+# LLM settings. Fill these with your own provider values when needed.
 LLM_PROVIDER=
 LLM_MODEL_ID=
 LLM_API_KEY=
 LLM_BASE_URL=
 LLM_TIMEOUT=120
 
-# Search settings for the future chapter14 deep research adapter.
+# Search settings for the built-in deep research adapter.
 SEARCH_API=duckduckgo
 TAVILY_API_KEY=
 SERPAPI_API_KEY=
@@ -40,8 +40,8 @@ NEO4J_USERNAME=
 NEO4J_PASSWORD=
 NEO4J_DATABASE=neo4j
 
-# Chapter14 integration path.
-CHAPTER14_BACKEND_PATH=../chapter14/helloagents-deepresearch-fixed/backend/src
+# Built-in DeepResearch path. Leave empty to use agents/deep_research/src.
+CHAPTER14_BACKEND_PATH=./agents/deep_research/src
 
 # Built-in RSS digest agent paths.
 RSS_DIGEST_ROOT=./agents/rss_digest

+ 7 - 2
Co-creation-projects/huailishang-AgentPlatformBase/README.md

@@ -6,7 +6,7 @@
 
 - 统一智能体注册表:后端通过 `AgentRegistry` 管理不同智能体。
 - 后台任务执行:长任务默认后台运行,前端轮询任务状态,不阻塞输入框。
-- 搜索员:封装 chapter14 的 DeepResearchAgent,生成调研报告并保留运行产物和长期笔记。
+- 搜索员:内置 DeepResearchAgent,生成调研报告并保留运行产物和长期笔记。
 - 资讯员:拉取 RSS、抽取正文、调用 LLM 生成中文摘要,并渲染 HTML 简报。
 - 数据分区:所有智能体数据统一放在 `data/{agent_id}/`,便于清理和提交时忽略。
 
@@ -38,6 +38,10 @@ agent_platform_base/
   agents/
     deep_research/
       README.md
+      src/
+        agent.py
+        config.py
+        services/
     rss_digest/
       src/rss_digest/
       config/
@@ -72,7 +76,8 @@ agent_platform_base/
 - Python 3.10+
 - FastAPI / Uvicorn
 - Pydantic
-- Requests / Feedparser / BeautifulSoup / Readability
+- hello-agents / OpenAI SDK / Tavily / DDGS
+- Requests / Python 标准库 RSS 与 HTML 解析
 - 原生 HTML、CSS、JavaScript
 
 ## 快速开始

+ 13 - 2
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/README.md

@@ -1,8 +1,19 @@
 # deep_research
 
-`deep_research` 当前通过 `backend/agents/adapters/deep_research.py` 封装 chapter14 的 `DeepResearchAgent`。
+`deep_research` 是 chapter16 平台内置的搜索调研智能体,源码位于:
 
-本目录预留给后续把搜索员业务实现内迁到 chapter16 时使用。当前运行数据写入 `data/deep_research/`:
+```text
+agents/deep_research/src/
+```
+
+这份源码来自 chapter14 的 DeepResearchAgent,并已内置到 chapter16 项目中。默认运行不再依赖 `code/chapter14`,因此只保留 `code/chapter16/agent_platform_base` 也可以运行搜索员。
+
+运行数据写入:
+
+```text
+data/deep_research/runs/
+data/deep_research/notes/
+```
 
 - `runs/`:单次运行过程产物,可按保留期清理。
 - `notes/`:研究笔记和索引,默认长期保留。

+ 18 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/__init__.py

@@ -0,0 +1,18 @@
+"""HelloAgents Deep Research - A deep research assistant powered by HelloAgents."""
+
+__version__ = "0.0.1"
+
+from .agent import DeepResearchAgent
+from .config import Configuration, SearchAPI
+from .models import SummaryState, SummaryStateInput, SummaryStateOutput, TodoItem
+
+__all__ = [
+    "DeepResearchAgent",
+    "Configuration",
+    "SearchAPI",
+    "SummaryState",
+    "SummaryStateInput",
+    "SummaryStateOutput",
+    "TodoItem",
+]
+

+ 551 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/agent.py

@@ -0,0 +1,551 @@
+"""Orchestrator coordinating the deep research workflow."""
+
+from __future__ import annotations
+
+import logging
+import re
+from pathlib import Path
+from queue import Empty, Queue
+from threading import Lock, Thread
+from typing import Any, Callable, Iterator
+
+from hello_agents import HelloAgentsLLM, ToolAwareSimpleAgent
+from hello_agents.tools import ToolRegistry
+from hello_agents.tools.builtin.note_tool import NoteTool
+
+from config import Configuration
+from prompts import (
+    report_writer_instructions,
+    task_summarizer_instructions,
+    todo_planner_system_prompt,
+)
+from models import SummaryState, SummaryStateOutput, TodoItem
+from services.planner import PlanningService
+from services.reporter import ReportingService
+from services.search import dispatch_search, prepare_research_context
+from services.summarizer import SummarizationService
+from services.tool_events import ToolCallTracker
+
+logger = logging.getLogger(__name__)
+
+
+class DeepResearchAgent:
+    """Coordinator orchestrating TODO-based research workflow using HelloAgents."""
+
+    def __init__(self, config: Configuration | None = None) -> None:
+        """Initialise the coordinator with configuration and shared tools."""
+        self.config = config or Configuration.from_env()
+        self.llm = self._init_llm()
+
+        self.note_tool = (
+            NoteTool(workspace=self.config.notes_workspace)
+            if self.config.enable_notes
+            else None
+        )
+        self.tools_registry: ToolRegistry | None = None
+        if self.note_tool:
+            registry = ToolRegistry()
+            registry.register_tool(self.note_tool)
+            self.tools_registry = registry
+
+        self._tool_tracker = ToolCallTracker(
+            self.config.notes_workspace if self.config.enable_notes else None
+        )
+        self._tool_event_sink_enabled = False
+        self._state_lock = Lock()
+
+        self.todo_agent = self._create_tool_aware_agent(
+            name="研究规划专家",
+            system_prompt=todo_planner_system_prompt.strip(),
+        )
+        self.report_agent = self._create_tool_aware_agent(
+            name="报告撰写专家",
+            system_prompt=report_writer_instructions.strip(),
+        )
+
+        self._summarizer_factory: Callable[[], ToolAwareSimpleAgent] = lambda: self._create_tool_aware_agent(  # noqa: E501
+            name="任务总结专家",
+            system_prompt=task_summarizer_instructions.strip(),
+        )
+
+        self.planner = PlanningService(self.todo_agent, self.config)
+        self.summarizer = SummarizationService(self._summarizer_factory, self.config)
+        self.reporting = ReportingService(self.report_agent, self.config)
+        self._last_search_notices: list[str] = []
+
+    # ------------------------------------------------------------------
+    # Public API
+    # ------------------------------------------------------------------
+    def _init_llm(self) -> HelloAgentsLLM:
+        """Instantiate HelloAgentsLLM following configuration preferences."""
+        llm_kwargs: dict[str, Any] = {"temperature": 0.0}
+
+        model_id = self.config.llm_model_id or self.config.local_llm
+        if model_id:
+            llm_kwargs["model"] = model_id
+
+        provider = (self.config.llm_provider or "").strip()
+        if provider:
+            llm_kwargs["provider"] = provider
+
+        if provider == "ollama":
+            llm_kwargs["base_url"] = self.config.sanitized_ollama_url()
+            if self.config.llm_api_key:
+                llm_kwargs["api_key"] = self.config.llm_api_key
+            else:
+                llm_kwargs["api_key"] = "ollama"
+        elif provider == "lmstudio":
+            llm_kwargs["base_url"] = self.config.lmstudio_base_url
+            if self.config.llm_api_key:
+                llm_kwargs["api_key"] = self.config.llm_api_key
+        else:
+            if self.config.llm_base_url:
+                llm_kwargs["base_url"] = self.config.llm_base_url
+            if self.config.llm_api_key:
+                llm_kwargs["api_key"] = self.config.llm_api_key
+
+        return HelloAgentsLLM(**llm_kwargs)
+
+    def _create_tool_aware_agent(self, *, name: str, system_prompt: str) -> ToolAwareSimpleAgent:
+        """Instantiate a ToolAwareSimpleAgent sharing tool registry and tracker."""
+        return ToolAwareSimpleAgent(
+            name=name,
+            llm=self.llm,
+            system_prompt=system_prompt,
+            enable_tool_calling=self.tools_registry is not None,
+            tool_registry=self.tools_registry,
+            tool_call_listener=self._tool_tracker.record,
+        )
+
+    def _set_tool_event_sink(self, sink: Callable[[dict[str, Any]], None] | None) -> None:
+        """Enable or disable immediate tool event callbacks."""
+        self._tool_event_sink_enabled = sink is not None
+        self._tool_tracker.set_event_sink(sink)
+
+    def run(self, topic: str) -> SummaryStateOutput:
+        """Execute the research workflow and return the final report."""
+        state = SummaryState(research_topic=topic)
+        state.todo_items = self.planner.plan_todo_list(state)
+        self._drain_tool_events(state)
+
+        if not state.todo_items:
+            logger.info("No TODO items generated; falling back to single task")
+            state.todo_items = [self.planner.create_fallback_task(state)]
+
+        for task in state.todo_items:
+            self._execute_task(state, task, emit_stream=False)
+
+        report = self.reporting.generate_report(state)
+        self._drain_tool_events(state)
+        state.structured_report = report
+        state.running_summary = report
+        self._persist_final_report(state, report)
+
+        return SummaryStateOutput(
+            running_summary=report,
+            report_markdown=report,
+            todo_items=state.todo_items,
+        )
+
+    def run_stream(self, topic: str) -> Iterator[dict[str, Any]]:
+        """Execute the workflow yielding incremental progress events."""
+        state = SummaryState(research_topic=topic)
+        logger.debug("Starting streaming research: topic=%s", topic)
+        yield {"type": "status", "message": "初始化研究流程"}
+
+        state.todo_items = self.planner.plan_todo_list(state)
+        for event in self._drain_tool_events(state, step=0):
+            yield event
+        if not state.todo_items:
+            state.todo_items = [self.planner.create_fallback_task(state)]
+
+        channel_map: dict[int, dict[str, Any]] = {}
+        for index, task in enumerate(state.todo_items, start=1):
+            token = f"task_{task.id}"
+            task.stream_token = token
+            channel_map[task.id] = {"step": index, "token": token}
+
+        yield {
+            "type": "todo_list",
+            "tasks": [self._serialize_task(t) for t in state.todo_items],
+            "step": 0,
+        }
+
+        event_queue: Queue[dict[str, Any]] = Queue()
+
+        def enqueue(
+            event: dict[str, Any],
+            *,
+            task: TodoItem | None = None,
+            step_override: int | None = None,
+        ) -> None:
+            payload = dict(event)
+            target_task_id = payload.get("task_id")
+            if task is not None:
+                target_task_id = task.id
+                payload["task_id"] = task.id
+
+            channel = channel_map.get(target_task_id) if target_task_id is not None else None
+            if channel:
+                payload.setdefault("step", channel["step"])
+                payload["stream_token"] = channel["token"]
+            if step_override is not None:
+                payload["step"] = step_override
+            event_queue.put(payload)
+
+        def tool_event_sink(event: dict[str, Any]) -> None:
+            enqueue(event)
+
+        self._set_tool_event_sink(tool_event_sink)
+
+        threads: list[Thread] = []
+
+        def worker(task: TodoItem, step: int) -> None:
+            try:
+                enqueue(
+                    {
+                        "type": "task_status",
+                        "task_id": task.id,
+                        "status": "in_progress",
+                        "title": task.title,
+                        "intent": task.intent,
+                        "note_id": task.note_id,
+                        "note_path": task.note_path,
+                    },
+                    task=task,
+                )
+
+                for event in self._execute_task(state, task, emit_stream=True, step=step):
+                    enqueue(event, task=task)
+            except Exception as exc:  # pragma: no cover - defensive guardrail
+                logger.exception("Task execution failed", exc_info=exc)
+                enqueue(
+                    {
+                        "type": "task_status",
+                        "task_id": task.id,
+                        "status": "failed",
+                        "detail": str(exc),
+                        "title": task.title,
+                        "intent": task.intent,
+                        "note_id": task.note_id,
+                        "note_path": task.note_path,
+                    },
+                    task=task,
+                )
+            finally:
+                enqueue({"type": "__task_done__", "task_id": task.id})
+
+        for task in state.todo_items:
+            step = channel_map.get(task.id, {}).get("step", 0)
+            thread = Thread(target=worker, args=(task, step), daemon=True)
+            threads.append(thread)
+            thread.start()
+
+        active_workers = len(state.todo_items)
+        finished_workers = 0
+
+        try:
+            while finished_workers < active_workers:
+                event = event_queue.get()
+                if event.get("type") == "__task_done__":
+                    finished_workers += 1
+                    continue
+                yield event
+
+            while True:
+                try:
+                    event = event_queue.get_nowait()
+                except Empty:
+                    break
+                if event.get("type") != "__task_done__":
+                    yield event
+        finally:
+            self._set_tool_event_sink(None)
+            for thread in threads:
+                thread.join()
+
+        report = self.reporting.generate_report(state)
+        final_step = len(state.todo_items) + 1
+        for event in self._drain_tool_events(state, step=final_step):
+            yield event
+        state.structured_report = report
+        state.running_summary = report
+
+        note_event = self._persist_final_report(state, report)
+        if note_event:
+            yield note_event
+
+        yield {
+            "type": "final_report",
+            "report": report,
+            "note_id": state.report_note_id,
+            "note_path": state.report_note_path,
+        }
+        yield {"type": "done"}
+
+    # ------------------------------------------------------------------
+    # Execution helpers
+    # ------------------------------------------------------------------
+    def _execute_task(
+        self,
+        state: SummaryState,
+        task: TodoItem,
+        *,
+        emit_stream: bool,
+        step: int | None = None,
+    ) -> Iterator[dict[str, Any]]:
+        """Run search + summarization for a single task."""
+        task.status = "in_progress"
+
+        search_result, notices, answer_text, backend = dispatch_search(
+            task.query,
+            self.config,
+            state.research_loop_count,
+        )
+        self._last_search_notices = notices
+        task.notices = notices
+
+        if emit_stream:
+            for event in self._drain_tool_events(state, step=step):
+                yield event
+        else:
+            self._drain_tool_events(state)
+
+        if notices and emit_stream:
+            for notice in notices:
+                if notice:
+                    yield {
+                        "type": "status",
+                        "message": notice,
+                        "task_id": task.id,
+                        "step": step,
+                    }
+
+        if not search_result or not search_result.get("results"):
+            task.status = "skipped"
+            if emit_stream:
+                for event in self._drain_tool_events(state, step=step):
+                    yield event
+                yield {
+                    "type": "task_status",
+                    "task_id": task.id,
+                    "status": "skipped",
+                    "title": task.title,
+                    "intent": task.intent,
+                    "note_id": task.note_id,
+                    "note_path": task.note_path,
+                    "step": step,
+                }
+            else:
+                self._drain_tool_events(state)
+            return
+        else:
+            if not emit_stream:
+                self._drain_tool_events(state)
+
+        sources_summary, context = prepare_research_context(
+            search_result,
+            answer_text,
+            self.config,
+        )
+
+        task.sources_summary = sources_summary
+
+        with self._state_lock:
+            state.web_research_results.append(context)
+            state.sources_gathered.append(sources_summary)
+            state.research_loop_count += 1
+
+        summary_text: str | None = None
+
+        if emit_stream:
+            for event in self._drain_tool_events(state, step=step):
+                yield event
+            yield {
+                "type": "sources",
+                "task_id": task.id,
+                "latest_sources": sources_summary,
+                "raw_context": context,
+                "step": step,
+                "backend": backend,
+                "note_id": task.note_id,
+                "note_path": task.note_path,
+            }
+
+            summary_stream, summary_getter = self.summarizer.stream_task_summary(state, task, context)
+            try:
+                for event in self._drain_tool_events(state, step=step):
+                    yield event
+                for chunk in summary_stream:
+                    if chunk:
+                        yield {
+                            "type": "task_summary_chunk",
+                            "task_id": task.id,
+                            "content": chunk,
+                            "note_id": task.note_id,
+                            "step": step,
+                        }
+                    for event in self._drain_tool_events(state, step=step):
+                        yield event
+            finally:
+                summary_text = summary_getter()
+        else:
+            summary_text = self.summarizer.summarize_task(state, task, context)
+            self._drain_tool_events(state)
+
+        task.summary = summary_text.strip() if summary_text else "暂无可用信息"
+        task.status = "completed"
+
+        if emit_stream:
+            for event in self._drain_tool_events(state, step=step):
+                yield event
+            yield {
+                "type": "task_status",
+                "task_id": task.id,
+                "status": "completed",
+                "summary": task.summary,
+                "sources_summary": task.sources_summary,
+                "note_id": task.note_id,
+                "note_path": task.note_path,
+                "step": step,
+            }
+        else:
+            self._drain_tool_events(state)
+
+    def _drain_tool_events(
+        self,
+        state: SummaryState,
+        *,
+        step: int | None = None,
+    ) -> list[dict[str, Any]]:
+        """Proxy to the shared tool call tracker."""
+        events = self._tool_tracker.drain(state, step=step)
+        if self._tool_event_sink_enabled:
+            return []
+        return events
+
+    @property
+    def _tool_call_events(self) -> list[dict[str, Any]]:
+        """Expose recorded tool events for legacy integrations."""
+        return self._tool_tracker.as_dicts()
+
+    def _serialize_task(self, task: TodoItem) -> dict[str, Any]:
+        """Convert task dataclass to serializable dict for frontend."""
+        return {
+            "id": task.id,
+            "title": task.title,
+            "intent": task.intent,
+            "query": task.query,
+            "status": task.status,
+            "summary": task.summary,
+            "sources_summary": task.sources_summary,
+            "note_id": task.note_id,
+            "note_path": task.note_path,
+            "stream_token": task.stream_token,
+        }
+
+    def _persist_final_report(self, state: SummaryState, report: str) -> dict[str, Any] | None:
+        if not self.note_tool or not report or not report.strip():
+            return None
+
+        note_title = f"研究报告:{state.research_topic}".strip() or "研究报告"
+        tags = ["deep_research", "report"]
+        content = report.strip()
+
+        note_id = self._find_existing_report_note_id(state)
+        response = ""
+
+        if note_id:
+            response = self.note_tool.run(
+                {
+                    "action": "update",
+                    "note_id": note_id,
+                    "title": note_title,
+                    "note_type": "conclusion",
+                    "tags": tags,
+                    "content": content,
+                }
+            )
+            if response.startswith("❌"):
+                note_id = None
+
+        if not note_id:
+            response = self.note_tool.run(
+                {
+                    "action": "create",
+                    "title": note_title,
+                    "note_type": "conclusion",
+                    "tags": tags,
+                    "content": content,
+                }
+            )
+            note_id = self._extract_note_id_from_text(response)
+
+        if not note_id:
+            return None
+
+        state.report_note_id = note_id
+        if self.config.notes_workspace:
+            note_path = Path(self.config.notes_workspace) / f"{note_id}.md"
+            state.report_note_path = str(note_path)
+        else:
+            note_path = None
+
+        payload = {
+            "type": "report_note",
+            "note_id": note_id,
+            "title": note_title,
+            "content": content,
+        }
+        if note_path:
+            payload["note_path"] = str(note_path)
+
+        return payload
+
+    def _find_existing_report_note_id(self, state: SummaryState) -> str | None:
+        if state.report_note_id:
+            return state.report_note_id
+
+        for event in reversed(self._tool_tracker.as_dicts()):
+            if event.get("tool") != "note":
+                continue
+
+            parameters = event.get("parsed_parameters") or {}
+            if not isinstance(parameters, dict):
+                continue
+
+            action = parameters.get("action")
+            if action not in {"create", "update"}:
+                continue
+
+            note_type = parameters.get("note_type")
+            if note_type != "conclusion":
+                title = parameters.get("title")
+                if not (isinstance(title, str) and title.startswith("研究报告")):
+                    continue
+
+            note_id = parameters.get("note_id")
+            if not note_id:
+                note_id = self._tool_tracker._extract_note_id(event.get("result", ""))  # type: ignore[attr-defined]
+
+            if note_id:
+                return note_id
+
+        return None
+
+    @staticmethod
+    def _extract_note_id_from_text(response: str) -> str | None:
+        if not response:
+            return None
+
+        match = re.search(r"ID:\s*([^\n]+)", response)
+        if not match:
+            return None
+
+        return match.group(1).strip()
+
+
+def run_deep_research(topic: str, config: Configuration | None = None) -> SummaryStateOutput:
+    """Convenience function mirroring the class-based API."""
+    agent = DeepResearchAgent(config=config)
+    return agent.run(topic)

+ 143 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/config.py

@@ -0,0 +1,143 @@
+import os
+from enum import Enum
+from typing import Any, Optional
+
+from pydantic import BaseModel, Field
+
+
+class SearchAPI(Enum):
+    PERPLEXITY = "perplexity"
+    TAVILY = "tavily"
+    DUCKDUCKGO = "duckduckgo"
+    SEARXNG = "searxng"
+    ADVANCED = "advanced"
+
+
+class Configuration(BaseModel):
+    """Configuration options for the deep research assistant."""
+
+    max_web_research_loops: int = Field(
+        default=3,
+        title="Research Depth",
+        description="Number of research iterations to perform",
+    )
+    local_llm: str = Field(
+        default="llama3.2",
+        title="Local Model Name",
+        description="Name of the locally hosted LLM (Ollama/LMStudio)",
+    )
+    llm_provider: str = Field(
+        default="ollama",
+        title="LLM Provider",
+        description="Provider identifier (ollama, lmstudio, or custom)",
+    )
+    search_api: SearchAPI = Field(
+        default=SearchAPI.DUCKDUCKGO,
+        title="Search API",
+        description="Web search API to use",
+    )
+    enable_notes: bool = Field(
+        default=True,
+        title="Enable Notes",
+        description="Whether to store task progress in NoteTool",
+    )
+    notes_workspace: str = Field(
+        default="./notes",
+        title="Notes Workspace",
+        description="Directory for NoteTool to persist task notes",
+    )
+    fetch_full_page: bool = Field(
+        default=True,
+        title="Fetch Full Page",
+        description="Include the full page content in the search results",
+    )
+    ollama_base_url: str = Field(
+        default="http://localhost:11434",
+        title="Ollama Base URL",
+        description="Base URL for Ollama API (without /v1 suffix)",
+    )
+    lmstudio_base_url: str = Field(
+        default="http://localhost:1234/v1",
+        title="LMStudio Base URL",
+        description="Base URL for LMStudio OpenAI-compatible API",
+    )
+    strip_thinking_tokens: bool = Field(
+        default=True,
+        title="Strip Thinking Tokens",
+        description="Whether to strip <think> tokens from model responses",
+    )
+    use_tool_calling: bool = Field(
+        default=False,
+        title="Use Tool Calling",
+        description="Use tool calling instead of JSON mode for structured output",
+    )
+    llm_api_key: Optional[str] = Field(
+        default=None,
+        title="LLM API Key",
+        description="Optional API key when using custom OpenAI-compatible services",
+    )
+    llm_base_url: Optional[str] = Field(
+        default=None,
+        title="LLM Base URL",
+        description="Optional base URL when using custom OpenAI-compatible services",
+    )
+    llm_model_id: Optional[str] = Field(
+        default=None,
+        title="LLM Model ID",
+        description="Optional model identifier for custom OpenAI-compatible services",
+    )
+
+    @classmethod
+    def from_env(cls, overrides: Optional[dict[str, Any]] = None) -> "Configuration":
+        """Create a configuration object using environment variables and overrides."""
+
+        raw_values: dict[str, Any] = {}
+
+        # Load values from environment variables based on field names
+        for field_name in cls.model_fields.keys():
+            env_key = field_name.upper()
+            if env_key in os.environ:
+                raw_values[field_name] = os.environ[env_key]
+
+        # Additional mappings for explicit env names
+        env_aliases = {
+            "local_llm": os.getenv("LOCAL_LLM"),
+            "llm_provider": os.getenv("LLM_PROVIDER"),
+            "llm_api_key": os.getenv("LLM_API_KEY"),
+            "llm_model_id": os.getenv("LLM_MODEL_ID"),
+            "llm_base_url": os.getenv("LLM_BASE_URL"),
+            "lmstudio_base_url": os.getenv("LMSTUDIO_BASE_URL"),
+            "ollama_base_url": os.getenv("OLLAMA_BASE_URL"),
+            "max_web_research_loops": os.getenv("MAX_WEB_RESEARCH_LOOPS"),
+            "fetch_full_page": os.getenv("FETCH_FULL_PAGE"),
+            "strip_thinking_tokens": os.getenv("STRIP_THINKING_TOKENS"),
+            "use_tool_calling": os.getenv("USE_TOOL_CALLING"),
+            "search_api": os.getenv("SEARCH_API"),
+            "enable_notes": os.getenv("ENABLE_NOTES"),
+            "notes_workspace": os.getenv("NOTES_WORKSPACE"),
+        }
+
+        for key, value in env_aliases.items():
+            if value is not None:
+                raw_values.setdefault(key, value)
+
+        if overrides:
+            for key, value in overrides.items():
+                if value is not None:
+                    raw_values[key] = value
+
+        return cls(**raw_values)
+
+    def sanitized_ollama_url(self) -> str:
+        """Ensure Ollama base URL includes the /v1 suffix required by OpenAI clients."""
+
+        base = self.ollama_base_url.rstrip("/")
+        if not base.endswith("/v1"):
+            base = f"{base}/v1"
+        return base
+
+    def resolved_model(self) -> Optional[str]:
+        """Best-effort resolution of the model identifier to use."""
+
+        return self.llm_model_id or self.local_llm
+

+ 190 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/main.py

@@ -0,0 +1,190 @@
+"""FastAPI entrypoint exposing the DeepResearchAgent via HTTP."""
+
+from __future__ import annotations
+
+import json
+import sys
+from typing import Any, Dict, Iterator, Optional
+
+from fastapi import FastAPI, HTTPException
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import StreamingResponse
+from loguru import logger
+from pydantic import BaseModel, Field
+
+from config import Configuration, SearchAPI
+from agent import DeepResearchAgent
+
+# 添加控制台日志处理程序
+logger.add(
+    sys.stderr,
+    level="INFO",
+    format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <4}</level> | <cyan>using_function:{function}</cyan> | <cyan>{file}:{line}</cyan> | <level>{message}</level>",
+    colorize=True,
+)
+
+
+# 添加错误日志文件处理程序
+logger.add(
+    sink=sys.stderr,
+    level="ERROR",
+    format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <4}</level> | <cyan>using_function:{function}</cyan> | <cyan>{file}:{line}</cyan> | <level>{message}</level>",
+    colorize=True,
+)
+
+
+class ResearchRequest(BaseModel):
+    """Payload for triggering a research run."""
+
+    topic: str = Field(..., description="Research topic supplied by the user")
+    search_api: SearchAPI | None = Field(
+        default=None,
+        description="Override the default search backend configured via env",
+    )
+
+
+class ResearchResponse(BaseModel):
+    """HTTP response containing the generated report and structured tasks."""
+
+    report_markdown: str = Field(
+        ..., description="Markdown-formatted research report including sections"
+    )
+    todo_items: list[dict[str, Any]] = Field(
+        default_factory=list,
+        description="Structured TODO items with summaries and sources",
+    )
+
+
+def _mask_secret(value: Optional[str], visible: int = 4) -> str:
+    """Mask sensitive tokens while keeping leading and trailing characters."""
+    if not value:
+        return "unset"
+
+    if len(value) <= visible * 2:
+        return "*" * len(value)
+
+    return f"{value[:visible]}...{value[-visible:]}"
+
+
+def _build_config(payload: ResearchRequest) -> Configuration:
+    overrides: Dict[str, Any] = {}
+
+    if payload.search_api is not None:
+        overrides["search_api"] = payload.search_api
+
+    return Configuration.from_env(overrides=overrides)
+
+
+def create_app() -> FastAPI:
+    app = FastAPI(title="HelloAgents Deep Researcher")
+
+    app.add_middleware(
+        CORSMiddleware,
+        allow_origins=["*"],
+        allow_credentials=True,
+        allow_methods=["*"],
+        allow_headers=["*"],
+    )
+
+    @app.on_event("startup")
+    def log_startup_configuration() -> None:
+        config = Configuration.from_env()
+
+        if config.llm_provider == "ollama":
+            base_url = config.sanitized_ollama_url()
+        elif config.llm_provider == "lmstudio":
+            base_url = config.lmstudio_base_url
+        else:
+            base_url = config.llm_base_url or "unset"
+
+        logger.info(
+            "DeepResearch configuration loaded: provider=%s model=%s base_url=%s search_api=%s "
+            "max_loops=%s fetch_full_page=%s tool_calling=%s strip_thinking=%s api_key=%s",
+            config.llm_provider,
+            config.resolved_model() or "unset",
+            base_url,
+            (config.search_api.value if isinstance(config.search_api, SearchAPI) else config.search_api),
+            config.max_web_research_loops,
+            config.fetch_full_page,
+            config.use_tool_calling,
+            config.strip_thinking_tokens,
+            _mask_secret(config.llm_api_key),
+        )
+
+    @app.get("/healthz")
+    def health_check() -> Dict[str, str]:
+        return {"status": "ok"}
+
+    @app.post("/research", response_model=ResearchResponse)
+    def run_research(payload: ResearchRequest) -> ResearchResponse:
+        try:
+            config = _build_config(payload)
+            agent = DeepResearchAgent(config=config)
+            result = agent.run(payload.topic)
+        except ValueError as exc:  # Likely due to unsupported configuration
+            raise HTTPException(status_code=400, detail=str(exc)) from exc
+        except Exception as exc:  # pragma: no cover - defensive guardrail
+            raise HTTPException(status_code=500, detail="Research failed") from exc
+
+        todo_payload = [
+            {
+                "id": item.id,
+                "title": item.title,
+                "intent": item.intent,
+                "query": item.query,
+                "status": item.status,
+                "summary": item.summary,
+                "sources_summary": item.sources_summary,
+                "note_id": item.note_id,
+                "note_path": item.note_path,
+            }
+            for item in result.todo_items
+        ]
+
+        return ResearchResponse(
+            report_markdown=(result.report_markdown or result.running_summary or ""),
+            todo_items=todo_payload,
+        )
+
+    @app.post("/research/stream")
+    def stream_research(payload: ResearchRequest) -> StreamingResponse:
+        try:
+            config = _build_config(payload)
+            agent = DeepResearchAgent(config=config)
+        except ValueError as exc:
+            raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+        def event_iterator() -> Iterator[str]:
+            try:
+                for event in agent.run_stream(payload.topic):
+                    yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
+            except Exception as exc:  # pragma: no cover - defensive guardrail
+                logger.exception("Streaming research failed")
+                error_payload = {"type": "error", "detail": str(exc)}
+                yield f"data: {json.dumps(error_payload, ensure_ascii=False)}\n\n"
+
+        return StreamingResponse(
+            event_iterator(),
+            media_type="text/event-stream",
+            headers={
+                "Cache-Control": "no-cache",
+                "Connection": "keep-alive",
+            },
+        )
+
+    return app
+
+
+app = create_app()
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run(
+        "main:app",
+        host="0.0.0.0",
+        port=8000,
+        reload=True,
+        log_level="info"
+    )

+ 51 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/models.py

@@ -0,0 +1,51 @@
+"""State models used by the deep research workflow."""
+
+import operator
+from dataclasses import dataclass, field
+from typing import List, Optional
+
+from typing_extensions import Annotated
+
+
+@dataclass(kw_only=True)
+class TodoItem:
+    """单个待办任务项。"""
+
+    id: int
+    title: str
+    intent: str
+    query: str
+    status: str = field(default="pending")
+    summary: Optional[str] = field(default=None)
+    sources_summary: Optional[str] = field(default=None)
+    notices: list[str] = field(default_factory=list)
+    note_id: Optional[str] = field(default=None)
+    note_path: Optional[str] = field(default=None)
+    stream_token: Optional[str] = field(default=None)
+
+
+@dataclass(kw_only=True)
+class SummaryState:
+    research_topic: str = field(default=None)  # Report topic
+    search_query: str = field(default=None)  # Deprecated placeholder
+    web_research_results: Annotated[list, operator.add] = field(default_factory=list)
+    sources_gathered: Annotated[list, operator.add] = field(default_factory=list)
+    research_loop_count: int = field(default=0)  # Research loop count
+    running_summary: str = field(default=None)  # Legacy summary field
+    todo_items: Annotated[list, operator.add] = field(default_factory=list)
+    structured_report: Optional[str] = field(default=None)
+    report_note_id: Optional[str] = field(default=None)
+    report_note_path: Optional[str] = field(default=None)
+
+
+@dataclass(kw_only=True)
+class SummaryStateInput:
+    research_topic: str = field(default=None)  # Report topic
+
+
+@dataclass(kw_only=True)
+class SummaryStateOutput:
+    running_summary: str = field(default=None)  # Backward-compatible文本
+    report_markdown: Optional[str] = field(default=None)
+    todo_items: List[TodoItem] = field(default_factory=list)
+

+ 110 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/prompts.py

@@ -0,0 +1,110 @@
+from datetime import datetime
+
+
+# Get current date in a readable format
+def get_current_date():
+    return datetime.now().strftime("%B %d, %Y")
+
+
+
+todo_planner_system_prompt = """
+你是一名研究规划专家,请把复杂主题拆解为一组有限、互补的待办任务。
+- 任务之间应互补,避免重复;
+- 每个任务要有明确意图与可执行的检索方向;
+- 输出须结构化、简明且便于后续协作。
+
+<GOAL>
+1. 结合研究主题梳理 3~5 个最关键的调研任务;
+2. 每个任务需明确目标意图,并给出适宜的网络检索查询;
+3. 任务之间要避免重复,整体覆盖用户的问题域;
+4. 在创建或更新任务时,必须调用 `note` 工具同步任务信息(这是唯一会写入笔记的途径)。
+</GOAL>
+
+<NOTE_COLLAB>
+- 为每个任务调用 `note` 工具创建/更新结构化笔记,统一使用 JSON 参数格式:
+  - 创建示例:`[TOOL_CALL:note:{"action":"create","task_id":1,"title":"任务 1: 背景梳理","note_type":"task_state","tags":["deep_research","task_1"],"content":"请记录任务概览、系统提示、来源概览、任务总结"}]`
+  - 更新示例:`[TOOL_CALL:note:{"action":"update","note_id":"<现有ID>","task_id":1,"title":"任务 1: 背景梳理","note_type":"task_state","tags":["deep_research","task_1"],"content":"...新增内容..."}]`
+- `tags` 必须包含 `deep_research` 与 `task_{task_id}`,以便其他 Agent 查找
+</NOTE_COLLAB>
+
+<TOOLS>
+你必须调用名为 `note` 的笔记工具来记录或更新待办任务,参数统一使用 JSON:
+```
+[TOOL_CALL:note:{"action":"create","task_id":1,"title":"任务 1: 背景梳理","note_type":"task_state","tags":["deep_research","task_1"],"content":"..."}]
+```
+</TOOLS>
+"""
+
+
+todo_planner_instructions = """
+
+<CONTEXT>
+当前日期:{current_date}
+研究主题:{research_topic}
+</CONTEXT>
+
+<FORMAT>
+请严格以 JSON 格式回复:
+{{
+  "tasks": [
+    {{
+      "title": "任务名称(10字内,突出重点)",
+      "intent": "任务要解决的核心问题,用1-2句描述",
+      "query": "建议使用的检索关键词"
+    }}
+  ]
+}}
+</FORMAT>
+
+如果主题信息不足以规划任务,请输出空数组:{{"tasks": []}}。必要时使用笔记工具记录你的思考过程。
+"""
+
+
+task_summarizer_instructions = """
+你是一名研究执行专家,请基于给定的上下文,为特定任务生成要点总结,对内容进行详尽且细致的总结而不是走马观花,需要勇于创新、打破常规思维,并尽可能多维度,从原理、应用、优缺点、工程实践、对比、历史演变等角度进行拓展。
+
+<GOAL>
+1. 针对任务意图梳理 3-5 条关键发现;
+2. 清晰说明每条发现的含义与价值,可引用事实数据;
+</GOAL>
+
+<NOTES>
+- 任务笔记由规划专家创建,笔记 ID 会在调用时提供;请先调用 `[TOOL_CALL:note:{"action":"read","note_id":"<note_id>"}]` 获取最新状态。
+- 更新任务总结后,使用 `[TOOL_CALL:note:{"action":"update","note_id":"<note_id>","task_id":{task_id},"title":"任务 {task_id}: …","note_type":"task_state","tags":["deep_research","task_{task_id}"],"content":"..."}]` 写回笔记,保持原有结构并追加新信息。
+- 若未找到笔记 ID,请先创建并在 `tags` 中包含 `task_{task_id}` 后再继续。
+</NOTES>
+
+<FORMAT>
+- 使用 Markdown 输出;
+- 以小节标题开头:"任务总结";
+- 关键发现使用有序或无序列表表达;
+- 若任务无有效结果,输出"暂无可用信息"。
+- 最终呈现给用户的总结中禁止包含 `[TOOL_CALL:...]` 指令。
+</FORMAT>
+"""
+
+
+report_writer_instructions = """
+你是一名专业的分析报告撰写者,请根据输入的任务总结与参考信息,生成结构化的研究报告。
+
+<REPORT_TEMPLATE>
+1. **背景概览**:简述研究主题的重要性与上下文。
+2. **核心洞见**:提炼 3-5 条最重要的结论,标注文献/任务编号。
+3. **证据与数据**:罗列支持性的事实或指标,可引用任务摘要中的要点。
+4. **风险与挑战**:分析潜在的问题、限制或仍待验证的假设。
+5. **参考来源**:按任务列出关键来源条目(标题 + 链接)。
+</REPORT_TEMPLATE>
+
+<REQUIREMENTS>
+- 报告使用 Markdown;
+- 各部分明确分节,禁止添加额外的封面或结语;
+- 若某部分信息缺失,说明"暂无相关信息";
+- 引用来源时使用任务标题或来源标题,确保可追溯。
+- 输出给用户的内容中禁止残留 `[TOOL_CALL:...]` 指令。
+</REQUIREMENTS>
+
+<NOTES>
+- 报告生成前,请针对每个 note_id 调用 `[TOOL_CALL:note:{"action":"read","note_id":"<note_id>"}]` 读取任务笔记。
+- 如需在报告层面沉淀结果,可创建新的 `conclusion` 类型笔记,例如:`[TOOL_CALL:note:{"action":"create","title":"研究报告:{研究主题}","note_type":"conclusion","tags":["deep_research","report"],"content":"...报告要点..."}]`。
+</NOTES>
+"""

+ 2 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/__init__.py

@@ -0,0 +1,2 @@
+"""Domain services for the deep researcher workflow."""
+

+ 59 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/notes.py

@@ -0,0 +1,59 @@
+"""Helpers for coordinating note tool usage instructions."""
+
+from __future__ import annotations
+
+import json
+
+from models import TodoItem
+
+
+def build_note_guidance(task: TodoItem) -> str:
+    """Generate note tool usage guidance for a specific task."""
+
+    tags_list = ["deep_research", f"task_{task.id}"]
+    tags_literal = json.dumps(tags_list, ensure_ascii=False)
+
+    if task.note_id:
+        read_payload = json.dumps({"action": "read", "note_id": task.note_id}, ensure_ascii=False)
+        update_payload = json.dumps(
+            {
+                "action": "update",
+                "note_id": task.note_id,
+                "task_id": task.id,
+                "title": f"任务 {task.id}: {task.title}",
+                "note_type": "task_state",
+                "tags": tags_list,
+                "content": "请将本轮新增信息补充到任务概览中",
+            },
+            ensure_ascii=False,
+        )
+
+        return (
+            "笔记协作指引:\n"
+            f"- 当前任务笔记 ID:{task.note_id}。\n"
+            f"- 在书写总结前必须调用:[TOOL_CALL:note:{read_payload}] 获取最新内容。\n"
+            f"- 完成分析后调用:[TOOL_CALL:note:{update_payload}] 同步增量信息。\n"
+            "- 更新时保持原有段落结构,新增内容请在对应段落中补充。\n"
+            f"- 建议 tags 保持为 {tags_literal},保证其他 Agent 可快速定位。\n"
+            "- 成功同步到笔记后,再输出面向用户的总结。\n"
+        )
+
+    create_payload = json.dumps(
+        {
+            "action": "create",
+            "task_id": task.id,
+            "title": f"任务 {task.id}: {task.title}",
+            "note_type": "task_state",
+            "tags": tags_list,
+            "content": "请记录任务概览、来源概览",
+        },
+        ensure_ascii=False,
+    )
+
+    return (
+        "笔记协作指引:\n"
+        f"- 当前任务尚未建立笔记,请先调用:[TOOL_CALL:note:{create_payload}]。\n"
+        "- 创建成功后记录返回的 note_id,并在后续所有更新中复用。\n"
+        "- 同步笔记后,再输出面向用户的总结。\n"
+    )
+

+ 160 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/planner.py

@@ -0,0 +1,160 @@
+"""Service responsible for converting the research topic into actionable tasks."""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from typing import Any, List, Optional
+
+from hello_agents import ToolAwareSimpleAgent
+
+from models import SummaryState, TodoItem
+from config import Configuration
+from prompts import get_current_date, todo_planner_instructions
+from utils import strip_thinking_tokens
+
+logger = logging.getLogger(__name__)
+
+TOOL_CALL_PATTERN = re.compile(
+    r"\[TOOL_CALL:(?P<tool>[^:]+):(?P<body>[^\]]+)\]",
+    re.IGNORECASE,
+)
+
+class PlanningService:
+    """Wraps the planner agent to produce structured TODO items."""
+
+    def __init__(self, planner_agent: ToolAwareSimpleAgent, config: Configuration) -> None:
+        self._agent = planner_agent
+        self._config = config
+
+    def plan_todo_list(self, state: SummaryState) -> List[TodoItem]:
+        """Ask the planner agent to break the topic into actionable tasks."""
+
+        prompt = todo_planner_instructions.format(
+            current_date=get_current_date(),
+            research_topic=state.research_topic,
+        )
+
+        response = self._agent.run(prompt)
+        self._agent.clear_history()
+
+        logger.info("Planner raw output (truncated): %s", response[:500])
+
+        tasks_payload = self._extract_tasks(response)
+        todo_items: List[TodoItem] = []
+
+        for idx, item in enumerate(tasks_payload, start=1):
+            title = str(item.get("title") or f"任务{idx}").strip()
+            intent = str(item.get("intent") or "聚焦主题的关键问题").strip()
+            query = str(item.get("query") or state.research_topic).strip()
+
+            if not query:
+                query = state.research_topic
+
+            task = TodoItem(
+                id=idx,
+                title=title,
+                intent=intent,
+                query=query,
+            )
+            todo_items.append(task)
+
+        state.todo_items = todo_items
+
+        titles = [task.title for task in todo_items]
+        logger.info("Planner produced %d tasks: %s", len(todo_items), titles)
+        return todo_items
+
+    @staticmethod
+    def create_fallback_task(state: SummaryState) -> TodoItem:
+        """Create a minimal fallback task when planning failed."""
+
+        return TodoItem(
+            id=1,
+            title="基础背景梳理",
+            intent="收集主题的核心背景与最新动态",
+            query=f"{state.research_topic} 最新进展" if state.research_topic else "基础背景梳理",
+        )
+
+    # ------------------------------------------------------------------
+    # Parsing helpers
+    # ------------------------------------------------------------------
+    def _extract_tasks(self, raw_response: str) -> List[dict[str, Any]]:
+        """Parse planner output into a list of task dictionaries."""
+
+        text = raw_response.strip()
+        if self._config.strip_thinking_tokens:
+            text = strip_thinking_tokens(text)
+
+        json_payload = self._extract_json_payload(text)
+        tasks: List[dict[str, Any]] = []
+
+        if isinstance(json_payload, dict):
+            candidate = json_payload.get("tasks")
+            if isinstance(candidate, list):
+                for item in candidate:
+                    if isinstance(item, dict):
+                        tasks.append(item)
+        elif isinstance(json_payload, list):
+            for item in json_payload:
+                if isinstance(item, dict):
+                    tasks.append(item)
+
+        if not tasks:
+            tool_payload = self._extract_tool_payload(text)
+            if tool_payload and isinstance(tool_payload.get("tasks"), list):
+                for item in tool_payload["tasks"]:
+                    if isinstance(item, dict):
+                        tasks.append(item)
+
+        return tasks
+
+    def _extract_json_payload(self, text: str) -> Optional[dict[str, Any] | list]:
+        """Try to locate and parse a JSON object or array from the text."""
+
+        start = text.find("{")
+        end = text.rfind("}")
+        if start != -1 and end != -1 and end > start:
+            candidate = text[start : end + 1]
+            try:
+                return json.loads(candidate)
+            except json.JSONDecodeError:
+                pass
+
+        start = text.find("[")
+        end = text.rfind("]")
+        if start != -1 and end != -1 and end > start:
+            candidate = text[start : end + 1]
+            try:
+                return json.loads(candidate)
+            except json.JSONDecodeError:
+                return None
+
+        return None
+
+    def _extract_tool_payload(self, text: str) -> Optional[dict[str, Any]]:
+        """Parse the first TOOL_CALL expression in the output."""
+
+        match = TOOL_CALL_PATTERN.search(text)
+        if not match:
+            return None
+
+        body = match.group("body")
+
+        try:
+            payload = json.loads(body)
+            if isinstance(payload, dict):
+                return payload
+        except json.JSONDecodeError:
+            pass
+
+        parts = [segment.strip() for segment in body.split(",") if segment.strip()]
+        payload: dict[str, Any] = {}
+        for part in parts:
+            if "=" not in part:
+                continue
+            key, value = part.split("=", 1)
+            payload[key.strip()] = value.strip().strip('"').strip("'")
+
+        return payload or None

+ 77 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/reporter.py

@@ -0,0 +1,77 @@
+"""Service that consolidates task results into the final report."""
+
+from __future__ import annotations
+
+import json
+
+from hello_agents import ToolAwareSimpleAgent
+
+from models import SummaryState
+from config import Configuration
+from utils import strip_thinking_tokens
+from services.text_processing import strip_tool_calls
+
+
+class ReportingService:
+    """Generates the final structured report."""
+
+    def __init__(self, report_agent: ToolAwareSimpleAgent, config: Configuration) -> None:
+        self._agent = report_agent
+        self._config = config
+
+    def generate_report(self, state: SummaryState) -> str:
+        """Generate a structured report based on completed tasks."""
+
+        tasks_block = []
+        for task in state.todo_items:
+            summary_block = task.summary or "暂无可用信息"
+            sources_block = task.sources_summary or "暂无来源"
+            tasks_block.append(
+                f"### 任务 {task.id}: {task.title}\n"
+                f"- 任务目标:{task.intent}\n"
+                f"- 检索查询:{task.query}\n"
+                f"- 执行状态:{task.status}\n"
+                f"- 任务总结:\n{summary_block}\n"
+                f"- 来源概览:\n{sources_block}\n"
+            )
+
+        note_references = []
+        for task in state.todo_items:
+            if task.note_id:
+                note_references.append(
+                    f"- 任务 {task.id}《{task.title}》:note_id={task.note_id}"
+                )
+
+        notes_section = "\n".join(note_references) if note_references else "- 暂无可用任务笔记"
+
+        read_template = json.dumps({"action": "read", "note_id": "<note_id>"}, ensure_ascii=False)
+        create_conclusion_template = json.dumps(
+            {
+                "action": "create",
+                "title": f"研究报告:{state.research_topic}",
+                "note_type": "conclusion",
+                "tags": ["deep_research", "report"],
+                "content": "请在此沉淀最终报告要点",
+            },
+            ensure_ascii=False,
+        )
+
+        prompt = (
+            f"研究主题:{state.research_topic}\n"
+            f"任务概览:\n{''.join(tasks_block)}\n"
+            f"可用任务笔记:\n{notes_section}\n"
+            f"请针对每条任务笔记使用格式:[TOOL_CALL:note:{read_template}] 读取内容,整合所有信息后撰写报告。\n"
+            f"如需输出汇总结论,可追加调用:[TOOL_CALL:note:{create_conclusion_template}] 保存报告要点。"
+        )
+
+        response = self._agent.run(prompt)
+        self._agent.clear_history()
+
+        report_text = response.strip()
+        if self._config.strip_thinking_tokens:
+            report_text = strip_thinking_tokens(report_text)
+
+        report_text = strip_tool_calls(report_text).strip()
+
+        return report_text or "报告生成失败,请检查输入。"
+

+ 109 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/search.py

@@ -0,0 +1,109 @@
+"""Search dispatch helpers leveraging HelloAgents SearchTool."""
+
+from __future__ import annotations
+
+import logging
+import io
+from contextlib import redirect_stdout
+from typing import Any, Optional, Tuple
+
+from hello_agents.tools import SearchTool
+
+from config import Configuration
+from utils import (
+    deduplicate_and_format_sources,
+    format_sources,
+    get_config_value,
+)
+
+logger = logging.getLogger(__name__)
+
+MAX_TOKENS_PER_SOURCE = 2000
+_GLOBAL_SEARCH_TOOL: SearchTool | None = None
+
+
+def _get_search_tool() -> SearchTool:
+    global _GLOBAL_SEARCH_TOOL
+    if _GLOBAL_SEARCH_TOOL is None:
+        # hello_agents SearchTool prints status messages during backend setup.
+        # Suppress them so Windows GBK consoles do not fail on emoji output.
+        with redirect_stdout(io.StringIO()):
+            _GLOBAL_SEARCH_TOOL = SearchTool(backend="hybrid")
+    return _GLOBAL_SEARCH_TOOL
+
+
+def dispatch_search(
+    query: str,
+    config: Configuration,
+    loop_count: int,
+) -> Tuple[dict[str, Any] | None, list[str], Optional[str], str]:
+    """Execute configured search backend and normalise response payload."""
+
+    search_api = get_config_value(config.search_api)
+
+    try:
+        raw_response = _get_search_tool().run(
+            {
+                "input": query,
+                "backend": search_api,
+                "mode": "structured",
+                "fetch_full_page": config.fetch_full_page,
+                "max_results": 5,
+                "max_tokens_per_source": MAX_TOKENS_PER_SOURCE,
+                "loop_count": loop_count,
+            }
+        )
+    except Exception as exc:  # pragma: no cover - defensive logging
+        logger.exception("Search backend %s failed: %s", search_api, exc)
+        raise
+
+    if isinstance(raw_response, str):
+        notices = [raw_response]
+        logger.warning("Search backend %s returned text notice: %s", search_api, raw_response)
+        payload: dict[str, Any] = {
+            "results": [],
+            "backend": search_api,
+            "answer": None,
+            "notices": notices,
+        }
+    else:
+        payload = raw_response
+        notices = list(payload.get("notices") or [])
+
+    backend_label = str(payload.get("backend") or search_api)
+    answer_text = payload.get("answer")
+    results = payload.get("results", [])
+
+    if notices:
+        for notice in notices:
+            logger.info("Search notice (%s): %s", backend_label, notice)
+
+    logger.info(
+        "Search backend=%s resolved_backend=%s answer=%s results=%s",
+        search_api,
+        backend_label,
+        bool(answer_text),
+        len(results),
+    )
+
+    return payload, notices, answer_text, backend_label
+
+
+def prepare_research_context(
+    search_result: dict[str, Any] | None,
+    answer_text: Optional[str],
+    config: Configuration,
+) -> tuple[str, str]:
+    """Build structured context and source summary for downstream agents."""
+
+    sources_summary = format_sources(search_result)
+    context = deduplicate_and_format_sources(
+        search_result or {"results": []},
+        max_tokens_per_source=MAX_TOKENS_PER_SOURCE,
+        fetch_full_page=config.fetch_full_page,
+    )
+
+    if answer_text:
+        context = f"AI直接答案:\n{answer_text}\n\n{context}"
+
+    return sources_summary, context

+ 125 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/summarizer.py

@@ -0,0 +1,125 @@
+"""Task summarization utilities."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Iterator
+from typing import Tuple
+
+from hello_agents import ToolAwareSimpleAgent
+
+from models import SummaryState, TodoItem
+from config import Configuration
+from utils import strip_thinking_tokens
+from services.notes import build_note_guidance
+from services.text_processing import strip_tool_calls
+
+
+class SummarizationService:
+    """Handles synchronous and streaming task summarization."""
+
+    def __init__(
+        self,
+        summarizer_factory: Callable[[], ToolAwareSimpleAgent],
+        config: Configuration,
+    ) -> None:
+        self._agent_factory = summarizer_factory
+        self._config = config
+
+    def summarize_task(self, state: SummaryState, task: TodoItem, context: str) -> str:
+        """Generate a task-specific summary using the summarizer agent."""
+
+        prompt = self._build_prompt(state, task, context)
+
+        agent = self._agent_factory()
+        try:
+            response = agent.run(prompt)
+        finally:
+            agent.clear_history()
+
+        summary_text = response.strip()
+        if self._config.strip_thinking_tokens:
+            summary_text = strip_thinking_tokens(summary_text)
+
+        summary_text = strip_tool_calls(summary_text).strip()
+
+        return summary_text or "暂无可用信息"
+
+    def stream_task_summary(
+        self, state: SummaryState, task: TodoItem, context: str
+    ) -> Tuple[Iterator[str], Callable[[], str]]:
+        """Stream the summary text for a task while collecting full output."""
+
+        prompt = self._build_prompt(state, task, context)
+        remove_thinking = self._config.strip_thinking_tokens
+        raw_buffer = ""
+        visible_output = ""
+        emit_index = 0
+        agent = self._agent_factory()
+
+        def flush_visible() -> Iterator[str]:
+            nonlocal emit_index, raw_buffer
+            while True:
+                start = raw_buffer.find("<think>", emit_index)
+                if start == -1:
+                    if emit_index < len(raw_buffer):
+                        segment = raw_buffer[emit_index:]
+                        emit_index = len(raw_buffer)
+                        if segment:
+                            yield segment
+                    break
+
+                if start > emit_index:
+                    segment = raw_buffer[emit_index:start]
+                    emit_index = start
+                    if segment:
+                        yield segment
+
+                end = raw_buffer.find("</think>", start)
+                if end == -1:
+                    break
+                emit_index = end + len("</think>")
+
+        def generator() -> Iterator[str]:
+            nonlocal raw_buffer, visible_output, emit_index
+            try:
+                for chunk in agent.stream_run(prompt):
+                    raw_buffer += chunk
+                    if remove_thinking:
+                        for segment in flush_visible():
+                            visible_output += segment
+                            if segment:
+                                yield segment
+                    else:
+                        visible_output += chunk
+                        if chunk:
+                            yield chunk
+            finally:
+                if remove_thinking:
+                    for segment in flush_visible():
+                        visible_output += segment
+                        if segment:
+                            yield segment
+                agent.clear_history()
+
+        def get_summary() -> str:
+            if remove_thinking:
+                cleaned = strip_thinking_tokens(visible_output)
+            else:
+                cleaned = visible_output
+
+            return strip_tool_calls(cleaned).strip()
+
+        return generator(), get_summary
+
+    def _build_prompt(self, state: SummaryState, task: TodoItem, context: str) -> str:
+        """Construct the summarization prompt shared by both modes."""
+
+        return (
+            f"任务主题:{state.research_topic}\n"
+            f"任务名称:{task.title}\n"
+            f"任务目标:{task.intent}\n"
+            f"检索查询:{task.query}\n"
+            f"任务上下文:\n{context}\n"
+            f"{build_note_guidance(task)}\n"
+            "请按照以上协作要求先同步笔记,然后返回一份面向用户的 Markdown 总结(仍遵循任务总结模板)。"
+        )

+ 16 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/text_processing.py

@@ -0,0 +1,16 @@
+"""Utility helpers for normalizing agent generated text."""
+
+from __future__ import annotations
+
+import re
+
+
+def strip_tool_calls(text: str) -> str:
+    """移除文本中的工具调用标记。"""
+
+    if not text:
+        return text
+
+    pattern = re.compile(r"\[TOOL_CALL:[^\]]+\]")
+    return pattern.sub("", text)
+

+ 215 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/services/tool_events.py

@@ -0,0 +1,215 @@
+"""Utility for collecting and exposing tool call events."""
+
+from __future__ import annotations
+
+import logging
+import re
+from dataclasses import dataclass
+from pathlib import Path
+from threading import Lock
+from typing import Any, Callable, Optional
+
+from models import SummaryState, TodoItem
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ToolCallEvent:
+    """Internal representation of a tool call event."""
+
+    id: int
+    agent: str
+    tool: str
+    raw_parameters: str
+    parsed_parameters: dict[str, Any]
+    result: str
+    task_id: Optional[int]
+    note_id: Optional[str]
+
+
+class ToolCallTracker:
+    """Collects tool call events and converts them to SSE payloads."""
+
+    def __init__(self, notes_workspace: Optional[str]) -> None:
+        self._notes_workspace = notes_workspace
+        self._events: list[ToolCallEvent] = []
+        self._cursor = 0
+        self._lock = Lock()
+        self._event_sink: Optional[Callable[[dict[str, Any]], None]] = None
+
+    def record(self, payload: dict[str, Any]) -> None:
+        """记录模型工具调用情况,便于日志与前端展示。"""
+
+        agent_name = str(payload.get("agent_name") or "unknown")
+        tool_name = str(payload.get("tool_name") or "unknown")
+        raw_parameters = str(payload.get("raw_parameters") or "")
+        parsed_parameters = payload.get("parsed_parameters") or {}
+        result_text = str(payload.get("result") or "")
+
+        if not isinstance(parsed_parameters, dict):
+            parsed_parameters = {}
+
+        task_id = self._infer_task_id(parsed_parameters)
+        note_id: Optional[str] = None
+
+        if tool_name == "note":
+            note_id = parsed_parameters.get("note_id")
+            if note_id is None:
+                note_id = self._extract_note_id(result_text)
+
+        event = ToolCallEvent(
+            id=len(self._events) + 1,
+            agent=agent_name,
+            tool=tool_name,
+            raw_parameters=raw_parameters,
+            parsed_parameters=parsed_parameters,
+            result=result_text,
+            task_id=task_id,
+            note_id=note_id,
+        )
+
+        with self._lock:
+            self._events.append(event)
+
+        logger.info(
+            "Tool call recorded: agent=%s tool=%s task_id=%s note_id=%s parsed_parameters=%s",
+            agent_name,
+            tool_name,
+            task_id,
+            note_id,
+            parsed_parameters,
+        )
+
+        sink = self._event_sink
+        if sink:
+            sink(self._build_payload(event, step=None))
+
+    # ------------------------------------------------------------------
+    # Draining helpers
+    # ------------------------------------------------------------------
+    def drain(self, state: SummaryState, *, step: Optional[int] = None) -> list[dict[str, Any]]:
+        """提取尚未消费的工具调用事件,并同步任务的 note_id。"""
+
+        with self._lock:
+            if self._cursor >= len(self._events):
+                return []
+            new_events = self._events[self._cursor :]
+            self._cursor = len(self._events)
+
+        if state.todo_items:
+            for event in new_events:
+                task_id = event.task_id
+                note_id = event.note_id
+                if task_id is None or not note_id:
+                    continue
+                self._attach_note_to_task(state.todo_items, task_id, note_id)
+
+        payloads: list[dict[str, Any]] = []
+        for event in new_events:
+            payload = self._build_payload(event, step=step)
+            payloads.append(payload)
+
+        return payloads
+
+    def reset(self) -> None:
+        """Clear recorded events."""
+
+        with self._lock:
+            self._events.clear()
+            self._cursor = 0
+
+    def as_dicts(self) -> list[dict[str, Any]]:
+        """Expose a snapshot of raw events for backwards compatibility."""
+
+        with self._lock:
+            return [
+                {
+                    "id": event.id,
+                    "agent": event.agent,
+                    "tool": event.tool,
+                    "raw_parameters": event.raw_parameters,
+                    "parsed_parameters": event.parsed_parameters,
+                    "result": event.result,
+                    "task_id": event.task_id,
+                    "note_id": event.note_id,
+                }
+                for event in self._events
+            ]
+
+    def set_event_sink(self, sink: Optional[Callable[[dict[str, Any]], None]]) -> None:
+        """Register a callback for immediate tool event notifications."""
+
+        self._event_sink = sink
+
+    def _build_payload(self, event: ToolCallEvent, step: Optional[int]) -> dict[str, Any]:
+        payload = {
+            "type": "tool_call",
+            "event_id": event.id,
+            "agent": event.agent,
+            "tool": event.tool,
+            "parameters": event.parsed_parameters,
+            "result": event.result,
+            "task_id": event.task_id,
+            "note_id": event.note_id,
+        }
+        if event.note_id and self._notes_workspace:
+            note_path = Path(self._notes_workspace) / f"{event.note_id}.md"
+            payload["note_path"] = str(note_path)
+        if step is not None:
+            payload["step"] = step
+        return payload
+
+    # ------------------------------------------------------------------
+    # Internal helpers
+    # ------------------------------------------------------------------
+    def _attach_note_to_task(self, tasks: list[TodoItem], task_id: int, note_id: str) -> None:
+        """Update matching TODO item with note metadata."""
+
+        for task in tasks:
+            if task.id != task_id:
+                continue
+
+            if task.note_id != note_id:
+                task.note_id = note_id
+                if self._notes_workspace:
+                    task.note_path = str(Path(self._notes_workspace) / f"{note_id}.md")
+            elif task.note_path is None and self._notes_workspace:
+                task.note_path = str(Path(self._notes_workspace) / f"{note_id}.md")
+            break
+
+    def _infer_task_id(self, parameters: dict[str, Any]) -> Optional[int]:
+        """尝试从工具参数推断 task_id。"""
+
+        if not parameters:
+            return None
+
+        if "task_id" in parameters:
+            try:
+                return int(parameters["task_id"])
+            except (TypeError, ValueError):
+                pass
+
+        tags = parameters.get("tags")
+        if isinstance(tags, list):
+            for tag in tags:
+                match = re.search(r"task_(\d+)", str(tag))
+                if match:
+                    return int(match.group(1))
+
+        title = parameters.get("title")
+        if isinstance(title, str):
+            match = re.search(r"任务\s*(\d+)", title)
+            if match:
+                return int(match.group(1))
+
+        return None
+
+    def _extract_note_id(self, response: str) -> Optional[str]:
+        if not response:
+            return None
+
+        match = re.search(r"ID:\s*([^\n]+)", response)
+        if match:
+            return match.group(1).strip()
+        return None

+ 84 - 0
Co-creation-projects/huailishang-AgentPlatformBase/agents/deep_research/src/utils.py

@@ -0,0 +1,84 @@
+"""Utility helpers shared across deep researcher services."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, List, Union
+
+CHARS_PER_TOKEN = 4
+
+logger = logging.getLogger(__name__)
+
+
+def get_config_value(value: Any) -> str:
+    """Return configuration value as plain string."""
+
+    return value if isinstance(value, str) else value.value
+
+
+def strip_thinking_tokens(text: str) -> str:
+    """Remove ``<think>`` sections from model responses."""
+
+    while "<think>" in text and "</think>" in text:
+        start = text.find("<think>")
+        end = text.find("</think>") + len("</think>")
+        text = text[:start] + text[end:]
+    return text
+
+
+def deduplicate_and_format_sources(
+    search_response: Dict[str, Any] | List[Dict[str, Any]],
+    max_tokens_per_source: int,
+    *,
+    fetch_full_page: bool = False,
+) -> str:
+    """Format and deduplicate search results for downstream prompting."""
+
+    if isinstance(search_response, dict):
+        sources_list = search_response.get("results", [])
+    else:
+        sources_list = search_response
+
+    unique_sources: dict[str, Dict[str, Any]] = {}
+    for source in sources_list:
+        url = source.get("url")
+        if not url:
+            continue
+        if url not in unique_sources:
+            unique_sources[url] = source
+
+    formatted_parts: List[str] = []
+    for source in unique_sources.values():
+        title = source.get("title") or source.get("url", "")
+        content = source.get("content", "")
+        formatted_parts.append(f"信息来源: {title}\n\n")
+        formatted_parts.append(f"URL: {source.get('url', '')}\n\n")
+        formatted_parts.append(f"信息内容: {content}\n\n")
+
+        if fetch_full_page:
+            raw_content = source.get("raw_content")
+            if raw_content is None:
+                logger.debug("raw_content missing for %s", source.get("url", ""))
+                raw_content = ""
+            char_limit = max_tokens_per_source * CHARS_PER_TOKEN
+            if len(raw_content) > char_limit:
+                raw_content = f"{raw_content[:char_limit]}... [truncated]"
+            formatted_parts.append(
+                f"详细信息内容限制为 {max_tokens_per_source} 个 token: {raw_content}\n\n"
+            )
+
+    return "".join(formatted_parts).strip()
+
+
+def format_sources(search_results: Dict[str, Any] | None) -> str:
+    """Return bullet list summarising search sources."""
+
+    if not search_results:
+        return ""
+
+    results = search_results.get("results", [])
+    return "\n".join(
+        f"* {item.get('title', item.get('url', ''))} : {item.get('url', '')}"
+        for item in results
+        if item.get("url")
+    )

+ 54 - 23
Co-creation-projects/huailishang-AgentPlatformBase/backend/agents/adapters/deep_research.py

@@ -1,7 +1,9 @@
 from __future__ import annotations
 
 import importlib
+import io
 import sys
+from contextlib import redirect_stdout
 from pathlib import Path
 from time import perf_counter
 from typing import Any
@@ -17,7 +19,7 @@ from backend.models import AgentRequest, AgentResponse
 
 
 class DeepResearchAdapter(BaseAgent):
-    """Expose chapter14 DeepResearchAgent as one platform-level agent."""
+    """Expose the built-in DeepResearchAgent as one platform-level agent."""
 
     def run(self, request: AgentRequest) -> AgentResponse:
         event_logger.emit("agent_started", agent_id=self.agent_id, task_id=request.task_id)
@@ -50,11 +52,12 @@ class DeepResearchAdapter(BaseAgent):
 
     def _run_with_artifacts(self, request: AgentRequest) -> tuple[str, dict[str, Any]]:
         total_started = perf_counter()
+        stdout_buffer = io.StringIO()
         timings: dict[str, float] = {}
 
-        started = perf_counter()
+        cleanup_started = perf_counter()
         cleanup_stats = cleanup_deep_research_artifacts()
-        timings["cleanup_seconds"] = round(perf_counter() - started, 3)
+        timings["cleanup_seconds"] = round(perf_counter() - cleanup_started, 3)
 
         if request.context.get("mode") == "group_chat":
             return (
@@ -62,78 +65,106 @@ class DeepResearchAdapter(BaseAgent):
                 {"skipped": True, "reason": "batch_guard", "cleanup": cleanup_stats},
             )
 
-        chapter14_path = Path(settings.chapter14_backend_path).resolve()
-        if not chapter14_path.exists():
+        deep_research_path = Path(settings.chapter14_backend_path).resolve()
+        if not deep_research_path.exists():
             return (
-                f"chapter14 后端路径不存在,无法运行 deep_research:{chapter14_path}",
+                f"DeepResearch 内置源码路径不存在,无法运行 deep_research:{deep_research_path}",
                 {
                     "ready": False,
-                    "chapter14_backend_path": str(chapter14_path),
+                    "deep_research_path": str(deep_research_path),
                     "cleanup": cleanup_stats,
                 },
             )
 
         if request.context.get("dry_run"):
             return (
-                "deep_research 已接入 chapter14 后端路径,真实运行时会调用 chapter14 的 DeepResearchAgent。",
+                "deep_research 已接入内置 DeepResearchAgent,真实运行时会执行搜索调研流程。",
                 {
                     "ready": True,
-                    "chapter14_backend_path": str(chapter14_path),
+                    "deep_research_path": str(deep_research_path),
                     "cleanup": cleanup_stats,
                 },
             )
 
+        topic_preview = request.input.replace("\n", " ")[:120]
+        print(f"[deep_research] start task_id={request.task_id or '-'} topic={topic_preview}")
+
         started = perf_counter()
-        DeepResearchAgent, Configuration = self._load_chapter14_types(chapter14_path)
-        timings["load_chapter14_seconds"] = round(perf_counter() - started, 3)
+        with redirect_stdout(stdout_buffer):
+            DeepResearchAgent, Configuration = self._load_deep_research_types(deep_research_path)
+        timings["load_deep_research_seconds"] = round(perf_counter() - started, 3)
+        print(f"[deep_research] loaded source={deep_research_path}")
 
         started = perf_counter()
-        config = Configuration.from_env(overrides=self._chapter14_overrides())
-        agent = DeepResearchAgent(config=config)
+        with redirect_stdout(stdout_buffer):
+            config = Configuration.from_env(overrides=self._deep_research_overrides())
+            agent = DeepResearchAgent(config=config)
         timings["agent_init_seconds"] = round(perf_counter() - started, 3)
+        print(
+            "[deep_research] initialized "
+            f"search={config.search_api.value if hasattr(config.search_api, 'value') else config.search_api} "
+            f"model={config.resolved_model() or '-'}"
+        )
 
         started = perf_counter()
-        result = agent.run(request.input)
+        print("[deep_research] researching...")
+        with redirect_stdout(stdout_buffer):
+            result = agent.run(request.input)
         timings["agent_run_seconds"] = round(perf_counter() - started, 3)
 
         started = perf_counter()
         todo_items = [self._serialize_todo(item) for item in result.todo_items]
-        report = result.report_markdown or result.running_summary or ""
+        report = (result.report_markdown or result.running_summary or "").strip()
         completed_items = [
             item for item in todo_items if item.get("status") == "completed" and item.get("summary")
         ]
+        skipped_items = [item for item in todo_items if item.get("status") == "skipped"]
+        failed_items = [item for item in todo_items if item.get("status") == "failed"]
         artifacts: dict[str, Any] = {
             "report_markdown": report,
             "todo_items": todo_items,
             "cleanup": cleanup_stats,
         }
+        captured_stdout = stdout_buffer.getvalue().strip()
+        if captured_stdout:
+            artifacts["stdout"] = captured_stdout
         timings["postprocess_seconds"] = round(perf_counter() - started, 3)
         timings["total_seconds"] = round(perf_counter() - total_started, 3)
         artifacts["timings"] = timings
         if todo_items:
             artifacts["todo_count"] = len(todo_items)
             artifacts["completed_count"] = len(completed_items)
+            artifacts["skipped_count"] = len(skipped_items)
+            artifacts["failed_count"] = len(failed_items)
 
-        if todo_items and not completed_items:
+        print(
+            "[deep_research] research completed "
+            f"tasks={len(todo_items)} completed={len(completed_items)} "
+            f"skipped={len(skipped_items)} failed={len(failed_items)}"
+        )
+        print(f"[deep_research] report generated chars={len(report)}")
+
+        if todo_items and not completed_items and not report:
             output = (
                 "搜索员没有拿到可用的搜索总结,因此未返回正式研究报告。\n"
                 "可能原因:搜索后端无结果、网络 API 调用失败,或任务执行阶段没有产出摘要。\n"
                 "请查看后端日志和 data/deep_research/runs 目录下的 task_* 文件。"
             )
+            print(f"[deep_research] failed seconds={timings['total_seconds']}")
             return output, artifacts
 
-        output = report.strip()
-        if not output:
-            output = "deep_research 已完成,但没有生成报告正文。"
+        if todo_items and not completed_items:
+            artifacts["warning"] = "no_completed_research_tasks"
 
+        output = report or "deep_research 已完成,但没有生成报告正文。"
+        print(f"[deep_research] complete seconds={timings['total_seconds']}")
         return output, artifacts
 
-    def _load_chapter14_types(self, chapter14_path: Path) -> tuple[type[Any], type[Any]]:
-        path_text = str(chapter14_path)
+    def _load_deep_research_types(self, deep_research_path: Path) -> tuple[type[Any], type[Any]]:
+        path_text = str(deep_research_path)
         if path_text not in sys.path:
             sys.path.insert(0, path_text)
 
-        # Chapter14 loads its own .env on import; reload chapter16 .env afterwards.
         agent_module = importlib.import_module("agent")
         config_module = importlib.import_module("config")
         if ENV_FILE.exists():
@@ -141,7 +172,7 @@ class DeepResearchAdapter(BaseAgent):
 
         return agent_module.DeepResearchAgent, config_module.Configuration
 
-    def _chapter14_overrides(self) -> dict[str, Any]:
+    def _deep_research_overrides(self) -> dict[str, Any]:
         overrides: dict[str, Any] = {
             "notes_workspace": self._resolve_workspace(settings.notes_workspace),
             "run_workspace": self._resolve_workspace(settings.run_workspace),

+ 22 - 4
Co-creation-projects/huailishang-AgentPlatformBase/backend/config.py

@@ -37,15 +37,33 @@ def _int_env(name: str, default: int) -> int:
     return int(value)
 
 
+def _path_env(name: str, default: Path) -> str:
+    value = os.getenv(name)
+    path = Path(value) if value else default
+    if not path.is_absolute():
+        path = ROOT_DIR / path
+    return str(path.resolve())
+
+
+def _chapter14_backend_default() -> Path:
+    chapter14_root = ROOT_DIR.parents[1] / "chapter14"
+    candidates = [
+        ROOT_DIR / "agents" / "deep_research" / "src",
+        chapter14_root / "helloagents-deepresearch" / "backend" / "src",
+        chapter14_root / "helloagents-deepresearch-fixed" / "backend" / "src",
+    ]
+    for candidate in candidates:
+        if candidate.exists():
+            return candidate
+    return candidates[0]
+
+
 @dataclass(frozen=True)
 class Settings:
     app_name: str = os.getenv("APP_NAME", "Agent Platform Base")
     app_host: str = os.getenv("APP_HOST", "127.0.0.1")
     app_port: int = int(os.getenv("APP_PORT", "8016"))
-    chapter14_backend_path: str = os.getenv(
-        "CHAPTER14_BACKEND_PATH",
-        str((ROOT_DIR.parents[1] / "chapter14" / "helloagents-deepresearch-fixed" / "backend" / "src").resolve()),
-    )
+    chapter14_backend_path: str = _path_env("CHAPTER14_BACKEND_PATH", _chapter14_backend_default())
 
     llm_provider: str | None = os.getenv("LLM_PROVIDER") or None
     llm_model_id: str | None = os.getenv("LLM_MODEL_ID") or None

+ 9 - 0
Co-creation-projects/huailishang-AgentPlatformBase/backend/main.py

@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import logging
+import os
 from pathlib import Path
 
 from fastapi import FastAPI, HTTPException
@@ -16,6 +18,13 @@ from backend.tasks.manager import task_manager
 from backend.tasks.runner import TaskRunner
 
 
+if os.getenv("APP_ACCESS_LOG", "false").strip().lower() not in {"1", "true", "yes", "on"}:
+    logging.getLogger("uvicorn.access").disabled = True
+    logging.getLogger("uvicorn.access").setLevel(logging.CRITICAL + 1)
+
+for noisy_logger in ("agent", "services", "services.planner", "services.tool_events"):
+    logging.getLogger(noisy_logger).setLevel(logging.WARNING)
+
 app = FastAPI(title=settings.app_name, version="0.1.0")
 
 app.add_middleware(

+ 1 - 0
Co-creation-projects/huailishang-AgentPlatformBase/main.py

@@ -12,5 +12,6 @@ if __name__ == "__main__":
         "backend.main:app",
         host=os.getenv("APP_HOST", settings.app_host),
         port=int(os.getenv("APP_PORT", str(settings.app_port))),
+        access_log=os.getenv("APP_ACCESS_LOG", "false").strip().lower() in {"1", "true", "yes", "on"},
         reload=False,
     )

+ 6 - 0
Co-creation-projects/huailishang-AgentPlatformBase/requirements.txt

@@ -3,3 +3,9 @@ uvicorn[standard]>=0.27
 pydantic>=2.0
 python-dotenv>=1.0
 requests>=2.31
+hello-agents==0.2.9
+openai>=1.12.0
+tavily-python>=0.5.0
+ddgs>=9.6.1
+loguru>=0.7.3
+typing_extensions>=4.8.0