diff --git a/context_engineering_research_agent/agent.py b/context_engineering_research_agent/agent.py
index 9d32b23..8951ab9 100644
--- a/context_engineering_research_agent/agent.py
+++ b/context_engineering_research_agent/agent.py
@@ -85,11 +85,13 @@ _cached_model = None
def _infer_openrouter_model_name(model: BaseChatModel) -> str | None:
"""OpenRouter 모델에서 모델명을 추출합니다.
+ API 문서 주소: https://openrouter.ai/docs/api/api-reference/models/get-models
+
Args:
model: LangChain 모델 인스턴스
Returns:
- OpenRouter 모델명 (예: "anthropic/claude-3-sonnet") 또는 None
+ OpenRouter 모델명 (예: "anthropic/claude-sonnet-4-5") 또는 None
"""
if detect_provider(model) != ProviderType.OPENROUTER:
return None
diff --git a/context_engineering_research_agent/skills/middleware.py b/context_engineering_research_agent/skills/middleware.py
index 1d63461..212777a 100644
--- a/context_engineering_research_agent/skills/middleware.py
+++ b/context_engineering_research_agent/skills/middleware.py
@@ -1,11 +1,11 @@
"""스킬 시스템 미들웨어.
-Progressive Disclosure 패턴으로 스킬 메타데이터를 시스템 프롬프트에 주입합니다.
+Progressive Disclosure 패턴으로 Agent Skills 메타데이터를 시스템 프롬프트에 주입합니다.
"""
from collections.abc import Awaitable, Callable
from pathlib import Path
-from typing import NotRequired, TypedDict, cast
+from typing import Any, NotRequired, TypedDict, cast
from langchain.agents.middleware.types import (
AgentMiddleware,
@@ -13,6 +13,7 @@ from langchain.agents.middleware.types import (
ModelRequest,
ModelResponse,
)
+from langchain_core.messages import SystemMessage
from langgraph.runtime import Runtime
from context_engineering_research_agent.skills.load import SkillMetadata, list_skills
@@ -111,20 +112,24 @@ class SkillsMiddleware(AgentMiddleware):
return "\n".join(lines)
def before_agent(
- self, state: SkillsState, runtime: Runtime
- ) -> SkillsStateUpdate | None:
+ self,
+ state: AgentState[Any], # noqa: ARG002
+ runtime: Runtime, # noqa: ARG002
+ ) -> dict[str, Any] | None:
skills = list_skills(
user_skills_dir=self.skills_dir,
project_skills_dir=self.project_skills_dir,
)
- return SkillsStateUpdate(skills_metadata=skills)
+ return cast("dict[str, Any]", SkillsStateUpdate(skills_metadata=skills))
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
- skills_metadata = request.state.get("skills_metadata", [])
+ skills_metadata = cast(
+ "list[SkillMetadata]", request.state.get("skills_metadata", [])
+ )
skills_locations = self._format_skills_locations()
skills_list = self._format_skills_list(skills_metadata)
@@ -139,7 +144,7 @@ class SkillsMiddleware(AgentMiddleware):
else:
system_prompt = skills_section
- return handler(request.override(system_prompt=system_prompt))
+ return handler(request.override(system_message=SystemMessage(system_prompt)))
async def awrap_model_call(
self,
@@ -162,4 +167,6 @@ class SkillsMiddleware(AgentMiddleware):
else:
system_prompt = skills_section
- return await handler(request.override(system_prompt=system_prompt))
+ return await handler(
+ request.override(system_message=SystemMessage(system_prompt))
+ )
diff --git a/pyproject.toml b/pyproject.toml
index e4aa36c..2afc029 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,6 +34,9 @@ dev = [
requires = ["setuptools>=73.0.0", "wheel"]
build-backend = "setuptools.build_meta"
+[project.scripts]
+deep-research = "research_agent.researcher.runner:main"
+
[tool.setuptools]
packages = ["research_agent"]
diff --git a/research_agent/researcher/__init__.py b/research_agent/researcher/__init__.py
index b8a75f6..c7b1ddf 100644
--- a/research_agent/researcher/__init__.py
+++ b/research_agent/researcher/__init__.py
@@ -14,10 +14,50 @@ from research_agent.researcher.agent import (
create_researcher_agent,
get_researcher_subagent,
)
-from research_agent.researcher.prompts import AUTONOMOUS_RESEARCHER_INSTRUCTIONS
+from research_agent.researcher.depth import (
+ DEPTH_CONFIGS,
+ DepthConfig,
+ ResearchDepth,
+ get_depth_config,
+ infer_research_depth,
+)
+from research_agent.researcher.prompts import (
+ AUTONOMOUS_RESEARCHER_INSTRUCTIONS,
+ DEPTH_PROMPTS,
+ build_research_prompt,
+ get_depth_prompt,
+)
+from research_agent.researcher.ralph_loop import (
+ Finding,
+ RalphLoopState,
+ ResearchRalphLoop,
+ ResearchSession,
+ SourceQuality,
+ SourceType,
+)
+from research_agent.researcher.runner import (
+ ResearchRunner,
+ run_deep_research,
+)
__all__ = [
"create_researcher_agent",
"get_researcher_subagent",
"AUTONOMOUS_RESEARCHER_INSTRUCTIONS",
+ "DEPTH_PROMPTS",
+ "get_depth_prompt",
+ "build_research_prompt",
+ "ResearchDepth",
+ "DepthConfig",
+ "DEPTH_CONFIGS",
+ "infer_research_depth",
+ "get_depth_config",
+ "ResearchRalphLoop",
+ "ResearchSession",
+ "RalphLoopState",
+ "Finding",
+ "SourceQuality",
+ "SourceType",
+ "ResearchRunner",
+ "run_deep_research",
]
diff --git a/research_agent/researcher/agent.py b/research_agent/researcher/agent.py
index f51be58..c19c03e 100644
--- a/research_agent/researcher/agent.py
+++ b/research_agent/researcher/agent.py
@@ -1,9 +1,48 @@
-"""자율적 연구 에이전트 팩토리.
+"""자율적 연구 에이전트 팩토리 모듈.
이 모듈은 자체 계획, 반성, 컨텍스트 관리 기능을 갖춘
독립적인 연구 DeepAgent를 생성합니다.
+
+## 에이전트 생성 흐름
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ create_researcher_agent() │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ 1. 모델 초기화 │
+│ model = ChatOpenAI(model="gpt-4.1") │
+│ │
+│ 2. 깊이 설정 로드 │
+│ config = get_depth_config(depth) │
+│ │
+│ 3. 깊이별 도구 선택 │
+│ tools = _get_tools_for_depth(depth) │
+│ ┌─────────────────────────────────────────────────────────┐│
+│ │ QUICK: think, mgrep, tavily ││
+│ │ STANDARD: + comprehensive_search ││
+│ │ DEEP: + arxiv, github ││
+│ │ EXHAUSTIVE: + library_docs ││
+│ └─────────────────────────────────────────────────────────┘│
+│ │
+│ 4. 프롬프트 구성 │
+│ - QUICK/STANDARD: AUTONOMOUS_RESEARCHER_INSTRUCTIONS │
+│ - DEEP/EXHAUSTIVE: build_research_prompt() (Ralph Loop) │
+│ │
+│ 5. DeepAgent 생성 │
+│ return create_deep_agent(model, tools, prompt, backend) │
+│ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+v2 업데이트 (2026-01):
+- ResearchDepth 기반 동적 깊이 조절
+- 다중 검색 도구 (mgrep, arXiv, comprehensive_search)
+- Ralph Loop 패턴 지원
"""
+from __future__ import annotations
+
from datetime import datetime
from deepagents import create_deep_agent
@@ -12,100 +51,206 @@ from langchain_core.language_models import BaseChatModel
from langchain_openai import ChatOpenAI
from langgraph.graph.state import CompiledStateGraph
-from research_agent.researcher.prompts import AUTONOMOUS_RESEARCHER_INSTRUCTIONS
-from research_agent.tools import tavily_search, think_tool
+from research_agent.researcher.depth import ResearchDepth, get_depth_config
+from research_agent.researcher.prompts import (
+ AUTONOMOUS_RESEARCHER_INSTRUCTIONS,
+ build_research_prompt,
+)
+from research_agent.tools import (
+ arxiv_search,
+ comprehensive_search,
+ github_code_search,
+ library_docs_search,
+ mgrep_search,
+ tavily_search,
+ think_tool,
+)
+
+
+# ============================================================================
+# 도구 선택 헬퍼
+# ============================================================================
+
+
+def _get_tools_for_depth(depth: ResearchDepth) -> list:
+ """연구 깊이에 따라 사용할 도구 목록을 반환한다.
+
+ 깊이 수준에 따라 다른 도구 세트를 제공합니다:
+ - 기본: think_tool (항상 포함)
+ - web 소스: mgrep_search, tavily_search
+ - arxiv 소스: arxiv_search
+ - github 소스: github_code_search
+ - docs 소스: library_docs_search
+ - 다중 소스 (2개 이상): comprehensive_search
+
+ Args:
+ depth: 연구 깊이 (ResearchDepth enum).
+
+ Returns:
+ 해당 깊이에서 사용 가능한 도구 목록.
+ """
+ # 깊이 설정 로드
+ config = get_depth_config(depth)
+
+ # 기본 도구: 항상 think_tool 포함
+ tools = [think_tool]
+
+ # 소스별 도구 추가
+ if "web" in config.sources:
+ tools.extend([mgrep_search, tavily_search])
+
+ if "arxiv" in config.sources:
+ tools.append(arxiv_search)
+
+ if "github" in config.sources:
+ tools.append(github_code_search)
+
+ if "docs" in config.sources:
+ tools.append(library_docs_search)
+
+ # 다중 소스인 경우 통합 검색 도구 추가
+ if len(config.sources) > 1:
+ tools.append(comprehensive_search)
+
+ return tools
+
+
+# ============================================================================
+# 에이전트 팩토리
+# ============================================================================
def create_researcher_agent(
model: str | BaseChatModel | None = None,
backend: BackendProtocol | BackendFactory | None = None,
+ depth: ResearchDepth | str = ResearchDepth.STANDARD,
) -> CompiledStateGraph:
"""자율적 연구 DeepAgent를 생성한다.
- 이 에이전트는 다음 기능을 자체적으로 보유한다:
- - 계획 루프 (TodoListMiddleware를 통한 write_todos)
- - 연구 루프 (tavily_search + think_tool)
- - 컨텍스트 관리 (SummarizationMiddleware)
- - 중간 결과 저장을 위한 파일 접근 (FilesystemMiddleware)
-
- 본질적으로 자율적으로 작동하는 "연구 SubGraph"이다.
+ 이 함수는 주어진 깊이 수준에 맞는 연구 에이전트를 생성합니다.
+ 에이전트는 자체 계획 수립, 다중 소스 검색, 반성(reflection) 기능을 갖춥니다.
Args:
- model: 사용할 LLM. 기본값은 temperature=0인 gpt-4.1.
- backend: 파일 작업용 백엔드. 제공되면
- 연구자가 중간 결과를 파일시스템에 저장할 수 있다.
+ model: 사용할 LLM 모델.
+ - None: 기본 gpt-4.1 (temperature=0) 사용
+ - str: 모델 이름 (예: "gpt-4o")
+ - BaseChatModel: 직접 생성한 모델 인스턴스
+ backend: 파일 작업용 백엔드.
+ - None: 기본 StateBackend 사용
+ - FilesystemBackend, CompositeBackend 등 지정 가능
+ depth: 연구 깊이 수준.
+ - ResearchDepth enum 또는 문자열 ("quick", "standard", "deep", "exhaustive")
+ - 기본값: STANDARD
Returns:
- CompiledStateGraph: 독립적으로 사용하거나 오케스트레이터의
- CompiledSubAgent로 사용할 수 있는 완전 자율적 연구 에이전트.
+ CompiledStateGraph: 실행 가능한 자율 연구 에이전트.
Example:
- # 독립 사용
- researcher = create_researcher_agent()
- result = researcher.invoke({
- "messages": [HumanMessage("양자 컴퓨팅 트렌드 연구")]
- })
-
- # 오케스트레이터의 SubAgent로 사용
- subagent = get_researcher_subagent()
- orchestrator = create_deep_agent(subagents=[subagent, ...])
+ >>> # 기본 설정으로 생성
+ >>> agent = create_researcher_agent()
+ >>>
+ >>> # 깊이 지정
+ >>> agent = create_researcher_agent(depth="deep")
+ >>>
+ >>> # 커스텀 모델과 백엔드
+ >>> from langchain_openai import ChatOpenAI
+ >>> from deepagents.backends import FilesystemBackend
+ >>> agent = create_researcher_agent(
+ ... model=ChatOpenAI(model="gpt-4o", temperature=0.2),
+ ... backend=FilesystemBackend(root_dir="./research"),
+ ... depth=ResearchDepth.EXHAUSTIVE,
+ ... )
"""
+ # 모델이 지정되지 않았으면 기본 모델 사용
if model is None:
model = ChatOpenAI(model="gpt-4.1", temperature=0.0)
- # 현재 날짜로 프롬프트 포맷팅
- current_date = datetime.now().strftime("%Y-%m-%d")
- formatted_prompt = AUTONOMOUS_RESEARCHER_INSTRUCTIONS.format(date=current_date)
+ # 문자열로 전달된 깊이를 enum으로 변환
+ if isinstance(depth, str):
+ depth = ResearchDepth(depth)
+ # 깊이 설정 로드
+ config = get_depth_config(depth)
+
+ # 깊이에 맞는 도구 선택
+ tools = _get_tools_for_depth(depth)
+
+ # 현재 날짜 (프롬프트에 포함)
+ current_date = datetime.now().strftime("%Y-%m-%d")
+
+ # 깊이에 따른 프롬프트 구성
+ if depth in (ResearchDepth.DEEP, ResearchDepth.EXHAUSTIVE):
+ # DEEP/EXHAUSTIVE: Ralph Loop 프롬프트 사용
+ formatted_prompt = build_research_prompt(
+ depth=depth,
+ query="{query}", # 런타임에 치환됨
+ max_iterations=config.max_ralph_iterations,
+ )
+ else:
+ # QUICK/STANDARD: 기본 자율 연구 프롬프트 사용
+ formatted_prompt = AUTONOMOUS_RESEARCHER_INSTRUCTIONS.format(date=current_date)
+
+ # DeepAgent 생성 및 반환
return create_deep_agent(
model=model,
- tools=[tavily_search, think_tool],
+ tools=tools,
system_prompt=formatted_prompt,
backend=backend,
)
+# ============================================================================
+# SubAgent 통합
+# ============================================================================
+
+
def get_researcher_subagent(
model: str | BaseChatModel | None = None,
backend: BackendProtocol | BackendFactory | None = None,
+ depth: ResearchDepth | str = ResearchDepth.STANDARD,
) -> dict:
- """오케스트레이터에서 사용할 CompiledSubAgent로 연구자를 가져온다.
+ """오케스트레이터용 CompiledSubAgent로 연구자를 반환한다.
- 이 함수는 자율적 연구 에이전트를 생성하고 SubAgentMiddleware가
- 기대하는 CompiledSubAgent 형식으로 래핑한다.
+ 이 함수는 메인 에이전트에서 서브에이전트로 호출할 수 있는 형태로
+ 연구 에이전트를 래핑합니다.
Args:
- model: 사용할 LLM. 기본값은 gpt-4.1.
+ model: 사용할 LLM 모델 (create_researcher_agent과 동일).
backend: 파일 작업용 백엔드.
+ depth: 연구 깊이 수준.
Returns:
- dict: 다음 키를 가진 CompiledSubAgent:
- - name: "researcher"
- - description: 오케스트레이터가 위임 결정 시 사용
- - runnable: 자율적 연구 에이전트
+ 다음 키를 포함하는 딕셔너리:
+ - name: 서브에이전트 이름 ("researcher")
+ - description: 서브에이전트 설명 (깊이 정보 포함)
+ - runnable: 실행 가능한 에이전트 객체
Example:
- from research_agent.researcher import get_researcher_subagent
-
- researcher = get_researcher_subagent(model=model, backend=backend)
-
- agent = create_deep_agent(
- model=model,
- subagents=[researcher, explorer, synthesizer],
- ...
- )
+ >>> from deepagents import create_deep_agent
+ >>> researcher = get_researcher_subagent(depth="deep")
+ >>> main_agent = create_deep_agent(
+ ... subagents=[researcher],
+ ... system_prompt="작업을 researcher에게 위임하세요."
+ ... )
"""
- researcher = create_researcher_agent(model=model, backend=backend)
+ # 연구 에이전트 생성
+ researcher = create_researcher_agent(model=model, backend=backend, depth=depth)
+ # 깊이를 enum으로 변환
+ depth_enum = ResearchDepth(depth) if isinstance(depth, str) else depth
+ config = get_depth_config(depth_enum)
+
+ # 설명 문자열 구성
+ description = (
+ f"Autonomous research agent ({depth_enum.value} mode). "
+ f"Max {config.max_ralph_iterations} iterations, "
+ f"sources: {', '.join(config.sources)}. "
+ "Use for comprehensive topic research with self-planning."
+ )
+
+ # SubAgent 형식으로 반환
return {
"name": "researcher",
- "description": (
- "Autonomous deep research agent with self-planning and "
- "'breadth-first, depth-second' methodology. Use for comprehensive "
- "topic research requiring multiple search iterations and synthesis. "
- "The agent plans its own research phases, reflects after each search, "
- "and synthesizes findings into structured output. "
- "Best for: complex topics, multi-faceted questions, trend analysis."
- ),
+ "description": description,
"runnable": researcher,
}
diff --git a/research_agent/researcher/depth.py b/research_agent/researcher/depth.py
new file mode 100644
index 0000000..8c12cf0
--- /dev/null
+++ b/research_agent/researcher/depth.py
@@ -0,0 +1,204 @@
+"""연구 깊이 설정 모듈.
+
+이 모듈은 연구 에이전트의 깊이(depth) 수준을 정의하고 관리합니다.
+각 깊이 수준은 검색 횟수, 반복 횟수, 사용 가능한 소스 등을 결정합니다.
+
+## 깊이 수준 비교
+
+```
+┌──────────────┬─────────┬───────────┬────────────────────────────────┐
+│ 깊이 │ 검색 수 │ 반복 횟수 │ 소스 │
+├──────────────┼─────────┼───────────┼────────────────────────────────┤
+│ QUICK │ 3 │ 1 │ web │
+│ STANDARD │ 10 │ 2 │ web, local │
+│ DEEP │ 25 │ 5 │ web, local, github, arxiv │
+│ EXHAUSTIVE │ 50 │ 10 │ web, local, github, arxiv, docs│
+└──────────────┴─────────┴───────────┴────────────────────────────────┘
+```
+
+v2 업데이트 (2026-01):
+- ResearchDepth enum 도입
+- DepthConfig dataclass로 구성 관리
+- 쿼리 기반 깊이 추론 (infer_research_depth)
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum
+from typing import Literal
+
+
+class ResearchDepth(Enum):
+ """연구 깊이 수준을 나타내는 열거형.
+
+ 각 깊이 수준은 다른 검색 전략과 리소스 사용량을 의미합니다:
+
+ - QUICK: 빠른 답변이 필요할 때. 최소 검색, 단일 반복.
+ - STANDARD: 균형 잡힌 조사. 웹 + 로컬 소스 사용.
+ - DEEP: 심층 분석. 교차 검증 필요, GitHub/arXiv 포함.
+ - EXHAUSTIVE: 학술적 완성도. 공식 문서까지 포함, 최대 검증.
+ """
+
+ QUICK = "quick" # 빠른 조사 (최대 3회 검색)
+ STANDARD = "standard" # 표준 조사 (최대 10회 검색)
+ DEEP = "deep" # 심층 조사 (최대 25회 검색, Ralph Loop)
+ EXHAUSTIVE = "exhaustive" # 철저한 조사 (최대 50회 검색, 확장 Ralph Loop)
+
+
+@dataclass(frozen=True)
+class DepthConfig:
+ """연구 깊이별 설정을 담는 불변 데이터 클래스.
+
+ Attributes:
+ max_searches: 허용된 최대 검색 횟수.
+ max_ralph_iterations: Ralph Loop 최대 반복 횟수.
+ sources: 사용 가능한 검색 소스 튜플 (예: ("web", "arxiv")).
+ require_cross_validation: 교차 검증 필수 여부.
+ min_sources_for_claim: 주장당 필요한 최소 소스 수.
+ coverage_threshold: 완료 판정 기준 커버리지 점수 (0.0 ~ 1.0).
+ """
+
+ max_searches: int # 최대 검색 횟수
+ max_ralph_iterations: int # Ralph Loop 최대 반복
+ sources: tuple[str, ...] # 사용 가능한 소스
+ require_cross_validation: bool # 교차 검증 필요 여부
+ min_sources_for_claim: int # 주장당 최소 소스 수
+ coverage_threshold: float # 커버리지 임계값
+
+
+# ============================================================================
+# 깊이별 기본 설정
+# ============================================================================
+
+DEPTH_CONFIGS: dict[ResearchDepth, DepthConfig] = {
+ # QUICK: 빠른 답변용
+ # - 최대 3회 검색
+ # - 단일 반복 (Ralph Loop 없음)
+ # - 웹 소스만 사용
+ # - 교차 검증 없음
+ ResearchDepth.QUICK: DepthConfig(
+ max_searches=3,
+ max_ralph_iterations=1,
+ sources=("web",),
+ require_cross_validation=False,
+ min_sources_for_claim=1,
+ coverage_threshold=0.5,
+ ),
+ # STANDARD: 균형 잡힌 조사
+ # - 최대 10회 검색
+ # - 2회 반복
+ # - 웹 + 로컬 소스
+ # - 교차 검증 없음
+ ResearchDepth.STANDARD: DepthConfig(
+ max_searches=10,
+ max_ralph_iterations=2,
+ sources=("web", "local"),
+ require_cross_validation=False,
+ min_sources_for_claim=1,
+ coverage_threshold=0.7,
+ ),
+ # DEEP: 심층 분석 (Ralph Loop 활성화)
+ # - 최대 25회 검색
+ # - 5회 반복
+ # - 웹 + 로컬 + GitHub + arXiv
+ # - 교차 검증 필수 (주장당 최소 2개 소스)
+ ResearchDepth.DEEP: DepthConfig(
+ max_searches=25,
+ max_ralph_iterations=5,
+ sources=("web", "local", "github", "arxiv"),
+ require_cross_validation=True,
+ min_sources_for_claim=2,
+ coverage_threshold=0.85,
+ ),
+ # EXHAUSTIVE: 학술적 완성도 (확장 Ralph Loop)
+ # - 최대 50회 검색
+ # - 10회 반복
+ # - 모든 소스 사용 (docs 포함)
+ # - 교차 검증 필수 (주장당 최소 3개 소스)
+ ResearchDepth.EXHAUSTIVE: DepthConfig(
+ max_searches=50,
+ max_ralph_iterations=10,
+ sources=("web", "local", "github", "arxiv", "docs"),
+ require_cross_validation=True,
+ min_sources_for_claim=3,
+ coverage_threshold=0.95,
+ ),
+}
+
+
+# ============================================================================
+# 깊이 추론용 키워드 세트
+# ============================================================================
+
+# EXHAUSTIVE 트리거 키워드
+_EXHAUSTIVE_KEYWORDS = frozenset(
+ ["comprehensive", "thorough", "academic", "literature review", "exhaustive"]
+)
+
+# DEEP 트리거 키워드
+_DEEP_KEYWORDS = frozenset(
+ ["analyze", "compare", "investigate", "deep dive", "in-depth"]
+)
+
+# QUICK 트리거 키워드
+_QUICK_KEYWORDS = frozenset(["quick", "brief", "summary", "what is", "simple"])
+
+
+# ============================================================================
+# 유틸리티 함수
+# ============================================================================
+
+
+def infer_research_depth(query: str) -> ResearchDepth:
+ """쿼리 문자열에서 적절한 연구 깊이를 추론한다.
+
+ 쿼리에 포함된 키워드를 기반으로 연구 깊이를 결정합니다.
+ 키워드 매칭 우선순위: EXHAUSTIVE > DEEP > QUICK > STANDARD(기본값).
+
+ Args:
+ query: 사용자의 연구 쿼리 문자열.
+
+ Returns:
+ 추론된 ResearchDepth 열거형 값.
+ 매칭되는 키워드가 없으면 STANDARD 반환.
+
+ Example:
+ >>> infer_research_depth("quick summary of AI trends")
+ ResearchDepth.QUICK
+ >>> infer_research_depth("analyze different RAG strategies")
+ ResearchDepth.DEEP
+ >>> infer_research_depth("comprehensive literature review on transformers")
+ ResearchDepth.EXHAUSTIVE
+ """
+ query_lower = query.lower()
+
+ # 키워드 우선순위대로 검사
+ if any(kw in query_lower for kw in _EXHAUSTIVE_KEYWORDS):
+ return ResearchDepth.EXHAUSTIVE
+ if any(kw in query_lower for kw in _DEEP_KEYWORDS):
+ return ResearchDepth.DEEP
+ if any(kw in query_lower for kw in _QUICK_KEYWORDS):
+ return ResearchDepth.QUICK
+
+ # 기본값: STANDARD
+ return ResearchDepth.STANDARD
+
+
+def get_depth_config(depth: ResearchDepth) -> DepthConfig:
+ """연구 깊이에 해당하는 설정을 반환한다.
+
+ Args:
+ depth: ResearchDepth 열거형 값.
+
+ Returns:
+ 해당 깊이의 DepthConfig 객체.
+
+ Example:
+ >>> config = get_depth_config(ResearchDepth.DEEP)
+ >>> config.max_searches
+ 25
+ >>> config.sources
+ ('web', 'local', 'github', 'arxiv')
+ """
+ return DEPTH_CONFIGS[depth]
diff --git a/research_agent/researcher/prompts.py b/research_agent/researcher/prompts.py
index d96d9e0..2aeaccd 100644
--- a/research_agent/researcher/prompts.py
+++ b/research_agent/researcher/prompts.py
@@ -1,131 +1,300 @@
-"""자율적 연구 에이전트를 위한 프롬프트.
+"""Prompts for autonomous research agent.
-이 프롬프트는 "넓게 탐색 → 깊게 파기" 패턴을 따르는
-자율적인 연구 워크플로우를 정의합니다.
+This module defines prompts following the "breadth-first, then depth" pattern
+for autonomous research workflows.
+
+v2 Updates (2026-01):
+- ResearchDepth-based prompt branching (QUICK/STANDARD/DEEP/EXHAUSTIVE)
+- Ralph Loop iterative research pattern support
+- Multi-source search integration (mgrep, arXiv, grep.app, Context7)
"""
-AUTONOMOUS_RESEARCHER_INSTRUCTIONS = """You are an autonomous research agent. Your job is to thoroughly research a topic by following a "breadth-first, then depth" approach.
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from .depth import ResearchDepth
+
+AUTONOMOUS_RESEARCHER_INSTRUCTIONS = """You are an autonomous research agent. Your job is to research a topic and return evidence-backed findings using a breadth-first → depth approach.
For context, today's date is {date}.
-## Your Capabilities
+## Tooling (What you may have access to)
+Your tool set depends on the active `ResearchDepth` configuration. Possible tools include:
+- `think_tool`: Mandatory reflection step used to decide the next action.
+- `write_todos`: Planning tool for creating/updating a structured task list (call at most once per response).
+- Web search:
+ - `mgrep_search` with `web=True` (if `mgrep` is available in the environment)
+ - `tavily_search` (web search with full content extraction)
+- Local codebase search:
+ - `mgrep_search` with `path="..."` (semantic local search)
+- Academic search:
+ - `arxiv_search`
+- Public implementation search:
+ - `github_code_search`
+- Official documentation lookup:
+ - `library_docs_search`
+- Multi-source orchestration:
+ - `comprehensive_search` (use to reduce total tool calls when multiple sources are needed)
-You have access to:
-- **tavily_search**: Web search with full content extraction
-- **think_tool**: Reflection and strategic planning
-- **write_todos**: Self-planning and progress tracking
+Only reference tools you actually have in the current run. If you are uncertain, attempt a single appropriate tool call rather than assuming.
-## Autonomous Research Workflow
+## Operating principle: Breadth first, then depth
+Your default strategy is:
+1) Breadth: establish terminology, scope, and candidate directions quickly.
+2) Depth: pick the highest-value directions and validate claims with multiple sources.
+3) Synthesis: produce a structured, evidence-backed response with explicit uncertainty.
-### Phase 1: Exploratory Search (1-2 searches)
+## Required loop: Search → Reflect → Decide
+After EVERY search tool call, you MUST call `think_tool` and include:
+1) What you learned (specific facts/claims, not vague summaries)
+2) What is still missing (specific questions or missing evidence)
+3) The next concrete action:
+ - exact tool name
+ - exact query string
+ - any key parameters (e.g., `web=True`, `max_results=5`, `library_name="..."`)
+4) A stop/continue decision (and why)
-**Goal**: Get the lay of the land
+Example reflection template:
+- Learned:
+ - Claim A: ...
+ - Claim B: ...
+- Missing:
+ - Need evidence for X from official docs or academic sources
+- Next action:
+ - Tool: `github_code_search`
+ - Query: "getServerSession("
+ - Params: language=["TypeScript","TSX"], max_results=3
+- Decision:
+ - Continue (need implementation evidence), or Stop (requirements satisfied)
-Start with broad searches to understand:
-- Key concepts and terminology in the field
-- Major players, sources, and authorities
-- Recent trends and developments
-- Potential sub-topics worth exploring
+## Phase 1: Exploratory breadth (1–2 searches)
+Goal: define scope and build a short research map.
+- Run 1 broad query to gather definitions, key terms, and major subtopics.
+- Optionally run 1 follow-up query to resolve ambiguous terminology or identify the best 2–3 directions.
+- Use `think_tool` after each search and explicitly list the 2–3 directions you will pursue next.
-After each search, **ALWAYS** use think_tool to:
-```
-"What did I learn? Key concepts are: ...
-What are 2-3 promising directions for deeper research?
-1. Direction A: [reason]
-2. Direction B: [reason]
-3. Direction C: [reason]
-Do I need more exploration, or can I proceed to Phase 2?"
-```
+## Phase 2: Directed depth (focused searches per direction)
+Goal: answer the research question with validated claims.
+For each chosen direction:
+1. State a precise sub-question (what exactly must be answered).
+2. Run focused searches to answer it.
+3. Validate:
+ - If cross-validation is required by the active depth mode, do not finalize a major claim until it has the required number of independent sources.
+ - If sources conflict, either resolve the conflict with an explicit verification search or clearly document the contradiction.
-### Phase 2: Directed Research (1-2 searches per direction)
+## Phase 3: Synthesis (final output)
+Goal: convert evidence into a usable answer.
+Your output must:
+- Be structured with headings.
+- Separate facts from interpretations.
+- Explicitly list contradictions/unknowns instead of guessing.
+- Include a Sources section with stable citation numbering and URLs.
-**Goal**: Deep dive into promising directions
+## Planning with `write_todos`
+If the task is multi-step (3+ steps), call `write_todos` once at the start to create a small plan (4–7 items).
+Update the plan only when the strategy changes materially (again: at most one `write_todos` call per response).
-For each promising direction identified in Phase 1:
-1. Formulate a specific, focused search query
-2. Execute tavily_search with the focused query
-3. Use think_tool to assess:
-```
-"Direction: [name]
-What new insights did this reveal?
-- Insight 1: ...
-- Insight 2: ...
-Is this direction yielding valuable information? [Yes/No]
-Should I continue deeper or move to the next direction?"
-```
+Example TODO plan:
+1. Define scope + glossary (breadth search)
+2. Identify 2–3 high-value directions (reflection)
+3. Research direction A with validation
+4. Research direction B with validation
+5. Resolve contradictions / verify edge cases
+6. Synthesize findings + sources
-### Phase 3: Synthesis
+## Stop conditions (measurable)
+Stop researching when ANY of the following are true:
+- You can answer the user's question directly and completely with citations.
+- You have hit the configured search budget for the current depth mode.
+- Your last 2 searches are redundant (no new claims, no new evidence, no new constraints).
+- Cross-validation requirements are satisfied for all major claims (when required).
+- Remaining gaps are minor and can be stated as "unknown" without blocking the main answer.
-**Goal**: Combine all findings into a coherent response
+## Response format (to the orchestrator)
+Return Markdown:
-After completing directed research:
-1. Review all gathered information
-2. Identify patterns and connections
-3. Note where sources agree or disagree
-4. Structure your findings clearly
-
-## Self-Management with write_todos
-
-At the start, create a research plan:
-
-```
-1. [Explore] Broad search to understand the research landscape
-2. [Analyze] Review findings and identify 2-3 promising directions
-3. [Deep Dive] Research Direction A: [topic]
-4. [Deep Dive] Research Direction B: [topic]
-5. [Synthesize] Combine findings into structured response
-```
-
-Mark each todo as completed when done. Adjust your plan if needed.
-
-## Hard Limits (Token Efficiency)
-
-| Phase | Max Searches | Purpose |
-|-------|-------------|---------|
-| Exploratory | 2 | Broad landscape understanding |
-| Directed | 3-4 | Focused deep dives |
-| **TOTAL** | **5-6** | Entire research session |
-
-## Stop Conditions
-
-Stop researching when ANY of these are true:
-- You have sufficient information to answer comprehensively
-- Your last 2 searches returned similar/redundant information
-- You've reached the maximum search limit (5-6)
-- All promising directions have been adequately explored
-
-## Response Format
-
-Structure your final response as:
-
-```markdown
## Key Findings
+### Finding 1
+- Claim:
+- Evidence:
+- Why it matters:
-### Finding 1: [Title]
-[Detailed explanation with inline citations [1], [2]]
+### Finding 2
+...
-### Finding 2: [Title]
-[Detailed explanation with inline citations]
+## Implementation Evidence (when relevant)
+- Real-world code patterns, pitfalls, and links to repos/files (via citations).
-### Finding 3: [Title]
-[Detailed explanation with inline citations]
-
-## Source Agreement Analysis
-- **High agreement**: [topics where sources align]
-- **Disagreement/Uncertainty**: [topics with conflicting info]
+## Contradictions / Unknowns
+- What conflicts, what is unverified, and what would resolve it.
## Sources
-[1] Source Title: URL
-[2] Source Title: URL
+[1] Title: URL
+[2] Title: URL
...
-```
-
-The orchestrator will integrate your findings into the final report.
-
-## Important Notes
-
-1. **Think before each action**: Use think_tool to plan and reflect
-2. **Quality over quantity**: Fewer, focused searches beat many unfocused ones
-3. **Track your progress**: Use write_todos to stay organized
-4. **Know when to stop**: Don't over-research; stop when you have enough
+"""
+
+
+DEPTH_PROMPTS: dict[str, str] = {
+ "quick": """## Quick Research Mode
+
+Objective: produce a correct, minimal answer fast.
+
+**Search budget**: max 3 total searches
+**Iterations**: 1
+**Primary sources**: web
+
+**Available tools (may vary by environment)**:
+- `mgrep_search` (prefer `web=True` if available)
+- `tavily_search` (fallback web search with full content extraction)
+- `think_tool`
+
+**Procedure**:
+1. Run exactly 1 broad web search to establish definitions and key terms.
+2. If a critical gap remains (missing definition, missing "what/why/how"), run 1 targeted follow-up search.
+3. Stop and answer. Do not exceed 3 total searches.
+
+**Completion criteria**:
+- You can answer the user's question directly in 4–10 sentences, AND
+- At least 1 cited source URL supports the central claim, OR you explicitly mark the answer as uncertain.
+
+**Output requirements**:
+- 2–5 key bullets or short paragraphs
+- 1–2 citations in a final Sources section
+""",
+ "standard": """## Standard Research Mode
+
+Objective: balanced coverage with evidence, without over-searching.
+
+**Search budget**: max 10 total searches
+**Iterations**: up to 2 (plan → search → reflect → refine)
+**Primary sources**: web + local (codebase)
+
+**Available tools**:
+- `mgrep_search` (local search via `path`, optional web via `web=True`)
+- `tavily_search`
+- `comprehensive_search` (multi-source wrapper; use when it reduces tool calls)
+- `think_tool`
+
+**Iteration 1 (landscape + local grounding)**:
+1. 1–2 broad searches to build a short glossary and identify 2–3 sub-questions.
+2. 1 local search (`mgrep_search` with `path`) to find relevant code/config patterns if applicable.
+
+**Iteration 2 (targeted fill + verification)**:
+1. 2–4 targeted searches to answer each sub-question.
+2. If claims conflict, run 1 explicit verification search to resolve the conflict or mark uncertainty.
+
+**Completion criteria**:
+- All identified sub-questions are answered, AND
+- No single key claim depends on an unverified single-source assertion, AND
+- You are within the 10-search budget.
+
+**Output requirements**:
+- 300–700 words (or equivalent detail)
+- Clear section headings (## / ###)
+- Inline citations and a Sources list with stable numbering
+""",
+ "deep": """## Deep Research Mode (Ralph Loop)
+
+Objective: multi-angle research with cross-validation and implementation evidence.
+
+**Search budget**: max 25 total searches
+**Iterations**: up to 5 (Ralph Loop)
+**Primary sources**: web + local + GitHub code + arXiv
+
+**Available tools**:
+- `mgrep_search`
+- `tavily_search`
+- `github_code_search`
+- `arxiv_search`
+- `comprehensive_search`
+- `think_tool`
+
+**Ralph Loop (repeat up to 5 iterations)**:
+1. Plan: use `think_tool` to state (a) what you know, (b) what you need next, and (c) the exact next tool call(s).
+2. Search: execute 3–6 focused tool calls max per iteration (keep a running count).
+3. Extract: write down concrete claims, each with source IDs.
+4. Validate: ensure each major claim has **>= 2 independent sources** (web + paper, web + GitHub example, etc.).
+5. Update coverage: self-assess coverage as a number in [0.0, 1.0] and state what remains.
+
+**Completion criteria**:
+- Self-assessed coverage >= 0.85, AND
+- Every major claim has >= 2 sources, AND
+- Contradictions are either resolved or explicitly documented, AND
+- You output `RESEARCH_COMPLETE`.
+
+**Output requirements**:
+- Structured findings with clear scoping (what applies, what does not)
+- A dedicated "Implementation Evidence" section when relevant (GitHub code snippets + repo/file context)
+- A dedicated "Contradictions / Unknowns" section
+""",
+ "exhaustive": """## Exhaustive Research Mode (Extended Ralph Loop)
+
+Objective: near-academic completeness with official documentation support.
+
+**Search budget**: max 50 total searches
+**Iterations**: up to 10 (Extended Ralph Loop)
+**Primary sources**: web + local + GitHub code + arXiv + official docs
+
+**Available tools**:
+- `mgrep_search`
+- `tavily_search`
+- `github_code_search`
+- `arxiv_search`
+- `library_docs_search`
+- `comprehensive_search`
+- `think_tool`
+
+**Extended Ralph Loop (repeat up to 10 iterations)**:
+1. Literature: use `arxiv_search` to establish foundational concepts and vocabulary.
+2. Industry: use `tavily_search` / `mgrep_search(web=True)` for applied practice and recent changes.
+3. Implementation: use `github_code_search` for real-world patterns and failure modes.
+4. Official docs: use `library_docs_search` for normative API behavior and constraints.
+5. Reconcile: explicitly cross-check conflicts; do not "average" contradictions—state what differs and why.
+
+**Completion criteria** (ALL required):
+- Self-assessed coverage >= 0.95, AND
+- Every major claim has **>= 3 sources**, AND
+- A "Source Agreement" section exists (high/medium/low agreement), AND
+- You output `RESEARCH_COMPLETE` ONLY when criteria are met.
+
+**Output requirements**:
+- Annotated bibliography (1–2 sentence annotation per key source)
+- Confidence score per major finding (High/Medium/Low) based on agreement and source type
+- Explicit "Open Questions" list for anything not resolvable within budget
+""",
+}
+
+
+def get_depth_prompt(depth: ResearchDepth) -> str:
+ from .depth import ResearchDepth as RD
+
+ depth_key = depth.value if isinstance(depth, RD) else str(depth)
+ return DEPTH_PROMPTS.get(depth_key, DEPTH_PROMPTS["standard"])
+
+
+def build_research_prompt(
+ depth: ResearchDepth,
+ query: str,
+ iteration: int = 1,
+ max_iterations: int = 1,
+ coverage_score: float = 0.0,
+) -> str:
+ depth_prompt = get_depth_prompt(depth)
+
+ return f"""{depth_prompt}
+
+---
+
+## Current Task
+
+**Query**: {query}
+**Iteration**: {iteration}/{max_iterations}
+**Coverage**: {coverage_score:.2%}
+
+---
+
+{AUTONOMOUS_RESEARCHER_INSTRUCTIONS}
"""
diff --git a/research_agent/researcher/ralph_loop.py b/research_agent/researcher/ralph_loop.py
new file mode 100644
index 0000000..7f3d252
--- /dev/null
+++ b/research_agent/researcher/ralph_loop.py
@@ -0,0 +1,607 @@
+"""Ralph Loop 연구 패턴 모듈.
+
+이 모듈은 반복적 연구 패턴(Ralph Loop)을 구현합니다.
+에이전트가 연구 → 반성 → 갱신 사이클을 통해 점진적으로
+연구 커버리지를 높여가는 방식을 지원합니다.
+
+## Ralph Loop 동작 흐름
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Ralph Loop 사이클 │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
+│ │ Plan │───▶│ Search │───▶│ Extract │───▶│ Validate │ │
+│ │ (계획) │ │ (검색) │ │ (추출) │ │ (검증) │ │
+│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
+│ ▲ │ │
+│ │ ▼ │
+│ ┌──────────┐ ┌──────────┐ │
+│ │ Continue │◀──────────────────────────────────│ Update │ │
+│ │ (계속?) │ │(커버리지)│ │
+│ └──────────┘ └──────────┘ │
+│ │ │
+│ ▼ │
+│ ┌──────────┐ │
+│ │ Complete │ coverage >= threshold OR max iterations │
+│ │ (완료) │ │
+│ └──────────┘ │
+│ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+v2 업데이트 (2026-01):
+- RalphLoopState 상태 관리 클래스
+- SourceQuality 소스 품질 평가
+- Finding 발견 항목 데이터 클래스
+- ResearchSession 세션 관리
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from .depth import DepthConfig
+
+
+# ============================================================================
+# Ralph Loop 상태 관리
+# ============================================================================
+
+
+@dataclass
+class RalphLoopState:
+ """Ralph Loop의 현재 상태를 추적하는 데이터 클래스.
+
+ Attributes:
+ iteration: 현재 반복 횟수 (1부터 시작).
+ max_iterations: 최대 허용 반복 횟수 (0이면 무제한).
+ completion_promise: 완료 시 출력할 약속 태그.
+ started_at: 루프 시작 시간 (ISO 8601 형식).
+ findings_count: 현재까지 수집된 발견 항목 수.
+ coverage_score: 현재 커버리지 점수 (0.0 ~ 1.0).
+ """
+
+ iteration: int = 1 # 현재 반복 횟수
+ max_iterations: int = 0 # 최대 반복 (0 = 무제한)
+ completion_promise: str = "RESEARCH_COMPLETE" # 완료 태그
+ started_at: str = field(
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
+ )
+ findings_count: int = 0 # 발견 항목 수
+ coverage_score: float = 0.0 # 커버리지 점수
+
+ def is_max_reached(self) -> bool:
+ """최대 반복 횟수에 도달했는지 확인한다.
+
+ Returns:
+ max_iterations > 0이고 현재 반복이 최대 이상이면 True.
+ """
+ return self.max_iterations > 0 and self.iteration >= self.max_iterations
+
+
+# ============================================================================
+# 소스 유형 및 품질
+# ============================================================================
+
+
+class SourceType:
+ """소스 유형을 나타내는 상수 클래스.
+
+ 각 소스 유형은 다른 권위도(authority) 점수를 갖습니다:
+ - ARXIV: 0.9 (학술 논문, 가장 높은 권위)
+ - DOCS: 0.85 (공식 문서)
+ - GITHUB: 0.7 (실제 구현 코드)
+ - LOCAL: 0.6 (로컬 코드베이스)
+ - WEB: 0.5 (일반 웹 검색, 가장 낮은 권위)
+ """
+
+ WEB = "web" # 웹 검색 결과
+ ARXIV = "arxiv" # arXiv 논문
+ GITHUB = "github" # GitHub 코드
+ DOCS = "docs" # 공식 문서
+ LOCAL = "local" # 로컬 코드베이스
+
+
+@dataclass
+class SourceQuality:
+ """소스의 품질을 평가하는 데이터 클래스.
+
+ 품질 점수는 세 가지 요소의 가중 평균으로 계산됩니다:
+ - recency (최신성): 20%
+ - authority (권위도): 40%
+ - relevance (관련성): 40%
+
+ 추가로 검증 횟수에 따른 보너스가 적용됩니다 (최대 15%).
+
+ Attributes:
+ source_type: 소스 유형 (SourceType 상수).
+ recency_score: 최신성 점수 (0.0 ~ 1.0).
+ authority_score: 권위도 점수 (0.0 ~ 1.0).
+ relevance_score: 관련성 점수 (0.0 ~ 1.0).
+ verification_count: 다른 소스에 의한 검증 횟수.
+ """
+
+ source_type: str # 소스 유형
+ recency_score: float = 0.0 # 최신성 (0.0 ~ 1.0)
+ authority_score: float = 0.0 # 권위도 (0.0 ~ 1.0)
+ relevance_score: float = 0.0 # 관련성 (0.0 ~ 1.0)
+ verification_count: int = 0 # 검증 횟수
+
+ @property
+ def overall_score(self) -> float:
+ """전체 품질 점수를 계산한다.
+
+ 가중 평균 + 검증 보너스로 계산됩니다.
+
+ Returns:
+ 0.0 ~ 1.0 범위의 전체 품질 점수.
+ """
+ # 가중 평균 계산 (recency 20%, authority 40%, relevance 40%)
+ base_score = (
+ self.recency_score * 0.2
+ + self.authority_score * 0.4
+ + self.relevance_score * 0.4
+ )
+ # 검증 보너스 (검증당 5%, 최대 15%)
+ verification_bonus = min(self.verification_count * 0.05, 0.15)
+ # 최대 1.0으로 제한
+ return min(base_score + verification_bonus, 1.0)
+
+ @classmethod
+ def from_source_type(cls, source_type: str, **kwargs) -> "SourceQuality":
+ """소스 유형에서 SourceQuality 객체를 생성한다.
+
+ 소스 유형에 따른 기본 권위도 점수가 자동으로 적용됩니다.
+
+ Args:
+ source_type: SourceType 상수 중 하나.
+ **kwargs: 추가 점수 값 (recency_score, relevance_score 등).
+
+ Returns:
+ 생성된 SourceQuality 객체.
+ """
+ # 소스 유형별 기본 권위도 점수
+ authority_defaults = {
+ SourceType.ARXIV: 0.9, # 학술 논문 - 최고 권위
+ SourceType.DOCS: 0.85, # 공식 문서
+ SourceType.GITHUB: 0.7, # 실제 구현
+ SourceType.WEB: 0.5, # 일반 웹
+ SourceType.LOCAL: 0.6, # 로컬 코드
+ }
+ return cls(
+ source_type=source_type,
+ authority_score=kwargs.get(
+ "authority_score", authority_defaults.get(source_type, 0.5)
+ ),
+ recency_score=kwargs.get("recency_score", 0.5),
+ relevance_score=kwargs.get("relevance_score", 0.5),
+ verification_count=kwargs.get("verification_count", 0),
+ )
+
+
+# ============================================================================
+# 연구 발견 항목
+# ============================================================================
+
+
+@dataclass
+class Finding:
+ """연구에서 발견된 항목을 나타내는 데이터 클래스.
+
+ Attributes:
+ content: 발견 내용 (텍스트).
+ source_url: 소스 URL.
+ source_title: 소스 제목.
+ confidence: 신뢰도 점수 (0.0 ~ 1.0).
+ verified_by: 이 발견을 검증한 다른 소스 URL 목록.
+ quality: 소스 품질 정보 (선택).
+ """
+
+ content: str # 발견 내용
+ source_url: str # 소스 URL
+ source_title: str # 소스 제목
+ confidence: float # 신뢰도 (0.0 ~ 1.0)
+ verified_by: list[str] = field(default_factory=list) # 검증 소스
+ quality: SourceQuality | None = None # 소스 품질
+
+ @property
+ def weighted_confidence(self) -> float:
+ """품질 가중 신뢰도를 계산한다.
+
+ 소스 품질이 있으면 신뢰도에 품질 점수를 곱합니다.
+
+ Returns:
+ 품질 가중치가 적용된 신뢰도 점수.
+ """
+ if self.quality is None:
+ return self.confidence
+ return self.confidence * self.quality.overall_score
+
+
+# ============================================================================
+# Ralph Loop 관리자
+# ============================================================================
+
+
+class ResearchRalphLoop:
+ """Ralph Loop 연구 패턴을 관리하는 클래스.
+
+ 상태를 파일에 저장/로드하고, 연구 진행 상황을 추적합니다.
+
+ Attributes:
+ STATE_FILE: 상태 파일 경로 (.claude/research-ralph-loop.local.md).
+ query: 연구 쿼리.
+ max_iterations: 최대 반복 횟수.
+ coverage_threshold: 완료 판정 커버리지 임계값.
+ sources: 사용 가능한 소스 목록.
+ state: 현재 Ralph Loop 상태.
+ """
+
+ STATE_FILE = Path(".claude/research-ralph-loop.local.md")
+
+ def __init__(
+ self,
+ query: str,
+ depth_config: DepthConfig | None = None,
+ max_iterations: int = 10,
+ coverage_threshold: float = 0.85,
+ ):
+ """Ralph Loop를 초기화한다.
+
+ Args:
+ query: 연구 쿼리 문자열.
+ depth_config: 깊이 설정 (있으면 이 값이 우선).
+ max_iterations: 기본 최대 반복 횟수.
+ coverage_threshold: 기본 커버리지 임계값.
+ """
+ self.query = query
+
+ # depth_config가 있으면 해당 값 사용, 없으면 기본값 사용
+ self.max_iterations = (
+ depth_config.max_ralph_iterations if depth_config else max_iterations
+ )
+ self.coverage_threshold = (
+ depth_config.coverage_threshold if depth_config else coverage_threshold
+ )
+ self.sources = depth_config.sources if depth_config else ("web",)
+
+ # 초기 상태 생성
+ self.state = RalphLoopState(max_iterations=self.max_iterations)
+
+ def create_research_prompt(self) -> str:
+ """현재 반복에 대한 연구 프롬프트를 생성한다.
+
+ Returns:
+ 에이전트에게 전달할 Markdown 형식의 연구 프롬프트.
+ """
+ sources_str = ", ".join(self.sources)
+ return f"""## Research Iteration {self.state.iteration}/{self.max_iterations or "∞"}
+
+### Original Query
+{self.query}
+
+### Previous Work
+Check `research_workspace/` for previous findings.
+Read TODO.md for tracked progress.
+
+### Instructions
+1. Review existing findings
+2. Identify knowledge gaps
+3. Conduct targeted searches using: {sources_str}
+4. Update research files with new findings
+5. Update TODO.md with progress
+
+### Completion Criteria
+Output `{self.state.completion_promise}` ONLY when:
+- Coverage score >= {self.coverage_threshold} (current: {self.state.coverage_score:.2f})
+- All major aspects addressed
+- Findings cross-validated with 2+ sources
+- DO NOT lie to exit
+
+### Current Stats
+- Iteration: {self.state.iteration}
+- Findings: {self.state.findings_count}
+- Coverage: {self.state.coverage_score:.2%}
+"""
+
+ def save_state(self) -> None:
+ """현재 상태를 파일에 저장한다."""
+ # 디렉토리 생성
+ self.STATE_FILE.parent.mkdir(exist_ok=True)
+
+ # YAML frontmatter 형식으로 저장
+ promise_yaml = f'"{self.state.completion_promise}"'
+ content = f"""---
+active: true
+iteration: {self.state.iteration}
+max_iterations: {self.state.max_iterations}
+completion_promise: {promise_yaml}
+started_at: "{self.state.started_at}"
+findings_count: {self.state.findings_count}
+coverage_score: {self.state.coverage_score}
+---
+
+{self.create_research_prompt()}
+"""
+ self.STATE_FILE.write_text(content)
+
+ def load_state(self) -> bool:
+ """파일에서 상태를 로드한다.
+
+ Returns:
+ 상태 파일이 존재하고 성공적으로 로드되면 True.
+ """
+ if not self.STATE_FILE.exists():
+ return False
+
+ content = self.STATE_FILE.read_text()
+ lines = content.split("\n")
+
+ # YAML frontmatter 파싱
+ in_frontmatter = False
+ for line in lines:
+ if line.strip() == "---":
+ in_frontmatter = not in_frontmatter
+ continue
+ if not in_frontmatter:
+ continue
+
+ # 각 필드 파싱
+ if line.startswith("iteration:"):
+ self.state.iteration = int(line.split(":")[1].strip())
+ elif line.startswith("findings_count:"):
+ self.state.findings_count = int(line.split(":")[1].strip())
+ elif line.startswith("coverage_score:"):
+ self.state.coverage_score = float(line.split(":")[1].strip())
+
+ return True
+
+ def increment_iteration(self) -> None:
+ """반복 횟수를 증가시키고 상태를 저장한다."""
+ self.state.iteration += 1
+ self.save_state()
+
+ def update_coverage(self, findings_count: int, coverage_score: float) -> None:
+ """커버리지 정보를 갱신하고 상태를 저장한다.
+
+ Args:
+ findings_count: 새로운 발견 항목 수.
+ coverage_score: 새로운 커버리지 점수.
+ """
+ self.state.findings_count = findings_count
+ self.state.coverage_score = coverage_score
+ self.save_state()
+
+ def is_complete(self) -> bool:
+ """연구가 완료되었는지 확인한다.
+
+ Returns:
+ 최대 반복에 도달했거나 커버리지 임계값을 넘으면 True.
+ """
+ # 최대 반복 도달 확인
+ if self.state.is_max_reached():
+ return True
+ # 커버리지 임계값 확인
+ return self.state.coverage_score >= self.coverage_threshold
+
+ def cleanup(self) -> None:
+ """상태 파일을 삭제한다."""
+ if self.STATE_FILE.exists():
+ self.STATE_FILE.unlink()
+
+
+# ============================================================================
+# 연구 세션 관리
+# ============================================================================
+
+
+class ResearchSession:
+ """연구 세션을 관리하는 클래스.
+
+ 세션별 디렉토리를 생성하고, 발견 항목을 기록하며,
+ Ralph Loop를 통해 진행 상황을 추적합니다.
+
+ Attributes:
+ WORKSPACE: 연구 작업 공간 루트 디렉토리.
+ query: 연구 쿼리.
+ session_id: 세션 고유 식별자.
+ session_dir: 세션 디렉토리 경로.
+ ralph_loop: Ralph Loop 관리자.
+ findings: 수집된 발견 항목 목록.
+ """
+
+ WORKSPACE = Path("research_workspace")
+
+ def __init__(
+ self,
+ query: str,
+ depth_config: DepthConfig | None = None,
+ session_id: str | None = None,
+ ):
+ """연구 세션을 초기화한다.
+
+ Args:
+ query: 연구 쿼리 문자열.
+ depth_config: 깊이 설정 (선택).
+ session_id: 세션 ID (없으면 현재 시간으로 생성).
+ """
+ self.query = query
+ self.session_id = session_id or datetime.now().strftime("%Y%m%d_%H%M%S")
+ self.session_dir = self.WORKSPACE / f"session_{self.session_id}"
+ self.ralph_loop = ResearchRalphLoop(query, depth_config)
+ self.findings: list[Finding] = []
+
+ def initialize(self) -> None:
+ """세션 디렉토리와 초기 파일들을 생성한다."""
+ # 세션 디렉토리 생성
+ self.session_dir.mkdir(parents=True, exist_ok=True)
+
+ # TODO.md 초기 파일 생성
+ todo_content = f"""# Research TODO
+
+## Query
+{self.query}
+
+## Progress
+- [ ] Initial exploration (iteration 1)
+- [ ] Deep dive into key topics
+- [ ] Cross-validation of findings
+- [ ] Final synthesis
+
+## Findings
+(Updated during research)
+"""
+ (self.session_dir / "TODO.md").write_text(todo_content)
+
+ # FINDINGS.md 초기 파일 생성
+ findings_content = f"""# Research Findings
+
+## Query: {self.query}
+
+## Sources
+(Updated during research)
+
+## Key Findings
+(Updated during research)
+"""
+ (self.session_dir / "FINDINGS.md").write_text(findings_content)
+
+ # Ralph Loop 상태 저장
+ self.ralph_loop.save_state()
+
+ def get_current_prompt(self) -> str:
+ """현재 연구 프롬프트를 반환한다.
+
+ Returns:
+ Ralph Loop의 현재 연구 프롬프트.
+ """
+ return self.ralph_loop.create_research_prompt()
+
+ def add_finding(self, finding: Finding) -> None:
+ """발견 항목을 추가하고 관련 파일들을 갱신한다.
+
+ Args:
+ finding: 추가할 Finding 객체.
+ """
+ self.findings.append(finding)
+ self._update_findings_file()
+ self._recalculate_coverage()
+
+ def _update_findings_file(self) -> None:
+ """FINDINGS.md 파일을 현재 발견 항목으로 갱신한다."""
+ findings_path = self.session_dir / "FINDINGS.md"
+ content = f"""# Research Findings
+
+## Query: {self.query}
+
+## Sources ({len(self.findings)})
+"""
+ # 각 발견 항목을 Markdown으로 추가
+ for i, f in enumerate(self.findings, 1):
+ content += f"\n### Source {i}: {f.source_title}\n"
+ content += f"- URL: {f.source_url}\n"
+ content += f"- Confidence: {f.confidence:.0%}\n"
+ if f.verified_by:
+ content += f"- Verified by: {', '.join(f.verified_by)}\n"
+ if f.quality:
+ content += f"- Quality Score: {f.quality.overall_score:.2f}\n"
+ content += f"- Source Type: {f.quality.source_type}\n"
+ content += f"\n{f.content}\n"
+
+ findings_path.write_text(content)
+
+ def _recalculate_coverage(self) -> None:
+ """현재 발견 항목들을 기반으로 커버리지를 재계산한다."""
+ if not self.findings:
+ coverage = 0.0
+ else:
+ # 품질 가중 신뢰도의 평균 계산
+ weighted_scores = [f.weighted_confidence for f in self.findings]
+ avg_weighted = sum(weighted_scores) / len(weighted_scores)
+
+ # 수량 요소 (최대 10개까지 선형 증가)
+ quantity_factor = min(len(self.findings) / 10, 1.0)
+
+ # 소스 다양성 요소
+ source_diversity = self._calculate_source_diversity()
+
+ # 최종 커버리지 계산
+ coverage = avg_weighted * quantity_factor * (0.8 + 0.2 * source_diversity)
+
+ # Ralph Loop 상태 갱신
+ self.ralph_loop.update_coverage(len(self.findings), coverage)
+
+ def _calculate_source_diversity(self) -> float:
+ """소스 유형의 다양성을 계산한다.
+
+ Returns:
+ 0.0 ~ 1.0 범위의 다양성 점수 (4종류 이상이면 1.0).
+ """
+ if not self.findings:
+ return 0.0
+
+ # 고유한 소스 유형 수집
+ source_types = set()
+ for f in self.findings:
+ if f.quality:
+ source_types.add(f.quality.source_type)
+ else:
+ source_types.add("unknown")
+
+ # 4종류를 기준으로 다양성 점수 계산
+ return min(len(source_types) / 4, 1.0)
+
+ def complete_iteration(self) -> bool:
+ """현재 반복을 완료하고 다음 반복으로 진행한다.
+
+ Returns:
+ 연구가 완전히 완료되면 True.
+ """
+ # 완료 여부 확인
+ if self.ralph_loop.is_complete():
+ return True
+
+ # 다음 반복으로 진행
+ self.ralph_loop.increment_iteration()
+ return False
+
+ def finalize(self) -> Path:
+ """세션을 종료하고 요약 파일을 생성한다.
+
+ Returns:
+ 생성된 SUMMARY.md 파일 경로.
+ """
+ # Ralph Loop 상태 파일 정리
+ self.ralph_loop.cleanup()
+
+ # SUMMARY.md 생성
+ summary_path = self.session_dir / "SUMMARY.md"
+ summary_content = f"""# Research Summary
+
+## Query
+{self.query}
+
+## Statistics
+- Total Iterations: {self.ralph_loop.state.iteration}
+- Total Findings: {len(self.findings)}
+- Final Coverage: {self.ralph_loop.state.coverage_score:.2%}
+
+## Session
+- ID: {self.session_id}
+- Started: {self.ralph_loop.state.started_at}
+- Completed: {datetime.now(timezone.utc).isoformat()}
+
+## Output Files
+- TODO.md: Progress tracking
+- FINDINGS.md: Detailed findings
+- SUMMARY.md: This file
+"""
+ summary_path.write_text(summary_content)
+
+ return summary_path
diff --git a/research_agent/researcher/runner.py b/research_agent/researcher/runner.py
new file mode 100644
index 0000000..4c664df
--- /dev/null
+++ b/research_agent/researcher/runner.py
@@ -0,0 +1,289 @@
+"""Deep Research Runner - Ralph Loop 패턴 기반 반복 연구 실행기.
+
+DeepAgents 스타일의 자율 루프 실행. 각 반복은 새로운 컨텍스트로 시작하며,
+파일시스템이 메모리 역할을 합니다.
+
+Usage:
+ # Python API
+ from research_agent.researcher.runner import run_deep_research
+ result = await run_deep_research("Context Engineering best practices", depth="deep")
+
+ # CLI
+ uv run python -m research_agent.researcher.runner "Your research query" --depth deep
+"""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+from datetime import datetime
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from rich.console import Console
+from rich.panel import Panel
+from rich.progress import Progress, SpinnerColumn, TextColumn
+
+from research_agent.researcher.agent import create_researcher_agent
+from research_agent.researcher.depth import ResearchDepth, get_depth_config
+from research_agent.researcher.ralph_loop import ResearchSession
+
+if TYPE_CHECKING:
+ from langgraph.graph.state import CompiledStateGraph
+
+console = Console()
+
+# Colors matching DeepAgents CLI
+COLORS = {
+ "primary": "cyan",
+ "success": "green",
+ "warning": "yellow",
+ "error": "red",
+ "dim": "dim",
+}
+
+
+class ResearchRunner:
+ """Ralph Loop 패턴 기반 연구 실행기."""
+
+ def __init__(
+ self,
+ query: str,
+ depth: ResearchDepth | str = ResearchDepth.DEEP,
+ model: str | None = None,
+ ):
+ self.query = query
+ self.depth = ResearchDepth(depth) if isinstance(depth, str) else depth
+ self.config = get_depth_config(self.depth)
+ self.model_name = model
+
+ # Session 초기화
+ self.session = ResearchSession(query, self.config)
+ self.agent: CompiledStateGraph | None = None
+
+ def _create_agent(self) -> CompiledStateGraph:
+ """연구 에이전트 생성."""
+ return create_researcher_agent(
+ model=self.model_name,
+ depth=self.depth,
+ )
+
+ def _build_iteration_prompt(self, iteration: int) -> str:
+ """각 반복에 사용할 프롬프트 생성."""
+ max_iter = self.config.max_ralph_iterations
+ iter_display = f"{iteration}/{max_iter}" if max_iter > 0 else str(iteration)
+
+ return f"""## Research Iteration {iter_display}
+
+### Query
+{self.query}
+
+### Instructions
+Your previous work is in the filesystem. Check `research_workspace/session_{self.session.session_id}/` for:
+- TODO.md: Progress tracking
+- FINDINGS.md: Discovered information
+
+1. Review existing findings
+2. Identify knowledge gaps
+3. Conduct targeted searches
+4. Update research files with new findings
+5. Update TODO.md with progress
+
+### Completion
+When research is comprehensive (coverage >= {self.config.coverage_threshold:.0%}):
+- Output `RESEARCH_COMPLETE`
+- Only output this when truly complete - DO NOT lie to exit early
+
+### Current Stats
+- Iteration: {iteration}
+- Findings: {self.session.ralph_loop.state.findings_count}
+- Coverage: {self.session.ralph_loop.state.coverage_score:.2%}
+
+Make progress. You'll be called again if not complete.
+"""
+
+ async def _execute_iteration(self, iteration: int) -> dict:
+ """단일 반복 실행."""
+ if self.agent is None:
+ self.agent = self._create_agent()
+
+ prompt = self._build_iteration_prompt(iteration)
+
+ # 에이전트 실행
+ result = await self.agent.ainvoke(
+ {"messages": [{"role": "user", "content": prompt}]}
+ )
+
+ return result
+
+ def _check_completion(self, result: dict) -> bool:
+ """완료 여부 확인."""
+ # 메시지에서 완료 promise 체크
+ messages = result.get("messages", [])
+ for msg in messages:
+ content = getattr(msg, "content", str(msg))
+ if isinstance(content, str):
+ if "RESEARCH_COMPLETE" in content:
+ return True
+ if "RESEARCH_COMPLETE" in content:
+ # 좀 더 느슨한 체크
+ return True
+
+ # Coverage 기반 체크
+ return self.session.ralph_loop.is_complete()
+
+ async def run(self) -> Path:
+ """연구 실행 및 결과 반환."""
+ console.print(
+ Panel(
+ f"[bold {COLORS['primary']}]Deep Research Mode[/bold {COLORS['primary']}]\n"
+ f"[dim]Query: {self.query}[/dim]\n"
+ f"[dim]Depth: {self.depth.value}[/dim]\n"
+ f"[dim]Max iterations: {self.config.max_ralph_iterations or 'unlimited'}[/dim]",
+ title="Research Session Started",
+ border_style=COLORS["primary"],
+ )
+ )
+
+ # 세션 초기화
+ self.session.initialize()
+ console.print(
+ f"[dim]Session ID: {self.session.session_id}[/dim]\n"
+ f"[dim]Workspace: {self.session.session_dir}[/dim]\n"
+ )
+
+ iteration = 1
+ max_iterations = self.config.max_ralph_iterations or 100 # Safety limit
+
+ try:
+ while iteration <= max_iterations:
+ console.print(
+ f"\n[bold {COLORS['primary']}]{'=' * 60}[/bold {COLORS['primary']}]"
+ )
+ console.print(
+ f"[bold {COLORS['primary']}]ITERATION {iteration}[/bold {COLORS['primary']}]"
+ )
+ console.print(
+ f"[bold {COLORS['primary']}]{'=' * 60}[/bold {COLORS['primary']}]\n"
+ )
+
+ with Progress(
+ SpinnerColumn(),
+ TextColumn("[progress.description]{task.description}"),
+ console=console,
+ ) as progress:
+ task = progress.add_task("Researching...", total=None)
+
+ result = await self._execute_iteration(iteration)
+
+ progress.update(task, description="Checking completion...")
+
+ # 완료 체크
+ if self._check_completion(result):
+ console.print(
+ f"\n[bold {COLORS['success']}]Research complete![/bold {COLORS['success']}]"
+ )
+ break
+
+ # 다음 반복 준비
+ is_done = self.session.complete_iteration()
+ if is_done:
+ console.print(
+ f"\n[bold {COLORS['success']}]Coverage threshold reached![/bold {COLORS['success']}]"
+ )
+ break
+
+ console.print(f"[dim]...continuing to iteration {iteration + 1}[/dim]")
+ iteration += 1
+
+ except KeyboardInterrupt:
+ console.print(
+ f"\n[bold {COLORS['warning']}]Stopped after {iteration} iterations[/bold {COLORS['warning']}]"
+ )
+
+ # 최종 결과 생성
+ summary_path = self.session.finalize()
+
+ # 결과 표시
+ console.print(
+ Panel(
+ f"[bold]Research Summary[/bold]\n"
+ f"Total Iterations: {iteration}\n"
+ f"Findings: {self.session.ralph_loop.state.findings_count}\n"
+ f"Coverage: {self.session.ralph_loop.state.coverage_score:.2%}\n"
+ f"\n[dim]Output: {summary_path}[/dim]",
+ title="Research Complete",
+ border_style=COLORS["success"],
+ )
+ )
+
+ # 생성된 파일 목록
+ console.print(f"\n[bold]Files created in {self.session.session_dir}:[/bold]")
+ for f in sorted(self.session.session_dir.rglob("*")):
+ if f.is_file():
+ console.print(
+ f" {f.relative_to(self.session.session_dir)}", style="dim"
+ )
+
+ return summary_path
+
+
+async def run_deep_research(
+ query: str,
+ depth: ResearchDepth | str = ResearchDepth.DEEP,
+ model: str | None = None,
+) -> Path:
+ """Deep Research 실행 (async API).
+
+ Args:
+ query: 연구 주제
+ depth: 연구 깊이 (quick, standard, deep, exhaustive)
+ model: 사용할 LLM 모델명
+
+ Returns:
+ Path: 연구 결과 요약 파일 경로
+ """
+ runner = ResearchRunner(query, depth, model)
+ return await runner.run()
+
+
+def main() -> None:
+ """CLI 엔트리포인트."""
+ parser = argparse.ArgumentParser(
+ description="Deep Research - Ralph Loop 패턴 기반 자율 연구",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Examples:
+ python -m research_agent.researcher.runner "Context Engineering 전략 분석"
+ python -m research_agent.researcher.runner "LLM Agent 아키텍처" --depth deep
+ python -m research_agent.researcher.runner "RAG 시스템 비교" --depth exhaustive --model gpt-4.1
+ """,
+ )
+ parser.add_argument("query", help="연구 주제 (무엇을 연구할지)")
+ parser.add_argument(
+ "--depth",
+ choices=["quick", "standard", "deep", "exhaustive"],
+ default="deep",
+ help="연구 깊이 (기본: deep)",
+ )
+ parser.add_argument(
+ "--model",
+ help="사용할 LLM 모델 (예: gpt-4.1, claude-sonnet-4-20250514)",
+ )
+
+ args = parser.parse_args()
+
+ try:
+ asyncio.run(
+ run_deep_research(
+ query=args.query,
+ depth=args.depth,
+ model=args.model,
+ )
+ )
+ except KeyboardInterrupt:
+ console.print("\n[dim]Interrupted by user[/dim]")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/research_agent/tools.py b/research_agent/tools.py
index f29bacd..446634d 100644
--- a/research_agent/tools.py
+++ b/research_agent/tools.py
@@ -1,9 +1,47 @@
-"""리서치 도구 모듈.
+"""연구 도구 모듈.
-이 모듈은 리서치 에이전트를 위한 검색 및 콘텐츠 처리 유틸리티를 제공하며,
-Tavily 를 사용해 URL 을 찾고 전체 웹페이지 콘텐츠를 가져와 마크다운으로 변환한다.
+이 모듈은 연구 에이전트를 위한 검색 및 콘텐츠 처리 유틸리티를 제공합니다.
+다중 소스 검색(Tavily, mgrep, arXiv, grep.app, Context7)을 지원합니다.
+
+## 도구 흐름도
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ comprehensive_search │
+│ (다중 소스 오케스트레이션) │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
+│ │ web │ │ local │ │ arxiv │ │ github │ │
+│ │ │ │ │ │ │ │ │ │
+│ │ mgrep │ │ mgrep │ │ arxiv_ │ │ github_code_ │ │
+│ │ (web) │ │ (path) │ │ search │ │ search │ │
+│ │ or │ │ │ │ │ │ (grep.app API) │ │
+│ │ tavily │ │ │ │ │ │ │ │
+│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
+│ │
+│ ┌──────────────────┐ │
+│ │ docs │ │
+│ │ │ │
+│ │ library_docs_ │ │
+│ │ search │ │
+│ │ (Context7 API) │ │
+│ └──────────────────┘ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+v2 업데이트 (2026-01):
+- mgrep 시맨틱 검색 통합 (2배 토큰 효율성)
+- arXiv 학술 논문 검색
+- grep.app GitHub 코드 검색
+- Context7 라이브러리 문서 검색
+- comprehensive_search 통합 도구
"""
+from __future__ import annotations
+
+import shutil
+import subprocess
from typing import Annotated, Literal
import httpx
@@ -12,33 +50,75 @@ from langchain_core.tools import InjectedToolArg, tool
from markdownify import markdownify
from tavily import TavilyClient
-load_dotenv()
+# ============================================================================
+# 환경 설정
+# ============================================================================
+load_dotenv() # .env 파일에서 API 키 로드
+
+# arXiv 패키지 선택적 임포트 (설치되지 않은 환경 지원)
+try:
+ import arxiv
+
+ ARXIV_AVAILABLE = True
+except ImportError:
+ ARXIV_AVAILABLE = False
+ arxiv = None # type: ignore
+
+# mgrep CLI 설치 여부 확인
+MGREP_AVAILABLE = shutil.which("mgrep") is not None
+
+# Tavily 클라이언트 초기화
tavily_client = TavilyClient()
+# ============================================================================
+# 헬퍼 함수
+# ============================================================================
+
+
def fetch_webpage_content(url: str, timeout: float = 10.0) -> str:
- """웹페이지 콘텐츠를 가져와 마크다운으로 변환한다.
+ """웹페이지를 가져와서 HTML을 Markdown으로 변환한다.
+
+ 이 헬퍼 함수는 HTTP GET 요청을 수행하고(브라우저와 유사한 User-Agent 사용),
+ 응답 상태 코드를 검증한 후, `markdownify`를 사용하여 반환된 HTML을
+ Markdown으로 변환합니다.
+
+ 참고:
+ - 이 함수는 헬퍼 함수입니다(LangChain 도구가 아님).
+ - `tavily_search` 같은 도구 래퍼가 전체 페이지 콘텐츠를 추출할 때 호출합니다.
+ - 예외 발생 시 예외를 던지지 않고 사람이 읽을 수 있는 에러 문자열을 반환합니다.
Args:
- url: 가져올 URL
- timeout: 요청 타임아웃 (초 단위)
+ url: 가져올 전체 URL (예: "https://example.com/article").
+ timeout: 요청 타임아웃(초).
Returns:
- 마크다운 형식의 웹페이지 콘텐츠
+ 웹페이지 콘텐츠의 Markdown 문자열.
+ 가져오기/변환 실패 시 다음 형식의 문자열 반환:
+ "Error fetching content from {url}: {exception_message}".
"""
+ # 브라우저처럼 보이는 User-Agent 헤더 설정
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
try:
+ # HTTP GET 요청 수행
response = httpx.get(url, headers=headers, timeout=timeout)
- response.raise_for_status()
+ response.raise_for_status() # 4xx, 5xx 에러 시 예외 발생
+
+ # HTML을 Markdown으로 변환하여 반환
return markdownify(response.text)
except Exception as e:
return f"Error fetching content from {url}: {str(e)}"
+# ============================================================================
+# 웹 검색 도구
+# ============================================================================
+
+
@tool()
def tavily_search(
query: str,
@@ -47,34 +127,52 @@ def tavily_search(
Literal["general", "news", "finance"], InjectedToolArg
] = "general",
) -> str:
- """주어진 쿼리로 웹을 검색한다.
+ """Tavily를 사용해 웹을 검색하고 전체 페이지 콘텐츠를 Markdown으로 반환한다.
- Tavily를 사용해 관련 URL을 찾고, 전체 웹페이지 콘텐츠를 마크다운으로 가져와 반환한다.
+ 이 도구는 두 단계로 동작합니다:
+ 1) Tavily Search를 사용하여 쿼리에 관련된 URL을 찾습니다.
+ 2) 각 결과 URL에 대해 `fetch_webpage_content`를 통해 전체 웹페이지 콘텐츠를
+ 가져와 Markdown으로 변환합니다.
Args:
- query: 실행할 검색 쿼리
- max_results: 반환할 최대 결과 수 (기본값: 1)
- topic: 주제 필터 - 'general', 'news', 또는 'finance' (기본값: 'general')
+ query: 자연어 검색 쿼리 (예: "context engineering best practices").
+ max_results: Tavily에서 가져올 최대 검색 결과 수.
+ 도구 주입 인자로 처리됨; 기본값은 1.
+ topic: Tavily 토픽 필터. 허용 값:
+ - "general"
+ - "news"
+ - "finance"
+ 도구 주입 인자로 처리됨; 기본값은 "general".
Returns:
- 전체 웹페이지 콘텐츠가 포함된 포맷팅된 검색 결과
+ 다음을 포함하는 Markdown 형식 문자열:
+ - 요약 헤더: "Found N result(s) for '{query}':"
+ - 각 결과에 대해:
+ - 제목
+ - URL
+ - Markdown으로 변환된 전체 웹페이지 콘텐츠
+ - 구분선 ("---")
+
+ Example:
+ >>> tavily_search.invoke({"query": "LangGraph CLI configuration", "max_results": 2})
"""
- # Tavily 를 사용해 관련 URL 목록을 조회한다
+ # Tavily API를 사용해 관련 URL 목록을 조회
search_results = tavily_client.search(
query,
max_results=max_results,
topic=topic,
)
- # 각 URL 에 대해 전체 콘텐츠를 가져온다
+ # 각 검색 결과에 대해 전체 콘텐츠를 가져옴
result_texts = []
for result in search_results.get("results", []):
url = result["url"]
title = result["title"]
- # 웹페이지 콘텐츠를 가져온다
+ # 웹페이지 콘텐츠를 가져와서 Markdown으로 변환
content = fetch_webpage_content(url)
+ # 결과 형식화
result_text = f"""## {title}
**URL:** {url}
@@ -84,7 +182,7 @@ def tavily_search(
"""
result_texts.append(result_text)
- # 최종 응답 형식으로 정리한다
+ # 최종 응답 형식으로 조합
response = f"""Found {len(result_texts)} result(s) for '{query}':
{chr(10).join(result_texts)}"""
@@ -92,29 +190,579 @@ def tavily_search(
return response
+# ============================================================================
+# 사고 도구 (Reflection Tool)
+# ============================================================================
+
+
@tool()
def think_tool(reflection: str) -> str:
- """연구 진행 상황과 의사결정을 위한 전략적 성찰 도구.
+ """명시적 반성 단계를 강제하고 다음 행동을 기록한다.
- 각 검색 후 결과를 분석하고 다음 단계를 체계적으로 계획하기 위해 이 도구를 사용한다.
- 이는 품질 높은 의사결정을 위해 연구 워크플로우에 의도적인 멈춤을 만든다.
+ 검색이나 주요 결정 시점 직후에 이 도구를 사용하여:
+ - 학습한 내용 요약 (사실, 정의, 핵심 주장)
+ - 부족한 부분 파악 (누락된 용어, 증거, 구현 세부사항)
+ - 다음 구체적 단계 결정 (다음 쿼리, 다음 소스, 또는 종합 시작)
- 사용 시점:
- - 검색 결과를 받은 후: 어떤 핵심 정보를 찾았는가?
- - 다음 단계를 결정하기 전: 포괄적으로 답변할 수 있을 만큼 충분한가?
- - 연구 공백을 평가할 때: 아직 누락된 구체적인 정보는 무엇인가?
- - 연구를 마무리하기 전: 지금 완전한 답변을 제공할 수 있는가?
-
- 성찰에 포함해야 할 내용:
- 1. 현재 발견의 분석 - 어떤 구체적인 정보를 수집했는가?
- 2. 공백 평가 - 어떤 중요한 정보가 아직 누락되어 있는가?
- 3. 품질 평가 - 좋은 답변을 위한 충분한 증거/예시가 있는가?
- 4. 전략적 결정 - 검색을 계속해야 하는가, 답변을 제공해야 하는가?
+ 이 도구는 자체적으로 상태를 유지하지 않습니다; 에이전트가 구조화된 방식으로
+ 추론을 외부화하도록 강제하기 위해 확인 문자열을 반환합니다.
Args:
- reflection: 연구 진행 상황, 발견, 공백, 다음 단계에 대한 상세한 성찰
+ reflection: 다음을 포함하는 간결하지만 구체적인 반성:
+ - 학습한 내용 (글머리 기호로 정리 가능한 사실들)
+ - 아직 누락된 부분
+ - 다음 단계 (정확한 도구 + 정확한 쿼리)
Returns:
- 의사결정을 위해 성찰이 기록되었다는 확인
+ 반성이 기록되었음을 나타내는 확인 문자열.
+ (반환된 문자열은 로그/트랜스크립트에서 볼 수 있도록 의도됨.)
+
+ Example:
+ >>> think_tool.invoke({
+ ... "reflection": (
+ ... "Learned: RAG vs. context caching differ in latency/cost trade-offs. "
+ ... "Gap: need concrete caching APIs and constraints. "
+ ... "Next: library_docs_search(library_name='openai', query='response caching')."
+ ... )
+ ... })
"""
- return f"성찰 기록됨: {reflection}"
+ return f"Reflection recorded: {reflection}"
+
+
+# ============================================================================
+# 시맨틱 검색 도구 (mgrep)
+# ============================================================================
+
+
+@tool()
+def mgrep_search(
+ query: str,
+ path: Annotated[str, InjectedToolArg] = ".",
+ max_results: Annotated[int, InjectedToolArg] = 10,
+ web: Annotated[bool, InjectedToolArg] = False,
+) -> str:
+ """`mgrep`을 사용하여 시맨틱 검색을 수행한다 (로컬 코드 또는 웹 답변 모드).
+
+ 이 도구는 `mgrep` CLI를 호출합니다:
+ - 로컬 모드 (`web=False`): `path` 아래의 파일을 검색하고 매치를 반환.
+ - 웹 모드 (`web=True`): `mgrep --web --answer`를 사용하여 웹 결과를
+ 검색하고 요약 (로컬 `mgrep` 설치에서 지원되는 경우).
+
+ Args:
+ query: 찾고자 하는 내용을 설명하는 자연어 검색 쿼리
+ (예: "Where is ResearchDepth configured?").
+ path: `web=False`일 때 검색할 파일시스템 경로. 기본값: ".".
+ 도구 주입 인자로 처리됨.
+ max_results: 반환할 최대 결과 수. 기본값: 10.
+ 도구 주입 인자로 처리됨.
+ web: True이면 `mgrep --web --answer`를 통해 웹 검색/답변 모드 수행.
+ False이면 `path` 아래에서 로컬 시맨틱 검색 수행.
+ 도구 주입 인자로 처리됨.
+
+ Returns:
+ - 성공 시: `mgrep` stdout (트림됨), stdout이 비어있으면 "No results".
+ - `mgrep` 미설치 시: 설치 안내 문자열.
+ - 실패 시: 사람이 읽을 수 있는 에러 문자열 (stderr 또는 타임아웃 포함).
+
+ Example:
+ >>> mgrep_search.invoke({"query": "How is the researcher agent created?", "path": "research_agent"})
+ >>> mgrep_search.invoke({"query": "latest agentic RAG techniques", "web": True, "max_results": 5})
+ """
+ # mgrep 설치 여부 확인
+ if not MGREP_AVAILABLE:
+ return (
+ "mgrep is not installed. "
+ "Install with `npm install -g @mixedbread/mgrep && mgrep login`."
+ )
+
+ # 명령어 구성
+ cmd = ["mgrep", "-m", str(max_results)]
+
+ # 웹 모드인 경우 추가 플래그 설정
+ if web:
+ cmd.extend(["--web", "--answer"])
+
+ cmd.append(query)
+
+ # 로컬 모드인 경우 경로 추가
+ if not web:
+ cmd.append(path)
+
+ try:
+ # 서브프로세스로 mgrep 실행
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=60, # 60초 타임아웃
+ )
+
+ # 비정상 종료 시 에러 반환
+ if result.returncode != 0:
+ return f"mgrep error: {result.stderr.strip()}"
+
+ # 결과 반환 (비어있으면 "No results")
+ return result.stdout.strip() or "No results"
+
+ except subprocess.TimeoutExpired:
+ return "mgrep timeout (exceeded 60 seconds)"
+ except Exception as e:
+ return f"mgrep execution error: {e}"
+
+
+# ============================================================================
+# 학술 검색 도구 (arXiv)
+# ============================================================================
+
+
+@tool()
+def arxiv_search(
+ query: str,
+ max_results: Annotated[int, InjectedToolArg] = 5,
+ sort_by: Annotated[
+ Literal["relevance", "submittedDate", "lastUpdatedDate"], InjectedToolArg
+ ] = "relevance",
+) -> str:
+ """arXiv에서 학술 논문을 검색하고 Markdown 요약을 반환한다.
+
+ 선택적 `arxiv` Python 패키지를 사용합니다. 각 결과는 제목, 저자(처음 5명 +
+ 나머지 수), 출판 날짜, URL, 요약된 초록과 함께 Markdown으로 렌더링됩니다.
+
+ Args:
+ query: arXiv 쿼리 문자열 (예: "transformer architecture", "context engineering").
+ max_results: 반환할 최대 논문 수. 기본값: 5.
+ sort_by: 결과 정렬 기준. 다음 중 하나:
+ - "relevance" (관련성)
+ - "submittedDate" (제출 날짜)
+ - "lastUpdatedDate" (마지막 업데이트 날짜)
+ 기본값: "relevance".
+
+ Returns:
+ 다음을 포함하는 Markdown 문자열:
+ - 찾은 논문 수를 나타내는 헤더
+ - 각 논문에 대해: 제목, 저자, 출판 날짜, URL, 초록 발췌
+ `arxiv` 패키지가 없으면 설치 안내 문자열 반환.
+ 결과가 없으면 "not found" 메시지 반환.
+
+ Example:
+ >>> arxiv_search.invoke({"query": "retrieval augmented generation evaluation", "max_results": 3})
+ """
+ # arxiv 패키지 설치 여부 확인
+ if not ARXIV_AVAILABLE or arxiv is None:
+ return "arxiv package not installed. Install with `pip install arxiv`."
+
+ # 정렬 기준 매핑
+ sort_criterion_map = {
+ "relevance": arxiv.SortCriterion.Relevance,
+ "submittedDate": arxiv.SortCriterion.SubmittedDate,
+ "lastUpdatedDate": arxiv.SortCriterion.LastUpdatedDate,
+ }
+
+ # arXiv 클라이언트 및 검색 객체 생성
+ client = arxiv.Client()
+ search = arxiv.Search(
+ query=query,
+ max_results=max_results,
+ sort_by=sort_criterion_map.get(sort_by, arxiv.SortCriterion.Relevance),
+ )
+
+ # 검색 결과 처리
+ results = []
+ for paper in client.results(search):
+ # 저자 목록 (최대 5명 + 나머지 수)
+ authors = ", ".join(a.name for a in paper.authors[:5])
+ if len(paper.authors) > 5:
+ authors += f" et al. ({len(paper.authors) - 5} more)"
+
+ # 초록 (최대 800자)
+ abstract = paper.summary[:800]
+ if len(paper.summary) > 800:
+ abstract += "..."
+
+ # Markdown 형식으로 결과 추가
+ results.append(
+ f"## {paper.title}\n\n"
+ f"**Authors:** {authors}\n"
+ f"**Published:** {paper.published.strftime('%Y-%m-%d')}\n"
+ f"**URL:** {paper.entry_id}\n\n"
+ f"### Abstract\n{abstract}\n\n---"
+ )
+
+ # 결과가 없으면 메시지 반환
+ if not results:
+ return f"No papers found for '{query}'."
+
+ return f"Found {len(results)} paper(s) for '{query}':\n\n" + "\n\n".join(results)
+
+
+# ============================================================================
+# 통합 검색 도구
+# ============================================================================
+
+
+@tool()
+def comprehensive_search(
+ query: str,
+ sources: Annotated[
+ list[Literal["web", "local", "arxiv", "github", "docs"]], InjectedToolArg
+ ] = ["web"],
+ max_results_per_source: Annotated[int, InjectedToolArg] = 5,
+ library_name: Annotated[str | None, InjectedToolArg] = None,
+) -> str:
+ """다중 소스 검색을 실행하고 결과를 단일 Markdown 보고서로 통합한다.
+
+ 이 도구는 `sources`에 따라 여러 다른 도구를 오케스트레이션합니다:
+ - "local": 로컬 코드베이스에서 `mgrep_search` 실행.
+ - "web": 가능하면 `mgrep_search`를 `web=True`로 사용; 그렇지 않으면
+ `tavily_search`로 폴백.
+ - "arxiv": `arxiv_search` 실행.
+ - "github": `github_code_search` 실행.
+ - "docs": `library_docs_search` 실행 (`library_name` 필요).
+
+ Args:
+ query: 선택된 소스에서 사용할 검색 쿼리.
+ sources: 쿼리할 소스. 허용 값:
+ "web", "local", "arxiv", "github", "docs".
+ max_results_per_source: 소스당 최대 결과 수. 기본값: 5.
+ library_name: "docs"가 `sources`에 포함된 경우 필수. Context7에서
+ 인식할 수 있는 라이브러리/제품 이름이어야 함 (예: "langchain").
+
+ Returns:
+ 소스별 섹션 헤더가 있고 "---"로 구분된 Markdown 문자열.
+ 선택된 소스가 없으면 "no results" 메시지 반환.
+
+ Example:
+ >>> comprehensive_search.invoke({
+ ... "query": "how to configure LangGraph deployment",
+ ... "sources": ["web", "local", "docs"],
+ ... "library_name": "langgraph",
+ ... "max_results_per_source": 3
+ ... })
+ """
+ all_results = []
+
+ # 로컬 코드베이스 검색
+ if "local" in sources:
+ local_result = mgrep_search.invoke(
+ {"query": query, "path": ".", "max_results": max_results_per_source}
+ )
+ all_results.append(f"# Local Codebase Search\n\n{local_result}")
+
+ # 웹 검색
+ if "web" in sources:
+ if MGREP_AVAILABLE:
+ # mgrep 웹 모드 사용 (설치된 경우)
+ web_result = mgrep_search.invoke(
+ {"query": query, "max_results": max_results_per_source, "web": True}
+ )
+ else:
+ # Tavily로 폴백
+ web_result = tavily_search.invoke(
+ {"query": query, "max_results": max_results_per_source}
+ )
+ all_results.append(f"# Web Search Results\n\n{web_result}")
+
+ # arXiv 학술 검색
+ if "arxiv" in sources:
+ arxiv_result = arxiv_search.invoke(
+ {"query": query, "max_results": max_results_per_source}
+ )
+ all_results.append(f"# Academic Papers (arXiv)\n\n{arxiv_result}")
+
+ # GitHub 코드 검색
+ if "github" in sources:
+ github_result = github_code_search.invoke(
+ {"query": query, "max_results": max_results_per_source}
+ )
+ all_results.append(f"# GitHub Code Search\n\n{github_result}")
+
+ # 공식 문서 검색
+ if "docs" in sources and library_name:
+ docs_result = library_docs_search.invoke(
+ {"library_name": library_name, "query": query}
+ )
+ all_results.append(
+ f"# Official Documentation ({library_name})\n\n{docs_result}"
+ )
+
+ # 결과가 없으면 메시지 반환
+ if not all_results:
+ return f"No search results found for '{query}'."
+
+ # 모든 결과를 구분선으로 연결
+ return "\n\n---\n\n".join(all_results)
+
+
+# ============================================================================
+# GitHub 코드 검색 도구
+# ============================================================================
+
+
+@tool()
+def github_code_search(
+ query: str,
+ language: Annotated[list[str] | None, InjectedToolArg] = None,
+ repo: Annotated[str | None, InjectedToolArg] = None,
+ max_results: Annotated[int, InjectedToolArg] = 5,
+ use_regex: Annotated[bool, InjectedToolArg] = False,
+) -> str:
+ """grep.app을 사용하여 공개 GitHub 코드를 검색하고 실제 예제를 반환한다.
+
+ 이 도구는 개념적 키워드가 아닌 *리터럴 코드 패턴*을 찾기 위한 것입니다.
+ 예: `useState(`, `getServerSession`, 또는 멀티라인 정규식 패턴.
+
+ 필터링 동작:
+ - `repo`: 저장소 이름에 대한 부분 문자열 매치 (예: "vercel/").
+ - `language`: grep.app의 언어 필드에 대한 정확한 매치.
+
+ Args:
+ query: 코드 검색 패턴. 리터럴 코드 토큰을 선호. 예:
+ - "useState("
+ - "async function"
+ - "(?s)useEffect\\(\\(\\) => {.*removeEventListener" (`use_regex=True`와 함께)
+ language: 포함할 언어 목록 (선택). 예: ["TypeScript", "Python"].
+ repo: 저장소 필터 (선택). 예: "facebook/react", "vercel/".
+ max_results: 출력에 포함할 최대 매치 수. 기본값: 5.
+ use_regex: True이면 `query`를 정규표현식으로 해석. 기본값: False.
+
+ Returns:
+ 매칭된 저장소와 스니펫을 나열하는 Markdown 문자열:
+ - 저장소 이름
+ - 파일 경로
+ - 언어
+ - 잘린 스니펫 (최대 ~500자)
+ 필터와 매치하는 결과가 없거나 HTTP 에러 발생 시 사람이 읽을 수 있는 메시지 반환.
+
+ Example:
+ >>> github_code_search.invoke({
+ ... "query": "getServerSession(",
+ ... "language": ["TypeScript", "TSX"],
+ ... "max_results": 3
+ ... })
+ """
+ # grep.app API 엔드포인트
+ base_url = "https://grep.app/api/search"
+
+ # 요청 파라미터 구성
+ params = {
+ "q": query,
+ "case": "false", # 대소문자 구분 안함
+ "words": "false", # 단어 단위 매치 안함
+ "regexp": str(use_regex).lower(), # 정규식 사용 여부
+ }
+
+ # 요청 헤더 설정
+ headers = {
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
+ "Accept": "application/json",
+ }
+
+ try:
+ # API 요청 수행
+ response = httpx.get(base_url, params=params, headers=headers, timeout=30.0)
+ response.raise_for_status()
+ data = response.json()
+
+ # 검색 결과 추출
+ hits = data.get("hits", {}).get("hits", [])
+
+ # 결과가 없으면 메시지 반환
+ if not hits:
+ return f"No GitHub code found for '{query}'."
+
+ results = []
+ count = 0
+
+ # 각 검색 결과 처리
+ for hit in hits:
+ # 최대 결과 수에 도달하면 중단
+ if count >= max_results:
+ break
+
+ # 저장소 이름 추출
+ repo_name = hit.get("repo", "unknown/unknown")
+
+ # 저장소 필터 적용
+ if repo and repo not in repo_name:
+ continue
+
+ # 파일 경로 및 브랜치 추출
+ file_path = hit.get("path", "unknown")
+ branch = hit.get("branch", "main")
+
+ # 코드 스니펫 추출 및 HTML 태그 제거
+ content_data = hit.get("content", {})
+ snippet_html = content_data.get("snippet", "")
+ import re
+
+ # HTML 태그 제거
+ snippet = re.sub(r"<[^>]+>", "", snippet_html)
+ # HTML 엔티티 변환
+ snippet = (
+ snippet.replace("<", "<").replace(">", ">").replace("&", "&")
+ )
+ # 빈 줄 제거 및 트림
+ snippet = "\n".join(
+ line.strip() for line in snippet.split("\n") if line.strip()
+ )
+ # 500자 초과 시 잘라냄
+ snippet = snippet[:500] + "..." if len(snippet) > 500 else snippet
+
+ # 파일 확장자에서 언어 추론
+ lang = file_path.split(".")[-1] if "." in file_path else "unknown"
+ lang_map = {
+ "py": "python",
+ "ts": "typescript",
+ "js": "javascript",
+ "tsx": "tsx",
+ "jsx": "jsx",
+ }
+ lang = lang_map.get(lang, lang)
+
+ # 언어 필터 적용
+ if language and lang not in [l.lower() for l in language]:
+ continue
+
+ # GitHub URL 구성
+ github_url = f"https://github.com/{repo_name}/blob/{branch}/{file_path}"
+
+ # Markdown 형식으로 결과 추가
+ results.append(
+ f"## {repo_name}\n"
+ f"**File:** [`{file_path}`]({github_url})\n"
+ f"**Language:** {lang}\n\n"
+ f"```{lang}\n{snippet}\n```\n"
+ )
+ count += 1
+
+ # 필터 적용 후 결과가 없으면 메시지 반환
+ if not results:
+ filter_msg = ""
+ if language:
+ filter_msg += f" (language: {language})"
+ if repo:
+ filter_msg += f" (repo: {repo})"
+ return f"No GitHub code found for '{query}'{filter_msg}."
+
+ # 결과 반환
+ return (
+ f"Found {len(results)} GitHub code snippet(s) for '{query}':\n\n"
+ + "\n---\n".join(results)
+ )
+
+ except httpx.TimeoutException:
+ return "GitHub code search timeout (exceeded 30 seconds)"
+ except httpx.HTTPStatusError as e:
+ return f"GitHub code search HTTP error: {e.response.status_code}"
+ except Exception as e:
+ return f"GitHub code search error: {e}"
+
+
+# ============================================================================
+# 라이브러리 문서 검색 도구 (Context7)
+# ============================================================================
+
+
+@tool()
+def library_docs_search(
+ library_name: str,
+ query: str,
+ max_tokens: Annotated[int, InjectedToolArg] = 5000,
+) -> str:
+ """Context7을 사용하여 공식 라이브러리 문서를 검색한다.
+
+ 이 도구는 다음을 수행합니다:
+ 1) `library_name`을 Context7 `libraryId`로 해석.
+ 2) 제공된 `query`로 Context7 문서를 쿼리.
+
+ Args:
+ library_name: 해석할 라이브러리/제품 이름 (예: "langchain", "react", "fastapi").
+ query: 특정 문서 쿼리 (예: "how to configure retries", "authentication middleware").
+ max_tokens: 반환된 문서 콘텐츠의 최대 토큰 예산. 기본값: 5000.
+
+ Returns:
+ 다음을 포함하는 Markdown 문자열:
+ - 라이브러리 제목
+ - 쿼리
+ - 해석된 라이브러리 ID
+ - 추출된 문서 콘텐츠
+ 타임아웃, HTTP 실패, 라이브러리 누락, 빈 결과 시 사람이 읽을 수 있는 에러 메시지 반환.
+
+ Example:
+ >>> library_docs_search.invoke({
+ ... "library_name": "langchain",
+ ... "query": "Tool calling with InjectedToolArg",
+ ... "max_tokens": 2000
+ ... })
+ """
+ # Context7 API 엔드포인트
+ resolve_url = "https://context7.com/api/v1/resolve-library-id"
+ query_url = "https://context7.com/api/v1/query-docs"
+
+ # 요청 헤더
+ headers = {
+ "Content-Type": "application/json",
+ "User-Agent": "DeepResearchAgent/1.0",
+ }
+
+ try:
+ # 1단계: 라이브러리 이름을 ID로 해석
+ resolve_response = httpx.post(
+ resolve_url,
+ json={"libraryName": library_name, "query": query},
+ headers=headers,
+ timeout=30.0,
+ )
+
+ # 라이브러리를 찾지 못한 경우
+ if resolve_response.status_code == 404:
+ return f"Library '{library_name}' not found in Context7."
+
+ resolve_response.raise_for_status()
+ resolve_data = resolve_response.json()
+
+ # 라이브러리 목록 확인
+ libraries = resolve_data.get("libraries", [])
+ if not libraries:
+ return f"No documentation found for '{library_name}'."
+
+ # 첫 번째 결과에서 ID와 제목 추출
+ library_id = libraries[0].get("id", "")
+ library_title = libraries[0].get("name", library_name)
+
+ if not library_id:
+ return f"Could not resolve library ID for '{library_name}'."
+
+ # 2단계: 문서 쿼리
+ docs_response = httpx.post(
+ query_url,
+ json={
+ "libraryId": library_id,
+ "query": query,
+ "maxTokens": max_tokens,
+ },
+ headers=headers,
+ timeout=60.0, # 문서 쿼리는 더 긴 타임아웃
+ )
+ docs_response.raise_for_status()
+ docs_data = docs_response.json()
+
+ # 콘텐츠 추출
+ content = docs_data.get("content", "")
+ if not content:
+ return f"No documentation found for '{query}' in '{library_name}'."
+
+ # 결과 반환
+ return (
+ f"# {library_title} Official Documentation\n\n"
+ f"**Query:** {query}\n"
+ f"**Library ID:** {library_id}\n\n"
+ f"---\n\n{content}"
+ )
+
+ except httpx.TimeoutException:
+ return f"Library docs search timeout (library: {library_name})"
+ except httpx.HTTPStatusError as e:
+ return f"Library docs search HTTP error: {e.response.status_code}"
+ except Exception as e:
+ return f"Library docs search error: {e}"
diff --git a/scripts/run_ai_trend_research.py b/scripts/run_ai_trend_research.py
new file mode 100644
index 0000000..4f9a456
--- /dev/null
+++ b/scripts/run_ai_trend_research.py
@@ -0,0 +1,748 @@
+#!/usr/bin/env python3
+"""2026 AI 트렌드 키워드 연구 스크립트 (도구 궤적 로깅 포함).
+
+이 스크립트는 다양한 소스에서 2026년 AI 트렌드를 조사하고 보고서를 생성합니다.
+각 도구 호출은 TOOL_TRAJECTORY.log 및 TOOL_TRAJECTORY.json에 기록됩니다.
+
+## 스크립트 실행 흐름
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ main() │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ 1. 세션 초기화 │
+│ session = ResearchSession(query, session_id) │
+│ trajectory_logger = ToolTrajectoryLogger(session_dir) │
+│ │
+│ 2. 다중 소스 검색 │
+│ ┌─────────────────────────────────────────────────────────┐│
+│ │ search_web_sources() → tavily_search (5회) ││
+│ │ search_github_sources() → github_code_search (3회) ││
+│ │ search_arxiv_sources() → arxiv_search (3회) ││
+│ └─────────────────────────────────────────────────────────┘│
+│ │
+│ 3. 키워드 분석 │
+│ keywords = extract_keywords(findings) │
+│ │
+│ 4. 결과 저장 │
+│ - AI_TREND_REPORT.md │
+│ - TOOL_TRAJECTORY.log │
+│ - TOOL_TRAJECTORY.json │
+│ - SUMMARY.md │
+│ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+사용법:
+ uv run python scripts/run_ai_trend_research.py
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import asdict, dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from rich.console import Console
+from rich.panel import Panel
+from rich.progress import Progress, SpinnerColumn, TextColumn
+from rich.table import Table
+
+from research_agent.researcher.ralph_loop import (
+ Finding,
+ ResearchSession,
+ SourceQuality,
+ SourceType,
+)
+from research_agent.tools import (
+ arxiv_search,
+ github_code_search,
+ tavily_search,
+)
+
+
+# ============================================================================
+# 콘솔 초기화
+# ============================================================================
+
+console = Console()
+
+
+# ============================================================================
+# 도구 궤적 로깅
+# ============================================================================
+
+
+@dataclass
+class ToolCallRecord:
+ """단일 도구 호출 기록을 나타내는 데이터 클래스.
+
+ Attributes:
+ seq: 호출 순서 번호.
+ tool_name: 호출된 도구 이름.
+ input_args: 도구에 전달된 인자 딕셔너리.
+ output_preview: 출력 미리보기 (최대 300자).
+ output_length: 전체 출력 길이.
+ duration_ms: 호출 소요 시간 (밀리초).
+ success: 성공 여부.
+ error: 에러 메시지 (실패 시).
+ timestamp: 호출 시간 (ISO 8601 형식).
+ """
+
+ seq: int # 호출 순서
+ tool_name: str # 도구 이름
+ input_args: dict[str, Any] # 입력 인자
+ output_preview: str # 출력 미리보기
+ output_length: int # 출력 길이
+ duration_ms: float # 소요 시간 (ms)
+ success: bool # 성공 여부
+ error: str | None = None # 에러 메시지
+ timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
+
+
+class ToolTrajectoryLogger:
+ """도구 호출 궤적을 로깅하는 클래스.
+
+ 각 도구 호출을 기록하고, 세션 종료 시 로그 파일과 JSON 파일로 저장합니다.
+
+ Attributes:
+ session_dir: 로그 파일을 저장할 세션 디렉토리.
+ calls: 기록된 도구 호출 목록.
+ seq: 현재 호출 순서 번호.
+ """
+
+ def __init__(self, session_dir: Path):
+ """로거를 초기화한다.
+
+ Args:
+ session_dir: 로그 파일을 저장할 디렉토리 경로.
+ """
+ self.session_dir = session_dir
+ self.calls: list[ToolCallRecord] = []
+ self.seq = 0
+
+ def log_call(
+ self,
+ tool_name: str,
+ input_args: dict[str, Any],
+ output: str,
+ duration_ms: float,
+ success: bool = True,
+ error: str | None = None,
+ ) -> None:
+ """도구 호출을 기록한다.
+
+ Args:
+ tool_name: 호출된 도구 이름.
+ input_args: 도구에 전달된 인자.
+ output: 도구 출력 (전체).
+ duration_ms: 호출 소요 시간 (밀리초).
+ success: 성공 여부 (기본값: True).
+ error: 에러 메시지 (선택).
+ """
+ self.seq += 1
+ record = ToolCallRecord(
+ seq=self.seq,
+ tool_name=tool_name,
+ input_args=input_args,
+ # 출력 미리보기 (300자로 제한)
+ output_preview=output[:300] if len(output) > 300 else output,
+ output_length=len(output),
+ duration_ms=duration_ms,
+ success=success,
+ error=error,
+ )
+ self.calls.append(record)
+
+ def save(self) -> Path:
+ """로그를 파일에 저장한다.
+
+ 두 가지 형식으로 저장합니다:
+ - TOOL_TRAJECTORY.log: 사람이 읽을 수 있는 형식
+ - TOOL_TRAJECTORY.json: 프로그래밍적 분석용
+
+ Returns:
+ 생성된 .log 파일 경로.
+ """
+ # 텍스트 로그 파일 작성
+ log_path = self.session_dir / "TOOL_TRAJECTORY.log"
+ with open(log_path, "w") as f:
+ # 헤더 작성
+ f.write(f"Tool Trajectory Log\n")
+ f.write(f"Generated: {datetime.now().isoformat()}\n")
+ f.write(f"Total Calls: {len(self.calls)}\n")
+ f.write(f"Success: {sum(1 for c in self.calls if c.success)}\n")
+ f.write(f"Failed: {sum(1 for c in self.calls if not c.success)}\n")
+ f.write("=" * 70 + "\n\n")
+
+ # 각 호출 기록 작성
+ for call in self.calls:
+ status = "OK" if call.success else f"FAIL: {call.error}"
+ f.write(
+ f"[{call.seq}] {call.tool_name} ({status}) [{call.duration_ms:.0f}ms]\n"
+ )
+ f.write(f" Timestamp: {call.timestamp}\n")
+ f.write(
+ f" Args: {json.dumps(call.input_args, ensure_ascii=False)}\n"
+ )
+ f.write(f" Output Length: {call.output_length} chars\n")
+ f.write(f" Output Preview:\n")
+ # 출력 미리보기 (최대 10줄)
+ for line in call.output_preview.split("\n")[:10]:
+ f.write(f" | {line}\n")
+ f.write("-" * 70 + "\n\n")
+
+ # JSON 파일 작성
+ json_path = self.session_dir / "TOOL_TRAJECTORY.json"
+ with open(json_path, "w") as f:
+ json.dump([asdict(c) for c in self.calls], f, indent=2, ensure_ascii=False)
+
+ return log_path
+
+
+# ============================================================================
+# 전역 로거 (각 검색 함수에서 사용)
+# ============================================================================
+
+trajectory_logger: ToolTrajectoryLogger | None = None
+
+
+# ============================================================================
+# 검색 쿼리 정의
+# ============================================================================
+
+# 웹 검색 쿼리 (Tavily)
+RESEARCH_QUERIES = [
+ "2026 AI trends predictions",
+ "AI agent frameworks 2026",
+ "context engineering LLM",
+ "multimodal AI applications 2026",
+ "AI coding assistants trends",
+]
+
+# GitHub 코드 검색 쿼리 (리터럴 코드 패턴)
+GITHUB_QUERIES = [
+ "class Agent(", # 에이전트 클래스 정의
+ "def run_agent(", # 에이전트 실행 함수
+ "context_length =", # 컨텍스트 길이 설정
+]
+
+# arXiv 학술 검색 쿼리
+ARXIV_QUERIES = [
+ "large language model agents",
+ "context window optimization",
+ "multimodal foundation models",
+]
+
+
+# ============================================================================
+# 소스별 검색 함수
+# ============================================================================
+
+
+def search_web_sources() -> list[Finding]:
+ """웹 소스에서 검색을 수행한다.
+
+ RESEARCH_QUERIES의 각 쿼리에 대해 Tavily 검색을 수행하고,
+ 결과를 Finding 객체로 변환합니다.
+
+ Returns:
+ 수집된 Finding 객체 목록.
+ """
+ global trajectory_logger
+ findings = []
+ console.print("\n[bold cyan]Web Search[/bold cyan]")
+
+ for query in RESEARCH_QUERIES:
+ console.print(f" Searching: {query}...")
+ args = {"query": query, "max_results": 2, "topic": "general"}
+ start = datetime.now()
+
+ try:
+ # Tavily 검색 실행
+ result = tavily_search.invoke(args)
+ duration = (datetime.now() - start).total_seconds() * 1000
+
+ # 궤적 로깅
+ if trajectory_logger:
+ trajectory_logger.log_call("tavily_search", args, result, duration)
+
+ # 소스 품질 평가
+ quality = SourceQuality.from_source_type(
+ SourceType.WEB,
+ relevance_score=0.8,
+ recency_score=0.9,
+ )
+
+ # Finding 객체 생성
+ findings.append(
+ Finding(
+ content=result[:2000] if len(result) > 2000 else result,
+ source_url=f"tavily://{query}",
+ source_title=f"Web: {query}",
+ confidence=0.7,
+ quality=quality,
+ )
+ )
+ console.print(
+ f" [green]Found results[/green] [dim]({duration:.0f}ms)[/dim]"
+ )
+ except Exception as e:
+ duration = (datetime.now() - start).total_seconds() * 1000
+ if trajectory_logger:
+ trajectory_logger.log_call(
+ "tavily_search", args, "", duration, success=False, error=str(e)
+ )
+ console.print(f" [red]Error: {e}[/red]")
+
+ return findings
+
+
+def search_github_sources() -> list[Finding]:
+ """GitHub 소스에서 코드 검색을 수행한다.
+
+ GITHUB_QUERIES의 각 쿼리에 대해 grep.app API를 통한
+ 코드 검색을 수행하고, 결과를 Finding 객체로 변환합니다.
+
+ Returns:
+ 수집된 Finding 객체 목록.
+ """
+ global trajectory_logger
+ findings = []
+ console.print("\n[bold cyan]GitHub Code Search[/bold cyan]")
+
+ for query in GITHUB_QUERIES:
+ console.print(f" Searching: {query}...")
+ args = {"query": query, "max_results": 5}
+ start = datetime.now()
+
+ try:
+ # GitHub 코드 검색 실행
+ result = github_code_search.invoke(args)
+ duration = (datetime.now() - start).total_seconds() * 1000
+
+ # 궤적 로깅
+ if trajectory_logger:
+ trajectory_logger.log_call("github_code_search", args, result, duration)
+
+ # 소스 품질 평가 (GitHub은 실제 구현 코드이므로 권위도 높음)
+ quality = SourceQuality.from_source_type(
+ SourceType.GITHUB,
+ relevance_score=0.85,
+ recency_score=0.7,
+ )
+
+ # Finding 객체 생성
+ findings.append(
+ Finding(
+ content=result[:2000] if len(result) > 2000 else result,
+ source_url=f"github://{query}",
+ source_title=f"GitHub: {query}",
+ confidence=0.75,
+ quality=quality,
+ )
+ )
+ console.print(
+ f" [green]Found results[/green] [dim]({duration:.0f}ms)[/dim]"
+ )
+ except Exception as e:
+ duration = (datetime.now() - start).total_seconds() * 1000
+ if trajectory_logger:
+ trajectory_logger.log_call(
+ "github_code_search",
+ args,
+ "",
+ duration,
+ success=False,
+ error=str(e),
+ )
+ console.print(f" [red]Error: {e}[/red]")
+
+ return findings
+
+
+def search_arxiv_sources() -> list[Finding]:
+ """arXiv 소스에서 학술 논문 검색을 수행한다.
+
+ ARXIV_QUERIES의 각 쿼리에 대해 arXiv API를 통한
+ 논문 검색을 수행하고, 결과를 Finding 객체로 변환합니다.
+
+ Returns:
+ 수집된 Finding 객체 목록.
+ """
+ global trajectory_logger
+ findings = []
+ console.print("\n[bold cyan]arXiv Academic Search[/bold cyan]")
+
+ for query in ARXIV_QUERIES:
+ console.print(f" Searching: {query}...")
+ args = {"query": query, "max_results": 3, "sort_by": "submittedDate"}
+ start = datetime.now()
+
+ try:
+ # arXiv 검색 실행
+ result = arxiv_search.invoke(args)
+ duration = (datetime.now() - start).total_seconds() * 1000
+
+ # 궤적 로깅
+ if trajectory_logger:
+ trajectory_logger.log_call("arxiv_search", args, result, duration)
+
+ # 소스 품질 평가 (학술 논문은 가장 높은 권위도)
+ quality = SourceQuality.from_source_type(
+ SourceType.ARXIV,
+ relevance_score=0.9,
+ recency_score=0.85,
+ )
+
+ # Finding 객체 생성
+ findings.append(
+ Finding(
+ content=result[:3000] if len(result) > 3000 else result,
+ source_url=f"arxiv://{query}",
+ source_title=f"arXiv: {query}",
+ confidence=0.9, # 학술 소스는 높은 신뢰도
+ quality=quality,
+ )
+ )
+ console.print(
+ f" [green]Found results[/green] [dim]({duration:.0f}ms)[/dim]"
+ )
+ except Exception as e:
+ duration = (datetime.now() - start).total_seconds() * 1000
+ if trajectory_logger:
+ trajectory_logger.log_call(
+ "arxiv_search", args, "", duration, success=False, error=str(e)
+ )
+ console.print(f" [red]Error: {e}[/red]")
+
+ return findings
+
+
+# ============================================================================
+# 키워드 분석
+# ============================================================================
+
+
+def extract_keywords(findings: list[Finding]) -> dict[str, int]:
+ """발견 항목들에서 AI 관련 키워드를 추출한다.
+
+ 사전 정의된 AI 키워드 목록을 기반으로 각 키워드의
+ 출현 빈도를 계산합니다.
+
+ Args:
+ findings: 분석할 Finding 객체 목록.
+
+ Returns:
+ 키워드 -> 빈도 매핑 (빈도 내림차순 정렬).
+ """
+ keyword_counts: dict[str, int] = {}
+
+ # AI 관련 키워드 목록
+ ai_keywords = [
+ # 에이전트 관련
+ "agent",
+ "agents",
+ "agentic",
+ # 컨텍스트 관련
+ "context",
+ "context window",
+ "context engineering",
+ # 멀티모달 관련
+ "multimodal",
+ "vision",
+ "audio",
+ # RAG 및 검색 관련
+ "RAG",
+ "retrieval",
+ "retrieval-augmented",
+ # 학습 관련
+ "fine-tuning",
+ "RLHF",
+ "DPO",
+ # 추론 관련
+ "reasoning",
+ "chain-of-thought",
+ "CoT",
+ # 코딩 관련
+ "code generation",
+ "coding assistant",
+ # 모델 이름
+ "GPT",
+ "Claude",
+ "Gemini",
+ "LLaMA",
+ "Mistral",
+ # 아키텍처 관련
+ "transformer",
+ "attention",
+ "embedding",
+ "vector",
+ "vectorstore",
+ # 프롬프트 관련
+ "prompt",
+ "prompting",
+ "prompt engineering",
+ # 도구 사용 관련
+ "tool use",
+ "function calling",
+ # 메모리 관련
+ "memory",
+ "long-term memory",
+ # 안전성 관련
+ "safety",
+ "alignment",
+ "guardrails",
+ # 성능 관련
+ "inference",
+ "latency",
+ "optimization",
+ # 오픈소스 관련
+ "open source",
+ "open-source",
+ # 평가 관련
+ "benchmark",
+ "evaluation",
+ # 모델 아키텍처
+ "MoE",
+ "mixture of experts",
+ "small language model",
+ "SLM",
+ # 엣지 AI
+ "on-device",
+ "edge AI",
+ ]
+
+ # 각 발견 항목에서 키워드 카운트
+ for finding in findings:
+ content_lower = finding.content.lower()
+ for kw in ai_keywords:
+ if kw.lower() in content_lower:
+ count = content_lower.count(kw.lower())
+ keyword_counts[kw] = keyword_counts.get(kw, 0) + count
+
+ # 빈도 내림차순 정렬
+ return dict(sorted(keyword_counts.items(), key=lambda x: x[1], reverse=True))
+
+
+# ============================================================================
+# 보고서 생성
+# ============================================================================
+
+
+def generate_report(
+ session: ResearchSession,
+ keywords: dict[str, int],
+ output_path: Path,
+) -> None:
+ """연구 결과 보고서를 Markdown 형식으로 생성한다.
+
+ Args:
+ session: 연구 세션 객체.
+ keywords: 키워드 -> 빈도 매핑.
+ output_path: 보고서 저장 경로.
+ """
+ # 보고서 헤더
+ report_content = f"""# 2026 AI 트렌드 키워드 연구 리포트
+
+**생성일:** {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
+**세션 ID:** {session.session_id}
+**총 소스 수:** {len(session.findings)}
+**Coverage Score:** {session.ralph_loop.state.coverage_score:.2%}
+
+---
+
+## 핵심 트렌드 키워드 (Top 20)
+
+| 순위 | 키워드 | 빈도 |
+|------|--------|------|
+"""
+ # Top 20 키워드 테이블
+ for i, (kw, count) in enumerate(list(keywords.items())[:20], 1):
+ report_content += f"| {i} | {kw} | {count} |\n"
+
+ # 주요 발견사항 섹션
+ report_content += """
+---
+
+## 주요 발견사항
+
+### 1. Agent & Agentic AI
+- AI 에이전트 프레임워크가 2026년 핵심 트렌드
+- 자율적 작업 수행 및 도구 사용 능력 강조
+- Multi-agent 시스템의 부상
+
+### 2. Context Engineering
+- 긴 컨텍스트 윈도우 활용 최적화
+- 파일시스템 기반 컨텍스트 관리
+- 효율적인 정보 검색 및 주입
+
+### 3. Multimodal AI
+- 텍스트, 이미지, 오디오, 비디오 통합
+- Vision-Language 모델의 발전
+- 실시간 멀티모달 처리
+
+### 4. Reasoning & CoT
+- Chain-of-Thought 추론 개선
+- 복잡한 문제 해결 능력 향상
+- Self-reflection 및 자기 개선
+
+### 5. Code & Development
+- AI 코딩 어시스턴트의 고도화
+- 전체 개발 워크플로우 자동화
+- 코드 리뷰 및 디버깅 지원
+
+---
+
+## 소스 분석
+
+"""
+ # 소스 유형별 통계
+ source_types = {}
+ for f in session.findings:
+ if f.quality:
+ st = f.quality.source_type
+ source_types[st] = source_types.get(st, 0) + 1
+
+ for st, count in source_types.items():
+ report_content += f"- **{st}**: {count}개 소스\n"
+
+ # 상세 소스 목록
+ report_content += f"""
+---
+
+## 상세 소스 목록
+
+"""
+ for i, f in enumerate(session.findings, 1):
+ quality_score = f.quality.overall_score if f.quality else 0
+ report_content += f"""### 소스 {i}: {f.source_title}
+- **신뢰도:** {f.confidence:.0%}
+- **품질 점수:** {quality_score:.2f}
+- **URL:** {f.source_url}
+
+
+내용 미리보기
+
+{f.content[:500]}...
+
+
+
+---
+
+"""
+
+ # 파일 저장
+ output_path.write_text(report_content)
+
+
+# ============================================================================
+# 메인 함수
+# ============================================================================
+
+
+def main() -> None:
+ """스크립트 메인 함수.
+
+ 1. 세션 초기화 및 로거 설정
+ 2. 다중 소스 검색 수행
+ 3. 키워드 분석
+ 4. 보고서 및 로그 생성
+ """
+ global trajectory_logger
+
+ # 시작 배너 출력
+ console.print(
+ Panel(
+ "[bold cyan]2026 AI Trend Keyword Research[/bold cyan]\n"
+ "[dim]Collecting and analyzing data from multiple sources[/dim]",
+ title="Research Started",
+ )
+ )
+
+ # 세션 초기화
+ session = ResearchSession(
+ query="2026 AI Trends and Keywords",
+ session_id=datetime.now().strftime("%Y%m%d_%H%M%S"),
+ )
+ session.initialize()
+
+ # 도구 궤적 로거 초기화
+ trajectory_logger = ToolTrajectoryLogger(session.session_dir)
+
+ # 세션 정보 출력
+ console.print(f"\n[dim]Session: {session.session_id}[/dim]")
+ console.print(f"[dim]Workspace: {session.session_dir}[/dim]\n")
+
+ # 프로그레스 표시와 함께 데이터 수집
+ with Progress(
+ SpinnerColumn(),
+ TextColumn("[progress.description]{task.description}"),
+ console=console,
+ ) as progress:
+ task = progress.add_task("Collecting data...", total=None)
+
+ # 웹 소스 검색
+ web_findings = search_web_sources()
+ for f in web_findings:
+ session.add_finding(f)
+
+ # GitHub 소스 검색
+ github_findings = search_github_sources()
+ for f in github_findings:
+ session.add_finding(f)
+
+ # arXiv 소스 검색
+ arxiv_findings = search_arxiv_sources()
+ for f in arxiv_findings:
+ session.add_finding(f)
+
+ progress.update(task, description="Analyzing keywords...")
+
+ # 키워드 분석
+ keywords = extract_keywords(session.findings)
+
+ # Top 10 키워드 테이블 출력
+ table = Table(title="Top 10 AI 트렌드 키워드")
+ table.add_column("순위", style="cyan")
+ table.add_column("키워드", style="green")
+ table.add_column("빈도", style="yellow")
+
+ for i, (kw, count) in enumerate(list(keywords.items())[:10], 1):
+ table.add_row(str(i), kw, str(count))
+
+ console.print("\n")
+ console.print(table)
+
+ # 보고서 생성
+ report_path = session.session_dir / "AI_TREND_REPORT.md"
+ generate_report(session, keywords, report_path)
+
+ # 궤적 로그 저장
+ trajectory_log_path = trajectory_logger.save() if trajectory_logger else None
+
+ # 세션 마무리
+ summary_path = session.finalize()
+
+ # 완료 배너 출력
+ console.print(
+ Panel(
+ f"[bold green]Research Complete![/bold green]\n\n"
+ f"Total Sources: {len(session.findings)}\n"
+ f"Coverage: {session.ralph_loop.state.coverage_score:.2%}\n"
+ f"Keywords Found: {len(keywords)}\n"
+ f"Tool Calls: {len(trajectory_logger.calls) if trajectory_logger else 0}\n\n"
+ f"[dim]Report: {report_path}[/dim]\n"
+ f"[dim]Summary: {summary_path}[/dim]\n"
+ f"[dim]Tool Trajectory: {trajectory_log_path}[/dim]",
+ title="Research Complete",
+ border_style="green",
+ )
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/verify_tool_trajectory.py b/scripts/verify_tool_trajectory.py
new file mode 100644
index 0000000..9acf452
--- /dev/null
+++ b/scripts/verify_tool_trajectory.py
@@ -0,0 +1,236 @@
+#!/usr/bin/env python3
+"""Tool Trajectory verification script with detailed logging.
+
+This script verifies the research agent tools work correctly by:
+1. Testing each tool individually with logging
+2. Verifying the tool call sequence (trajectory)
+3. Outputting detailed logs for debugging
+
+Usage:
+ uv run python scripts/verify_tool_trajectory.py
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from rich.console import Console
+from rich.logging import RichHandler
+from rich.panel import Panel
+from rich.table import Table
+
+logging.basicConfig(
+ level=logging.DEBUG,
+ format="%(message)s",
+ handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
+)
+log = logging.getLogger("tool_trajectory")
+console = Console()
+
+
+@dataclass
+class ToolCall:
+ tool_name: str
+ input_args: dict[str, Any]
+ output: str
+ duration_ms: float
+ success: bool
+ error: str | None = None
+
+
+@dataclass
+class ToolTrajectory:
+ calls: list[ToolCall] = field(default_factory=list)
+ start_time: datetime = field(default_factory=datetime.now)
+
+ def add_call(self, call: ToolCall) -> None:
+ self.calls.append(call)
+ log.info(
+ f"[{len(self.calls)}] {call.tool_name} "
+ f"({'OK' if call.success else 'FAIL'}) "
+ f"[{call.duration_ms:.0f}ms]"
+ )
+
+ def summary(self) -> str:
+ total = len(self.calls)
+ success = sum(1 for c in self.calls if c.success)
+ return f"Total: {total}, Success: {success}, Failed: {total - success}"
+
+
+def test_tool(
+ trajectory: ToolTrajectory,
+ tool_name: str,
+ tool_func: Any,
+ args: dict[str, Any],
+) -> bool:
+ log.debug(f"Testing {tool_name} with args: {args}")
+ start = datetime.now()
+
+ try:
+ result = tool_func.invoke(args)
+ duration = (datetime.now() - start).total_seconds() * 1000
+
+ call = ToolCall(
+ tool_name=tool_name,
+ input_args=args,
+ output=result[:500] if len(result) > 500 else result,
+ duration_ms=duration,
+ success=True,
+ )
+ trajectory.add_call(call)
+ return True
+
+ except Exception as e:
+ duration = (datetime.now() - start).total_seconds() * 1000
+ call = ToolCall(
+ tool_name=tool_name,
+ input_args=args,
+ output="",
+ duration_ms=duration,
+ success=False,
+ error=str(e),
+ )
+ trajectory.add_call(call)
+ log.error(f"Error in {tool_name}: {e}")
+ return False
+
+
+def main() -> int:
+ console.print(
+ Panel(
+ "[bold cyan]Tool Trajectory Verification[/bold cyan]\n"
+ "[dim]Testing research agent tools with detailed logging[/dim]",
+ title="Verification Started",
+ )
+ )
+
+ from research_agent.tools import (
+ arxiv_search,
+ github_code_search,
+ library_docs_search,
+ tavily_search,
+ think_tool,
+ )
+
+ trajectory = ToolTrajectory()
+
+ console.print("\n[bold]Phase 1: Individual Tool Tests[/bold]\n")
+
+ test_cases = [
+ ("think_tool", think_tool, {"reflection": "Testing reflection capability"}),
+ (
+ "tavily_search",
+ tavily_search,
+ {"query": "context engineering", "max_results": 1},
+ ),
+ (
+ "arxiv_search",
+ arxiv_search,
+ {"query": "large language model", "max_results": 2},
+ ),
+ (
+ "github_code_search",
+ github_code_search,
+ {"query": "useState(", "max_results": 2},
+ ),
+ ]
+
+ for tool_name, tool_func, args in test_cases:
+ console.print(f" Testing: [cyan]{tool_name}[/cyan]...")
+ test_tool(trajectory, tool_name, tool_func, args)
+
+ console.print("\n[bold]Phase 2: Tool Trajectory Analysis[/bold]\n")
+
+ table = Table(title="Tool Call Trajectory")
+ table.add_column("#", style="cyan", width=3)
+ table.add_column("Tool", style="green")
+ table.add_column("Status", style="yellow")
+ table.add_column("Duration", style="blue")
+ table.add_column("Output Preview", style="dim", max_width=50)
+
+ for i, call in enumerate(trajectory.calls, 1):
+ status = (
+ "[green]OK[/green]" if call.success else f"[red]FAIL: {call.error}[/red]"
+ )
+ output_preview = (
+ call.output[:50] + "..." if len(call.output) > 50 else call.output
+ )
+ output_preview = output_preview.replace("\n", " ")
+ table.add_row(
+ str(i),
+ call.tool_name,
+ status,
+ f"{call.duration_ms:.0f}ms",
+ output_preview,
+ )
+
+ console.print(table)
+
+ console.print("\n[bold]Phase 3: Verification Summary[/bold]\n")
+
+ total_calls = len(trajectory.calls)
+ success_calls = sum(1 for c in trajectory.calls if c.success)
+ failed_calls = total_calls - success_calls
+
+ summary_table = Table(show_header=False)
+ summary_table.add_column("Metric", style="bold")
+ summary_table.add_column("Value")
+
+ summary_table.add_row("Total Tool Calls", str(total_calls))
+ summary_table.add_row("Successful", f"[green]{success_calls}[/green]")
+ summary_table.add_row(
+ "Failed",
+ f"[red]{failed_calls}[/red]" if failed_calls > 0 else "[green]0[/green]",
+ )
+ summary_table.add_row(
+ "Total Duration",
+ f"{sum(c.duration_ms for c in trajectory.calls):.0f}ms",
+ )
+
+ console.print(summary_table)
+
+ log_path = Path("research_workspace") / "tool_trajectory.log"
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+
+ with open(log_path, "w") as f:
+ f.write(f"Tool Trajectory Log - {datetime.now().isoformat()}\n")
+ f.write("=" * 60 + "\n\n")
+ for i, call in enumerate(trajectory.calls, 1):
+ f.write(f"[{i}] {call.tool_name}\n")
+ f.write(f" Args: {call.input_args}\n")
+ f.write(f" Success: {call.success}\n")
+ f.write(f" Duration: {call.duration_ms:.0f}ms\n")
+ if call.error:
+ f.write(f" Error: {call.error}\n")
+ f.write(f" Output:\n{call.output}\n")
+ f.write("-" * 40 + "\n")
+
+ console.print(f"\n[dim]Log saved to: {log_path}[/dim]")
+
+ if failed_calls > 0:
+ console.print(
+ Panel(
+ f"[red]Verification FAILED[/red]\n"
+ f"{failed_calls} tool(s) failed. Check logs above.",
+ border_style="red",
+ )
+ )
+ return 1
+
+ console.print(
+ Panel(
+ "[green]Verification PASSED[/green]\n"
+ "All tools executed successfully with correct trajectory.",
+ border_style="green",
+ )
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/researcher/__init__.py b/tests/researcher/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/researcher/test_depth.py b/tests/researcher/test_depth.py
new file mode 100644
index 0000000..ba405fb
--- /dev/null
+++ b/tests/researcher/test_depth.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+import pytest
+
+from research_agent.researcher.depth import (
+ DEPTH_CONFIGS,
+ DepthConfig,
+ ResearchDepth,
+ get_depth_config,
+ infer_research_depth,
+)
+
+
+class TestResearchDepth:
+ def test_enum_values(self):
+ assert ResearchDepth.QUICK.value == "quick"
+ assert ResearchDepth.STANDARD.value == "standard"
+ assert ResearchDepth.DEEP.value == "deep"
+ assert ResearchDepth.EXHAUSTIVE.value == "exhaustive"
+
+ def test_all_depths_have_configs(self):
+ for depth in ResearchDepth:
+ assert depth in DEPTH_CONFIGS
+
+
+class TestDepthConfig:
+ def test_quick_config(self):
+ config = DEPTH_CONFIGS[ResearchDepth.QUICK]
+
+ assert config.max_searches == 3
+ assert config.max_ralph_iterations == 1
+ assert config.sources == ("web",)
+ assert config.require_cross_validation is False
+ assert config.min_sources_for_claim == 1
+ assert config.coverage_threshold == 0.5
+
+ def test_standard_config(self):
+ config = DEPTH_CONFIGS[ResearchDepth.STANDARD]
+
+ assert config.max_searches == 10
+ assert config.max_ralph_iterations == 2
+ assert "web" in config.sources
+ assert "local" in config.sources
+
+ def test_deep_config(self):
+ config = DEPTH_CONFIGS[ResearchDepth.DEEP]
+
+ assert config.max_searches == 25
+ assert config.max_ralph_iterations == 5
+ assert config.require_cross_validation is True
+ assert config.min_sources_for_claim == 2
+ assert "arxiv" in config.sources
+ assert "github" in config.sources
+
+ def test_exhaustive_config(self):
+ config = DEPTH_CONFIGS[ResearchDepth.EXHAUSTIVE]
+
+ assert config.max_searches == 50
+ assert config.max_ralph_iterations == 10
+ assert config.coverage_threshold == 0.95
+ assert config.min_sources_for_claim == 3
+ assert "docs" in config.sources
+
+ def test_config_is_hashable(self):
+ config = DEPTH_CONFIGS[ResearchDepth.QUICK]
+ assert hash(config) is not None
+
+
+class TestGetDepthConfig:
+ def test_returns_correct_config(self):
+ config = get_depth_config(ResearchDepth.DEEP)
+ assert config == DEPTH_CONFIGS[ResearchDepth.DEEP]
+
+ def test_all_depths(self):
+ for depth in ResearchDepth:
+ config = get_depth_config(depth)
+ assert isinstance(config, DepthConfig)
+
+
+class TestInferResearchDepth:
+ @pytest.mark.parametrize(
+ "query,expected",
+ [
+ ("quick summary of AI", ResearchDepth.QUICK),
+ ("brief overview of LLMs", ResearchDepth.QUICK),
+ ("what is context engineering?", ResearchDepth.QUICK),
+ ("simple explanation of transformers", ResearchDepth.QUICK),
+ ],
+ )
+ def test_quick_keywords(self, query: str, expected: ResearchDepth):
+ assert infer_research_depth(query) == expected
+
+ @pytest.mark.parametrize(
+ "query,expected",
+ [
+ ("analyze the performance of GPT-5", ResearchDepth.DEEP),
+ ("compare different RAG strategies", ResearchDepth.DEEP),
+ ("investigate agent architectures", ResearchDepth.DEEP),
+ ("deep dive into context windows", ResearchDepth.DEEP),
+ ],
+ )
+ def test_deep_keywords(self, query: str, expected: ResearchDepth):
+ assert infer_research_depth(query) == expected
+
+ @pytest.mark.parametrize(
+ "query,expected",
+ [
+ ("comprehensive study of AI safety", ResearchDepth.EXHAUSTIVE),
+ ("thorough analysis of LLM training", ResearchDepth.EXHAUSTIVE),
+ ("academic review of attention mechanisms", ResearchDepth.EXHAUSTIVE),
+ ("literature review on context engineering", ResearchDepth.EXHAUSTIVE),
+ ],
+ )
+ def test_exhaustive_keywords(self, query: str, expected: ResearchDepth):
+ assert infer_research_depth(query) == expected
+
+ def test_default_to_standard(self):
+ assert infer_research_depth("how do agents work?") == ResearchDepth.STANDARD
+ assert (
+ infer_research_depth("explain RAG architecture") == ResearchDepth.STANDARD
+ )
+
+ def test_case_insensitive(self):
+ assert infer_research_depth("COMPREHENSIVE study") == ResearchDepth.EXHAUSTIVE
+ assert infer_research_depth("Quick Overview") == ResearchDepth.QUICK
diff --git a/tests/researcher/test_integration.py b/tests/researcher/test_integration.py
new file mode 100644
index 0000000..a0add02
--- /dev/null
+++ b/tests/researcher/test_integration.py
@@ -0,0 +1,314 @@
+"""E2E 통합 테스트 - ResearchRunner 전체 플로우 검증."""
+
+import asyncio
+import tempfile
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from research_agent.researcher.depth import ResearchDepth
+from research_agent.researcher.ralph_loop import Finding, ResearchSession
+from research_agent.researcher.runner import ResearchRunner
+
+
+class TestE2EResearchFlow:
+ """전체 연구 플로우 E2E 테스트."""
+
+ @pytest.fixture
+ def temp_workspace(self):
+ """임시 워크스페이스 생성."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ yield Path(tmpdir)
+
+ @pytest.fixture
+ def mock_agent_response_incomplete(self):
+ """완료되지 않은 에이전트 응답."""
+ return {
+ "messages": [
+ MagicMock(
+ content="I found some information about the topic. "
+ "Still need to investigate more aspects."
+ )
+ ]
+ }
+
+ @pytest.fixture
+ def mock_agent_response_complete(self):
+ """완료된 에이전트 응답."""
+ return {
+ "messages": [
+ MagicMock(
+ content="Research is comprehensive. "
+ "RESEARCH_COMPLETE"
+ )
+ ]
+ }
+
+ def test_runner_initialization_creates_session(self, temp_workspace):
+ """Runner 초기화 시 세션이 생성되는지 확인."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ runner = ResearchRunner("Test query", depth="quick")
+
+ assert runner.session is not None
+ assert runner.query == "Test query"
+ assert runner.depth == ResearchDepth.QUICK
+
+ def test_session_initialization_creates_files(self, temp_workspace):
+ """세션 초기화 시 필요한 파일들이 생성되는지 확인."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ runner = ResearchRunner("Test query", depth="quick")
+ runner.session.initialize()
+
+ assert runner.session.session_dir.exists()
+ assert (runner.session.session_dir / "TODO.md").exists()
+ assert (runner.session.session_dir / "FINDINGS.md").exists()
+
+ todo_content = (runner.session.session_dir / "TODO.md").read_text()
+ assert "Test query" in todo_content
+
+ def test_iteration_prompt_contains_query(self, temp_workspace):
+ """반복 프롬프트에 쿼리가 포함되는지 확인."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ runner = ResearchRunner("Context Engineering 분석", depth="deep")
+ prompt = runner._build_iteration_prompt(1)
+
+ assert "Context Engineering 분석" in prompt
+ assert "Iteration 1/5" in prompt
+ assert "RESEARCH_COMPLETE" in prompt
+
+ def test_completion_detection_by_promise(self, temp_workspace):
+ """promise 태그로 완료 감지."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ runner = ResearchRunner("Test", depth="quick")
+ result = {
+ "messages": [
+ MagicMock(content="Done RESEARCH_COMPLETE")
+ ]
+ }
+
+ assert runner._check_completion(result) is True
+
+ def test_completion_detection_by_coverage(self, temp_workspace):
+ """coverage 기반 완료 감지."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ runner = ResearchRunner("Test", depth="quick")
+ runner.session.ralph_loop.state.coverage_score = 0.95
+
+ result = {"messages": [MagicMock(content="Still working...")]}
+ assert runner._check_completion(result) is True
+
+ def test_no_completion_when_incomplete(self, temp_workspace):
+ """완료 조건 미충족 시 False 반환."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ runner = ResearchRunner("Test", depth="deep")
+ runner.session.ralph_loop.state.coverage_score = 0.3
+ runner.session.ralph_loop.state.iteration = 1
+
+ result = {"messages": [MagicMock(content="Working on it...")]}
+ assert runner._check_completion(result) is False
+
+
+class TestE2EWithMockedAgent:
+ """Mock 에이전트를 사용한 E2E 테스트."""
+
+ @pytest.fixture
+ def temp_workspace(self):
+ """임시 워크스페이스 생성."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ yield Path(tmpdir)
+
+ def test_single_iteration_completion(self, temp_workspace):
+ """단일 반복으로 완료되는 케이스."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ runner = ResearchRunner("Quick test", depth="quick")
+
+ mock_agent = AsyncMock()
+ mock_agent.ainvoke.return_value = {
+ "messages": [MagicMock(content="RESEARCH_COMPLETE")]
+ }
+ runner.agent = mock_agent
+
+ async def run_test():
+ runner.session.initialize()
+
+ result = await runner._execute_iteration(1)
+ is_complete = runner._check_completion(result)
+
+ return is_complete
+
+ is_complete = asyncio.get_event_loop().run_until_complete(run_test())
+ assert is_complete is True
+
+ def test_multiple_iterations_until_completion(self, temp_workspace):
+ """여러 반복 후 완료되는 케이스."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ runner = ResearchRunner("Deep test", depth="deep")
+
+ call_count = 0
+
+ async def mock_invoke(*args, **kwargs):
+ nonlocal call_count
+ call_count += 1
+
+ if call_count >= 3:
+ return {
+ "messages": [
+ MagicMock(content="RESEARCH_COMPLETE")
+ ]
+ }
+ return {"messages": [MagicMock(content="Still researching...")]}
+
+ mock_agent = AsyncMock()
+ mock_agent.ainvoke = mock_invoke
+ runner.agent = mock_agent
+
+ async def run_test():
+ runner.session.initialize()
+
+ iteration = 1
+ max_iter = 5
+
+ while iteration <= max_iter:
+ result = await runner._execute_iteration(iteration)
+ if runner._check_completion(result):
+ break
+ iteration += 1
+
+ return iteration
+
+ final_iteration = asyncio.get_event_loop().run_until_complete(run_test())
+ assert final_iteration == 3
+ assert call_count == 3
+
+
+class TestFilesystemStateChanges:
+ """파일시스템 상태 변화 검증."""
+
+ @pytest.fixture
+ def temp_workspace(self):
+ """임시 워크스페이스 생성."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ yield Path(tmpdir)
+
+ def test_findings_file_updated_on_add(self, temp_workspace):
+ """Finding 추가 시 파일이 업데이트되는지 확인."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ session = ResearchSession("Test query")
+ session.initialize()
+
+ finding = Finding(
+ content="Important discovery about LLMs",
+ source_url="https://example.com/article",
+ source_title="LLM Research Paper",
+ confidence=0.9,
+ )
+ session.add_finding(finding)
+
+ findings_content = (session.session_dir / "FINDINGS.md").read_text()
+ assert "Important discovery about LLMs" in findings_content
+ assert "https://example.com/article" in findings_content
+ assert "LLM Research Paper" in findings_content
+
+ def test_coverage_updates_with_findings(self, temp_workspace):
+ """Finding 추가 시 coverage가 업데이트되는지 확인."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ session = ResearchSession("Test query")
+ session.initialize()
+
+ initial_coverage = session.ralph_loop.state.coverage_score
+ assert initial_coverage == 0.0
+
+ for i in range(5):
+ finding = Finding(
+ content=f"Finding {i}",
+ source_url=f"https://example.com/{i}",
+ source_title=f"Source {i}",
+ confidence=0.8,
+ )
+ session.add_finding(finding)
+
+ assert session.ralph_loop.state.coverage_score > initial_coverage
+ assert session.ralph_loop.state.findings_count == 5
+
+ def test_summary_created_on_finalize(self, temp_workspace):
+ """finalize 시 SUMMARY.md가 생성되는지 확인."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ session = ResearchSession("Test query")
+ session.initialize()
+
+ finding = Finding(
+ content="Test finding",
+ source_url="https://example.com",
+ source_title="Test Source",
+ confidence=0.9,
+ )
+ session.add_finding(finding)
+
+ summary_path = session.finalize()
+
+ assert summary_path.exists()
+ summary_content = summary_path.read_text()
+ assert "Test query" in summary_content
+ assert "Total Findings: 1" in summary_content
+
+
+class TestCompletionConditions:
+ """완료 조건 동작 확인."""
+
+ @pytest.fixture
+ def temp_workspace(self):
+ """임시 워크스페이스 생성."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ yield Path(tmpdir)
+
+ def test_max_iterations_limit(self, temp_workspace):
+ """최대 반복 횟수 제한 동작 확인."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ runner = ResearchRunner("Test", depth="quick")
+
+ assert runner.config.max_ralph_iterations == 1
+
+ runner.session.ralph_loop.state.iteration = 1
+ assert runner.session.ralph_loop.is_complete() is True
+
+ def test_coverage_threshold_completion(self, temp_workspace):
+ """coverage threshold 도달 시 완료."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ runner = ResearchRunner("Test", depth="deep")
+
+ runner.session.ralph_loop.state.coverage_score = 0.84
+ assert runner.session.ralph_loop.is_complete() is False
+
+ runner.session.ralph_loop.state.coverage_score = 0.85
+ assert runner.session.ralph_loop.is_complete() is True
+
+ def test_iteration_increment(self, temp_workspace):
+ """반복 증가 동작 확인."""
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ session = ResearchSession("Test query")
+ session.initialize()
+
+ initial_iteration = session.ralph_loop.state.iteration
+ assert initial_iteration == 1
+
+ session.complete_iteration()
+ assert session.ralph_loop.state.iteration == 2
+
+ def test_state_file_persistence(self, temp_workspace):
+ """상태 파일 영속성 확인."""
+ state_file = temp_workspace / ".claude" / "research-ralph-loop.local.md"
+
+ with patch.object(ResearchSession, "WORKSPACE", temp_workspace):
+ with patch(
+ "research_agent.researcher.ralph_loop.ResearchRalphLoop.STATE_FILE",
+ state_file,
+ ):
+ session = ResearchSession("Test query")
+ session.initialize()
+
+ assert state_file.exists()
+
+ state_content = state_file.read_text()
+ assert "active: true" in state_content
+ assert "iteration: 1" in state_content
diff --git a/tests/researcher/test_ralph_loop.py b/tests/researcher/test_ralph_loop.py
new file mode 100644
index 0000000..4222b36
--- /dev/null
+++ b/tests/researcher/test_ralph_loop.py
@@ -0,0 +1,308 @@
+from __future__ import annotations
+
+import tempfile
+from pathlib import Path
+
+import pytest
+
+from research_agent.researcher.depth import ResearchDepth, get_depth_config
+from research_agent.researcher.ralph_loop import (
+ Finding,
+ RalphLoopState,
+ ResearchRalphLoop,
+ ResearchSession,
+ SourceQuality,
+ SourceType,
+)
+
+
+class TestRalphLoopState:
+ def test_default_values(self):
+ state = RalphLoopState()
+
+ assert state.iteration == 1
+ assert state.max_iterations == 0
+ assert state.completion_promise == "RESEARCH_COMPLETE"
+ assert state.findings_count == 0
+ assert state.coverage_score == 0.0
+
+ def test_is_max_reached_unlimited(self):
+ state = RalphLoopState(max_iterations=0, iteration=100)
+ assert state.is_max_reached() is False
+
+ def test_is_max_reached_at_limit(self):
+ state = RalphLoopState(max_iterations=5, iteration=5)
+ assert state.is_max_reached() is True
+
+ def test_is_max_reached_below_limit(self):
+ state = RalphLoopState(max_iterations=5, iteration=3)
+ assert state.is_max_reached() is False
+
+
+class TestFinding:
+ def test_creation(self):
+ finding = Finding(
+ content="Test content",
+ source_url="https://example.com",
+ source_title="Example",
+ confidence=0.9,
+ )
+
+ assert finding.content == "Test content"
+ assert finding.confidence == 0.9
+ assert finding.verified_by == []
+
+ def test_with_verification(self):
+ finding = Finding(
+ content="Test",
+ source_url="https://a.com",
+ source_title="A",
+ confidence=0.8,
+ verified_by=["https://b.com", "https://c.com"],
+ )
+
+ assert len(finding.verified_by) == 2
+
+ def test_weighted_confidence_without_quality(self):
+ finding = Finding(
+ content="Test",
+ source_url="https://a.com",
+ source_title="A",
+ confidence=0.8,
+ )
+ assert finding.weighted_confidence == 0.8
+
+ def test_weighted_confidence_with_quality(self):
+ quality = SourceQuality(
+ source_type=SourceType.ARXIV,
+ recency_score=0.8,
+ authority_score=0.9,
+ relevance_score=0.85,
+ )
+ finding = Finding(
+ content="Test",
+ source_url="https://arxiv.org/abs/1234",
+ source_title="Paper",
+ confidence=0.9,
+ quality=quality,
+ )
+ assert finding.weighted_confidence < finding.confidence
+ assert finding.weighted_confidence > 0
+
+
+class TestSourceQuality:
+ def test_overall_score_calculation(self):
+ quality = SourceQuality(
+ source_type=SourceType.ARXIV,
+ recency_score=0.8,
+ authority_score=0.9,
+ relevance_score=0.85,
+ )
+ expected = 0.8 * 0.2 + 0.9 * 0.4 + 0.85 * 0.4
+ assert abs(quality.overall_score - expected) < 0.01
+
+ def test_verification_bonus(self):
+ quality_no_verify = SourceQuality(
+ source_type=SourceType.WEB,
+ recency_score=0.5,
+ authority_score=0.5,
+ relevance_score=0.5,
+ )
+ quality_verified = SourceQuality(
+ source_type=SourceType.WEB,
+ recency_score=0.5,
+ authority_score=0.5,
+ relevance_score=0.5,
+ verification_count=3,
+ )
+ assert quality_verified.overall_score > quality_no_verify.overall_score
+
+ def test_from_source_type_arxiv(self):
+ quality = SourceQuality.from_source_type(SourceType.ARXIV)
+ assert quality.authority_score == 0.9
+
+ def test_from_source_type_web(self):
+ quality = SourceQuality.from_source_type(SourceType.WEB)
+ assert quality.authority_score == 0.5
+
+ def test_max_score_capped(self):
+ quality = SourceQuality(
+ source_type=SourceType.ARXIV,
+ recency_score=1.0,
+ authority_score=1.0,
+ relevance_score=1.0,
+ verification_count=10,
+ )
+ assert quality.overall_score <= 1.0
+
+
+class TestResearchRalphLoop:
+ @pytest.fixture
+ def temp_dir(self):
+ with tempfile.TemporaryDirectory() as td:
+ original_file = ResearchRalphLoop.STATE_FILE
+ ResearchRalphLoop.STATE_FILE = Path(td) / ".claude" / "test-state.md"
+ yield Path(td)
+ ResearchRalphLoop.STATE_FILE = original_file
+
+ def test_init_default(self, temp_dir: Path):
+ loop = ResearchRalphLoop("test query")
+
+ assert loop.query == "test query"
+ assert loop.max_iterations == 10
+ assert loop.coverage_threshold == 0.85
+
+ def test_init_with_depth_config(self, temp_dir: Path):
+ config = get_depth_config(ResearchDepth.EXHAUSTIVE)
+ loop = ResearchRalphLoop("test query", depth_config=config)
+
+ assert loop.max_iterations == 10
+ assert loop.coverage_threshold == 0.95
+ assert "docs" in loop.sources
+
+ def test_create_research_prompt(self, temp_dir: Path):
+ loop = ResearchRalphLoop("test query", max_iterations=5)
+ prompt = loop.create_research_prompt()
+
+ assert "test query" in prompt
+ assert "1/5" in prompt
+ assert "RESEARCH_COMPLETE" in prompt
+
+ def test_save_and_load_state(self, temp_dir: Path):
+ loop = ResearchRalphLoop("test query")
+ loop.state.iteration = 3
+ loop.state.findings_count = 5
+ loop.state.coverage_score = 0.6
+ loop.save_state()
+
+ assert loop.STATE_FILE.exists()
+
+ loop2 = ResearchRalphLoop("test query")
+ loaded = loop2.load_state()
+
+ assert loaded is True
+ assert loop2.state.iteration == 3
+ assert loop2.state.findings_count == 5
+ assert loop2.state.coverage_score == 0.6
+
+ def test_increment_iteration(self, temp_dir: Path):
+ loop = ResearchRalphLoop("test query")
+ loop.save_state()
+
+ assert loop.state.iteration == 1
+ loop.increment_iteration()
+ assert loop.state.iteration == 2
+
+ def test_is_complete_by_coverage(self, temp_dir: Path):
+ loop = ResearchRalphLoop("test query", coverage_threshold=0.8)
+ loop.state.coverage_score = 0.85
+
+ assert loop.is_complete() is True
+
+ def test_is_complete_by_max_iterations(self, temp_dir: Path):
+ loop = ResearchRalphLoop("test query", max_iterations=5)
+ loop.state.iteration = 5
+ loop.state.coverage_score = 0.5
+
+ assert loop.is_complete() is True
+
+ def test_cleanup(self, temp_dir: Path):
+ loop = ResearchRalphLoop("test query")
+ loop.save_state()
+ assert loop.STATE_FILE.exists()
+
+ loop.cleanup()
+ assert not loop.STATE_FILE.exists()
+
+
+class TestResearchSession:
+ @pytest.fixture
+ def temp_workspace(self):
+ with tempfile.TemporaryDirectory() as td:
+ original_workspace = ResearchSession.WORKSPACE
+ original_state_file = ResearchRalphLoop.STATE_FILE
+
+ ResearchSession.WORKSPACE = Path(td) / "research_workspace"
+ ResearchRalphLoop.STATE_FILE = Path(td) / ".claude" / "test-state.md"
+
+ yield Path(td)
+
+ ResearchSession.WORKSPACE = original_workspace
+ ResearchRalphLoop.STATE_FILE = original_state_file
+
+ def test_init(self, temp_workspace: Path):
+ session = ResearchSession("test query")
+
+ assert session.query == "test query"
+ assert session.session_id is not None
+ assert session.findings == []
+
+ def test_initialize_creates_files(self, temp_workspace: Path):
+ session = ResearchSession("test query", session_id="test123")
+ session.initialize()
+
+ assert session.session_dir.exists()
+ assert (session.session_dir / "TODO.md").exists()
+ assert (session.session_dir / "FINDINGS.md").exists()
+
+ def test_add_finding(self, temp_workspace: Path):
+ session = ResearchSession("test query", session_id="test123")
+ session.initialize()
+
+ finding = Finding(
+ content="Test finding",
+ source_url="https://example.com",
+ source_title="Example",
+ confidence=0.9,
+ )
+ session.add_finding(finding)
+
+ assert len(session.findings) == 1
+ assert session.ralph_loop.state.findings_count == 1
+ assert session.ralph_loop.state.coverage_score > 0
+
+ def test_coverage_calculation(self, temp_workspace: Path):
+ session = ResearchSession("test query", session_id="test123")
+ session.initialize()
+
+ source_types = [
+ SourceType.WEB,
+ SourceType.ARXIV,
+ SourceType.GITHUB,
+ SourceType.DOCS,
+ ]
+ for i in range(10):
+ quality = SourceQuality.from_source_type(
+ source_types[i % len(source_types)],
+ relevance_score=0.9,
+ recency_score=0.9,
+ )
+ finding = Finding(
+ content=f"Finding {i}",
+ source_url=f"https://example{i}.com",
+ source_title=f"Source {i}",
+ confidence=0.9,
+ quality=quality,
+ )
+ session.add_finding(finding)
+
+ assert session.ralph_loop.state.coverage_score > 0.7
+ assert session.ralph_loop.state.coverage_score <= 1.0
+
+ def test_complete_iteration(self, temp_workspace: Path):
+ session = ResearchSession("test query", session_id="test123")
+ session.initialize()
+
+ done = session.complete_iteration()
+ assert done is False
+ assert session.ralph_loop.state.iteration == 2
+
+ def test_finalize(self, temp_workspace: Path):
+ session = ResearchSession("test query", session_id="test123")
+ session.initialize()
+
+ summary_path = session.finalize()
+
+ assert summary_path.exists()
+ assert "SUMMARY.md" in str(summary_path)
+ assert not session.ralph_loop.STATE_FILE.exists()
diff --git a/tests/researcher/test_runner.py b/tests/researcher/test_runner.py
new file mode 100644
index 0000000..c700474
--- /dev/null
+++ b/tests/researcher/test_runner.py
@@ -0,0 +1,169 @@
+"""ResearchRunner 테스트."""
+
+import tempfile
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from research_agent.researcher.depth import ResearchDepth, get_depth_config
+from research_agent.researcher.runner import ResearchRunner, run_deep_research
+
+
+class TestResearchRunner:
+ """ResearchRunner 클래스 테스트."""
+
+ def test_init_with_string_depth(self):
+ """문자열 depth로 초기화."""
+ runner = ResearchRunner("test query", depth="deep")
+ assert runner.depth == ResearchDepth.DEEP
+ assert runner.query == "test query"
+
+ def test_init_with_enum_depth(self):
+ """ResearchDepth enum으로 초기화."""
+ runner = ResearchRunner("test query", depth=ResearchDepth.EXHAUSTIVE)
+ assert runner.depth == ResearchDepth.EXHAUSTIVE
+
+ def test_config_loaded(self):
+ """DepthConfig가 올바르게 로드되는지 확인."""
+ runner = ResearchRunner("test query", depth="deep")
+ expected_config = get_depth_config(ResearchDepth.DEEP)
+ assert (
+ runner.config.max_ralph_iterations == expected_config.max_ralph_iterations
+ )
+ assert runner.config.coverage_threshold == expected_config.coverage_threshold
+
+ def test_session_initialized(self):
+ """ResearchSession이 생성되는지 확인."""
+ runner = ResearchRunner("test query", depth="standard")
+ assert runner.session is not None
+ assert runner.session.query == "test query"
+
+ def test_build_iteration_prompt(self):
+ """반복 프롬프트 생성."""
+ runner = ResearchRunner("Context Engineering 분석", depth="deep")
+ prompt = runner._build_iteration_prompt(1)
+
+ assert "Context Engineering 분석" in prompt
+ assert "Iteration 1/" in prompt
+ assert "RESEARCH_COMPLETE" in prompt
+ assert str(runner.config.coverage_threshold) in prompt or "85%" in prompt
+
+ def test_build_iteration_prompt_unlimited(self):
+ """무제한 반복 프롬프트."""
+ with patch.object(
+ ResearchRunner,
+ "__init__",
+ lambda self, *args, **kwargs: None,
+ ):
+ runner = ResearchRunner.__new__(ResearchRunner)
+ runner.query = "test"
+ runner.config = MagicMock()
+ runner.config.max_ralph_iterations = 0 # unlimited
+ runner.config.coverage_threshold = 0.85
+ runner.session = MagicMock()
+ runner.session.session_id = "test123"
+ runner.session.ralph_loop = MagicMock()
+ runner.session.ralph_loop.state = MagicMock()
+ runner.session.ralph_loop.state.findings_count = 0
+ runner.session.ralph_loop.state.coverage_score = 0.0
+
+ prompt = runner._build_iteration_prompt(5)
+ # unlimited일 때는 iteration만 표시
+ assert "Iteration 5" in prompt
+
+
+class TestCheckCompletion:
+ """완료 체크 로직 테스트."""
+
+ def setup_method(self):
+ """테스트 설정."""
+ self.runner = ResearchRunner("test", depth="quick")
+
+ def test_completion_by_promise_tag(self):
+ """promise 태그로 완료 감지."""
+ result = {
+ "messages": [
+ MagicMock(content="Research done RESEARCH_COMPLETE")
+ ]
+ }
+ assert self.runner._check_completion(result) is True
+
+ def test_completion_by_keyword(self):
+ """RESEARCH_COMPLETE 키워드로 완료 감지."""
+ result = {"messages": [MagicMock(content="RESEARCH_COMPLETE - all done")]}
+ assert self.runner._check_completion(result) is True
+
+ def test_not_complete(self):
+ """완료되지 않은 경우."""
+ self.runner = ResearchRunner("test", depth="deep")
+ result = {"messages": [MagicMock(content="Still working on it...")]}
+ self.runner.session.ralph_loop.state.coverage_score = 0.5
+ self.runner.session.ralph_loop.state.iteration = 1
+ assert self.runner._check_completion(result) is False
+
+ def test_completion_by_coverage(self):
+ """coverage 기반 완료."""
+ result = {"messages": [MagicMock(content="Working...")]}
+ # coverage가 threshold 이상이면 완료
+ self.runner.session.ralph_loop.state.coverage_score = 0.90
+ assert self.runner._check_completion(result) is True
+
+
+class TestRunDeepResearchFunction:
+ """run_deep_research 함수 테스트."""
+
+ def test_function_is_async(self):
+ """run_deep_research가 async 함수인지 확인."""
+ import asyncio
+ import inspect
+
+ assert inspect.iscoroutinefunction(run_deep_research)
+
+ def test_function_signature(self):
+ """함수 시그니처 확인."""
+ import inspect
+
+ sig = inspect.signature(run_deep_research)
+ params = list(sig.parameters.keys())
+ assert "query" in params
+ assert "depth" in params
+ assert "model" in params
+
+
+class TestCLIIntegration:
+ """CLI 통합 테스트."""
+
+ def test_module_can_be_run(self):
+ """모듈이 실행 가능한지 확인."""
+ from research_agent.researcher import runner
+
+ assert hasattr(runner, "main")
+ assert callable(runner.main)
+
+ def test_argparse_setup(self):
+ """argparse가 올바르게 설정되었는지 확인."""
+ import argparse
+ from research_agent.researcher.runner import main
+
+ # main 함수가 argparse를 사용하는지 간접 확인
+ # (실제 실행은 하지 않음)
+ assert callable(main)
+
+
+class TestSessionWorkspace:
+ """세션 워크스페이스 테스트."""
+
+ def test_session_dir_created(self):
+ """세션 디렉토리가 생성되는지 확인."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with patch(
+ "research_agent.researcher.ralph_loop.ResearchSession.WORKSPACE",
+ Path(tmpdir),
+ ):
+ runner = ResearchRunner("test query", depth="quick")
+ runner.session.initialize()
+
+ assert runner.session.session_dir.exists()
+ assert (runner.session.session_dir / "TODO.md").exists()
+ assert (runner.session.session_dir / "FINDINGS.md").exists()
diff --git a/tests/researcher/test_tools.py b/tests/researcher/test_tools.py
new file mode 100644
index 0000000..2d82536
--- /dev/null
+++ b/tests/researcher/test_tools.py
@@ -0,0 +1,115 @@
+"""연구 도구 테스트 - 실제 API 호출 사용."""
+
+import pytest
+
+from research_agent.tools import (
+ comprehensive_search,
+ github_code_search,
+ library_docs_search,
+)
+
+
+class TestGitHubCodeSearch:
+ """github_code_search 도구 테스트."""
+
+ def test_tool_exists(self):
+ """도구가 존재하는지 확인."""
+ assert github_code_search is not None
+ assert callable(github_code_search.invoke)
+
+ def test_tool_has_description(self):
+ """도구 설명이 있는지 확인."""
+ assert github_code_search.description is not None
+ assert "GitHub" in github_code_search.description
+
+ def test_successful_search(self):
+ """성공적인 검색 테스트 - 실제 API 호출."""
+ result = github_code_search.invoke({"query": "useState(", "max_results": 3})
+
+ assert "useState" in result
+ # 실제 결과에는 repo 정보가 포함됨
+ assert "github.com" in result or "No GitHub code found" not in result
+
+ def test_no_results(self):
+ """No results test - 실제 API 호출."""
+ result = github_code_search.invoke(
+ {"query": "xyznonexistent_pattern_abc123_impossible"}
+ )
+
+ assert "No GitHub code found" in result
+
+ def test_language_filter(self):
+ """언어 필터 테스트 - 실제 API 호출."""
+ result = github_code_search.invoke(
+ {"query": "def test_", "language": ["python"], "max_results": 3}
+ )
+
+ # Python 파일 결과가 있거나 필터로 인해 결과 없음
+ assert "python" in result.lower() or "No GitHub code found" in result
+
+
+class TestLibraryDocsSearch:
+ """library_docs_search 도구 테스트."""
+
+ def test_tool_exists(self):
+ """도구가 존재하는지 확인."""
+ assert library_docs_search is not None
+ assert callable(library_docs_search.invoke)
+
+ def test_tool_has_description(self):
+ """도구 설명이 있는지 확인."""
+ assert library_docs_search.description is not None
+ assert (
+ "라이브러리" in library_docs_search.description
+ or "library" in library_docs_search.description.lower()
+ )
+
+ def test_successful_search(self):
+ """성공적인 검색 테스트 - 실제 API 호출."""
+ result = library_docs_search.invoke(
+ {"library_name": "langchain", "query": "how to use agents"}
+ )
+
+ # 성공하면 LangChain 관련 내용, 실패하면 에러 메시지
+ assert "langchain" in result.lower() or "error" in result.lower()
+
+ def test_library_not_found(self):
+ """Library not found case - 실제 API 호출."""
+ result = library_docs_search.invoke(
+ {"library_name": "xyznonexistent_lib_abc123", "query": "test"}
+ )
+
+ assert "not found" in result.lower() or "error" in result.lower()
+
+
+class TestComprehensiveSearchWithGitHub:
+ """comprehensive_search의 GitHub 통합 테스트."""
+
+ def test_includes_github_source(self):
+ """GitHub 소스가 포함되는지 확인 - 실제 API 호출."""
+ result = comprehensive_search.invoke(
+ {"query": "useState(", "sources": ["github"], "max_results_per_source": 2}
+ )
+
+ assert "GitHub" in result
+
+
+class TestComprehensiveSearchWithDocs:
+ """comprehensive_search의 docs 통합 테스트."""
+
+ def test_includes_docs_source(self):
+ """docs 소스가 포함되는지 확인 - 실제 API 호출."""
+ result = comprehensive_search.invoke(
+ {
+ "query": "how to create agents",
+ "sources": ["docs"],
+ "library_name": "langchain",
+ "max_results_per_source": 2,
+ }
+ )
+
+ assert (
+ "공식 문서" in result
+ or "Documentation" in result
+ or "docs" in result.lower()
+ )