agent: wire research-repo Jina + LLM extractor + scholar + real condenser
Browse filesBrings the Space inference path into functional parity with
OSU-NLP-Group/QUEST/inference/:
- System prompt now advertises a third tool `google_scholar` (Serper /scholar).
- `visit` first tries r.jina.ai (with `JINA_API_KEYS`); on a hit it runs the
SUMMARY model (`SUMMARY_MODEL_NAME`, `API_KEY`/`API_BASE`) with the
research-repo EXTRACTOR_PROMPT for goal-directed distillation. Falls back
to the existing BeautifulSoup path if Jina or the LLM is unavailable.
- New `_run_scholar_single` mirrors inference/tool_scholar.py (Serper
/scholar endpoint, same row fields).
- `condenser` strategy now invokes a real State Summarizer: when the
in-context token estimate crosses MEMORY_TOKEN_THRESHOLD (default 16000),
the MEMORY model is called with the verbatim MEMORY_SYSTEM_PROMPT from
inference/tool_memory.py to produce the structured trusted/untrusted/
uncertain JSON, which is then injected as RESEARCH STATE SUMMARY and
replaces the long history. The legacy turn-count heuristic stays as a
fallback if the MEMORY model is not configured.
- requirements.txt: openai>=1.40, tiktoken>=0.7
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- app.py +384 -3
- requirements.txt +2 -0
|
@@ -52,11 +52,27 @@ You are provided with function signatures within <tools></tools> XML tags:
|
|
| 52 |
<tools>
|
| 53 |
{"type": "function", "function": {"name": "search", "description": "Perform Google web searches then returns a string of the top search results. Accepts multiple queries.", "parameters": {"type": "object", "properties": {"query": {"type": "array", "items": {"type": "string", "description": "The search query."}, "minItems": 1, "description": "The list of search queries."}}, "required": ["query"]}}}
|
| 54 |
{"type": "function", "function": {"name": "visit", "description": "Visit webpage(s) and return the summary of the content.", "parameters": {"type": "object", "properties": {"url": {"type": "array", "items": {"type": "string"}, "description": "The URL(s) of the webpage(s) to visit. Can be a single URL or an array of URLs."}, "goal": {"type": "string", "description": "The specific information goal for visiting webpage(s)."}}, "required": ["url", "goal"]}}}
|
|
|
|
| 55 |
</tools>
|
| 56 |
|
| 57 |
# Using prev_state (Research State Summary)
|
| 58 |
|
| 59 |
-
If you see a "RESEARCH STATE SUMMARY (prev_state)" section in the user message, it contains a compressed summary of previous research progress. Use it to
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
|
| 62 |
<tool_call>
|
|
@@ -66,6 +82,116 @@ For each function call, return a json object with function name and arguments wi
|
|
| 66 |
Current date: """
|
| 67 |
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
def build_system_prompt() -> str:
|
| 70 |
return QUEST_SYSTEM_PROMPT + date.today().isoformat()
|
| 71 |
|
|
@@ -1417,12 +1543,195 @@ def _clean_html_to_text(html: str, max_chars: int) -> str:
|
|
| 1417 |
return text[:max_chars]
|
| 1418 |
|
| 1419 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1420 |
def _run_visit_single(url: str, max_chars: int, goal: str = "") -> Dict[str, Any]:
|
| 1421 |
if not url.strip():
|
| 1422 |
return {"ok": False, "error": "URL cannot be empty."}
|
| 1423 |
-
cache_key = f"{url.strip()}::{max_chars}"
|
| 1424 |
if cache_key in VISIT_CACHE:
|
| 1425 |
return {**VISIT_CACHE[cache_key], "cached": True, "goal": goal}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1426 |
try:
|
| 1427 |
resp = requests.get(
|
| 1428 |
url,
|
|
@@ -1634,6 +1943,13 @@ def build_research_agent(
|
|
| 1634 |
]
|
| 1635 |
|
| 1636 |
final_answer: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1637 |
|
| 1638 |
status_lines.append("π Starting research agent")
|
| 1639 |
yield _emit()
|
|
@@ -1643,7 +1959,53 @@ def build_research_agent(
|
|
| 1643 |
|
| 1644 |
for turn in range(1, max_turns + 1):
|
| 1645 |
_apply_memory_strategy(messages, strategy, turn)
|
| 1646 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1647 |
summary_lines = "\n".join(f"- {n}" for n in state.trusted_notes[-6:])
|
| 1648 |
messages.append(
|
| 1649 |
{
|
|
@@ -1816,6 +2178,25 @@ def build_research_agent(
|
|
| 1816 |
f"β
turn {turn}: read {visit_ok}/{len(urls)} page(s)"
|
| 1817 |
)
|
| 1818 |
yield _emit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1819 |
else:
|
| 1820 |
tool_response = {"ok": False, "error": f"Unknown tool: {tool_name}"}
|
| 1821 |
status_lines.append(f"β οΈ turn {turn}: unknown tool `{tool_name}`")
|
|
|
|
| 52 |
<tools>
|
| 53 |
{"type": "function", "function": {"name": "search", "description": "Perform Google web searches then returns a string of the top search results. Accepts multiple queries.", "parameters": {"type": "object", "properties": {"query": {"type": "array", "items": {"type": "string", "description": "The search query."}, "minItems": 1, "description": "The list of search queries."}}, "required": ["query"]}}}
|
| 54 |
{"type": "function", "function": {"name": "visit", "description": "Visit webpage(s) and return the summary of the content.", "parameters": {"type": "object", "properties": {"url": {"type": "array", "items": {"type": "string"}, "description": "The URL(s) of the webpage(s) to visit. Can be a single URL or an array of URLs."}, "goal": {"type": "string", "description": "The specific information goal for visiting webpage(s)."}}, "required": ["url", "goal"]}}}
|
| 55 |
+
{"type": "function", "function": {"name": "google_scholar", "description": "Leverage Google Scholar to retrieve relevant information from academic publications. Accepts multiple queries.", "parameters": {"type": "object", "properties": {"query": {"type": "array", "items": {"type": "string", "description": "The search query."}, "minItems": 1, "description": "The list of search queries for Google Scholar."}}, "required": ["query"]}}}
|
| 56 |
</tools>
|
| 57 |
|
| 58 |
# Using prev_state (Research State Summary)
|
| 59 |
|
| 60 |
+
If you see a "RESEARCH STATE SUMMARY (prev_state)" section in the user message, it contains a compressed summary of previous research progress. Use it to:
|
| 61 |
+
|
| 62 |
+
1. **Avoid redundant work**:
|
| 63 |
+
- Check `search_queries` to avoid repeating searches that have already been executed.
|
| 64 |
+
- Check `visited_sources` to avoid visiting URLs that have already been visited.
|
| 65 |
+
|
| 66 |
+
2. **Use verified information**:
|
| 67 |
+
- Check `information_state.trusted` for facts that have been verified from visited sources. You can use these directly in your answer without re-searching or re-visiting.
|
| 68 |
+
- Check `information_state.untrusted` for claims that have been contradicted or proven unreliable.
|
| 69 |
+
|
| 70 |
+
3. **Follow up on uncertain information**:
|
| 71 |
+
- Check `information_state.uncertain` for claims that need more evidence. The `need` field specifies the exact next action (e.g., "visit <URL>" or "search <query>") to resolve the uncertainty.
|
| 72 |
+
|
| 73 |
+
IMPORTANT: Do NOT search for or visit information that is already in `prev_state`, unless it's insufficient to answer the user's question. Only in this case, you are encouraged to search for more information or even visit the same URL. Instead, use the information from `prev_state` directly, or follow the specific actions suggested in `information_state.uncertain.need` if more information is needed.
|
| 74 |
+
|
| 75 |
+
The final answer must exclude any information that remains uncertain or pending. All statements included must be fully verified.
|
| 76 |
|
| 77 |
For each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:
|
| 78 |
<tool_call>
|
|
|
|
| 82 |
Current date: """
|
| 83 |
|
| 84 |
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Vendored prompts from OSU-NLP-Group/QUEST (inference/prompt.py and
|
| 87 |
+
# inference/tool_memory.py). Kept verbatim so the secondary-LLM behaviour
|
| 88 |
+
# (visit extractor + condenser State Summarizer) matches the research code.
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
EXTRACTOR_PROMPT = """Please process the following webpage content and user goal to extract relevant information:
|
| 92 |
+
|
| 93 |
+
## **Webpage Content**
|
| 94 |
+
{webpage_content}
|
| 95 |
+
|
| 96 |
+
## **User Goal**
|
| 97 |
+
{goal}
|
| 98 |
+
|
| 99 |
+
## **Task Guidelines**
|
| 100 |
+
1. **Content Scanning for Rationale**: Locate the **specific sections/data** directly related to the user's goal within the webpage content
|
| 101 |
+
2. **Key Extraction for Evidence**: Identify and extract the **most relevant information** from the content, you never miss any important information, output the **full original context** of the content as far as possible, it can be more than three paragraphs.
|
| 102 |
+
3. **Summary Output for Summary**: Organize into a concise paragraph with logical flow, prioritizing clarity and judge the contribution of the information to the goal.
|
| 103 |
+
|
| 104 |
+
**Final Output Format using JSON format has "rational", "evidence", "summary" feilds**
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
MEMORY_SYSTEM_PROMPT = """You are a State Summarizer for a DeepResearch agent.
|
| 108 |
+
Your ONLY job is to maintain a compact, parseable, context-aware state JSON for memory management.
|
| 109 |
+
|
| 110 |
+
Your primary objective is to prevent redundant search and redundant visit actions by
|
| 111 |
+
extracting useful, answer-ready information from tool responses and preserving it
|
| 112 |
+
in a structured state.
|
| 113 |
+
|
| 114 |
+
You will be given:
|
| 115 |
+
1) events: a chronological list of interaction events (user/assistant messages and tool calls/responses)
|
| 116 |
+
2) prev_state: the previous state JSON (may be empty or null)
|
| 117 |
+
|
| 118 |
+
You MUST output ONLY a single JSON object that conforms EXACTLY to the schema below.
|
| 119 |
+
No markdown, no extra text, no code fences, no explanations.
|
| 120 |
+
|
| 121 |
+
========================
|
| 122 |
+
OUTPUT JSON SCHEMA (STRICT)
|
| 123 |
+
|
| 124 |
+
{
|
| 125 |
+
"version": "dr_state",
|
| 126 |
+
"search_queries": [
|
| 127 |
+
{ "q": "string", "intent": "string" }
|
| 128 |
+
],
|
| 129 |
+
"visited_sources": [
|
| 130 |
+
{ "url": "string", "note": "string" }
|
| 131 |
+
],
|
| 132 |
+
"information_state": {
|
| 133 |
+
"trusted": [
|
| 134 |
+
{ "id": "T1", "claim": "string", "sources": ["string"], "reason": "string" }
|
| 135 |
+
],
|
| 136 |
+
"untrusted": [
|
| 137 |
+
{ "id": "U1", "claim": "string", "sources": ["string"], "reason": "string" }
|
| 138 |
+
],
|
| 139 |
+
"uncertain": [
|
| 140 |
+
{ "id": "C1", "claim": "string", "sources": ["string"], "reason": "string", "need": "string" }
|
| 141 |
+
]
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
========================
|
| 146 |
+
TRIGGER NOTE (IMPORTANT)
|
| 147 |
+
|
| 148 |
+
This summarizer is invoked automatically when CONTEXT_THRESHOLD is reached:
|
| 149 |
+
- The system invokes summarization when context tokens reach a threshold.
|
| 150 |
+
- Focus on extracting evidence, deduplicating tool usage, and making the state more actionable.
|
| 151 |
+
|
| 152 |
+
Note: Agent-initiated condenser tool calls are ignored for memory updates.
|
| 153 |
+
Only automatic CONTEXT_THRESHOLD triggers will update the memory state.
|
| 154 |
+
|
| 155 |
+
========================
|
| 156 |
+
CORE PRINCIPLE (CRITICAL)
|
| 157 |
+
|
| 158 |
+
Visited pages alone are NOT useful memory.
|
| 159 |
+
|
| 160 |
+
For every visit() tool_response, you MUST attempt to extract at least one
|
| 161 |
+
useful, concrete fact into information_state unless the page is irrelevant.
|
| 162 |
+
|
| 163 |
+
The goal is that the DeepResearch agent can rely on information_state.trusted
|
| 164 |
+
to answer questions directly, and rely on information_state.uncertain.need
|
| 165 |
+
to know the exact next step without re-searching.
|
| 166 |
+
|
| 167 |
+
========================
|
| 168 |
+
UPDATE RULES (IMPORTANT)
|
| 169 |
+
|
| 170 |
+
0) Anti-redundancy objective:
|
| 171 |
+
- The state must clearly encode:
|
| 172 |
+
a) what is already verified and final (trusted),
|
| 173 |
+
b) what is false or contradicted (untrusted),
|
| 174 |
+
c) what is missing AND the exact next action to resolve it (uncertain.need).
|
| 175 |
+
- Prefer concrete actions such as:
|
| 176 |
+
"visit <exact URL>" or "search <exact query>".
|
| 177 |
+
|
| 178 |
+
1) Merge with prev_state:
|
| 179 |
+
- Start from prev_state if provided; update it using new events.
|
| 180 |
+
- Never delete past entries except for:
|
| 181 |
+
a) exact duplicates, or
|
| 182 |
+
b) bucket migration (moving the same claim between uncertain/trusted/untrusted).
|
| 183 |
+
|
| 184 |
+
2) De-duplication:
|
| 185 |
+
- search_queries: dedupe by exact "q" string.
|
| 186 |
+
- visited_sources: dedupe by exact "url".
|
| 187 |
+
- information_state: dedupe by exact "claim" string ACROSS ALL BUCKETS with priority:
|
| 188 |
+
trusted > untrusted > uncertain.
|
| 189 |
+
|
| 190 |
+
3) Output ONLY the JSON object. No markdown, no extra text.
|
| 191 |
+
|
| 192 |
+
Return ONLY the updated JSON object."""
|
| 193 |
+
|
| 194 |
+
|
| 195 |
def build_system_prompt() -> str:
|
| 196 |
return QUEST_SYSTEM_PROMPT + date.today().isoformat()
|
| 197 |
|
|
|
|
| 1543 |
return text[:max_chars]
|
| 1544 |
|
| 1545 |
|
| 1546 |
+
# ---------------------------------------------------------------------------
|
| 1547 |
+
# Secondary-LLM helpers (visit extractor, condenser State Summarizer, scholar).
|
| 1548 |
+
# Mirror inference/tool_visit.py + inference/tool_memory.py + inference/tool_scholar.py.
|
| 1549 |
+
# Each helper is best-effort: if the relevant env vars are missing it returns
|
| 1550 |
+
# None / falls through to the legacy behaviour so the Space still works.
|
| 1551 |
+
# ---------------------------------------------------------------------------
|
| 1552 |
+
|
| 1553 |
+
JINA_API_KEYS = os.getenv("JINA_API_KEYS", "").strip()
|
| 1554 |
+
WEBCONTENT_MAXLENGTH = int(os.getenv("WEBCONTENT_MAXLENGTH", "60000"))
|
| 1555 |
+
|
| 1556 |
+
SUMMARY_MODEL_NAME = os.getenv("SUMMARY_MODEL_NAME", "").strip()
|
| 1557 |
+
SUMMARY_API_KEY = (os.getenv("API_KEY") or os.getenv("SUMMARY_OPENAI_API_KEY") or "").strip()
|
| 1558 |
+
SUMMARY_API_BASE = (os.getenv("API_BASE") or os.getenv("SUMMARY_OPENAI_BASE_URL") or "").strip() or None
|
| 1559 |
+
|
| 1560 |
+
MEMORY_MODEL_NAME = os.getenv("MEMORY_MODEL_NAME", "").strip()
|
| 1561 |
+
MEMORY_API_KEY = (os.getenv("MEMORY_OPENAI_API_KEY") or SUMMARY_API_KEY).strip()
|
| 1562 |
+
MEMORY_API_BASE = (os.getenv("MEMORY_OPENAI_BASE_URL") or SUMMARY_API_BASE) or None
|
| 1563 |
+
MEMORY_TOKEN_THRESHOLD = int(
|
| 1564 |
+
os.getenv("MEMORY_THRESHOLD")
|
| 1565 |
+
or os.getenv("MEMORY_CONTEXT_THRESHOLD")
|
| 1566 |
+
or os.getenv("MEMORY_TOKEN_THRESHOLD")
|
| 1567 |
+
or "16000"
|
| 1568 |
+
)
|
| 1569 |
+
|
| 1570 |
+
|
| 1571 |
+
def _get_openai_client(api_key: str, base_url: Optional[str]):
|
| 1572 |
+
"""Lazy import so the Space still imports if `openai` isn't installed yet."""
|
| 1573 |
+
try:
|
| 1574 |
+
from openai import OpenAI
|
| 1575 |
+
except Exception:
|
| 1576 |
+
return None
|
| 1577 |
+
if not api_key:
|
| 1578 |
+
return None
|
| 1579 |
+
return OpenAI(api_key=api_key, base_url=base_url) if base_url else OpenAI(api_key=api_key)
|
| 1580 |
+
|
| 1581 |
+
|
| 1582 |
+
def _approx_token_count(text: str) -> int:
|
| 1583 |
+
"""Cheap token estimate (~4 chars/token). Tiktoken is heavy; this is fine
|
| 1584 |
+
for threshold gating where being off by 20% is harmless."""
|
| 1585 |
+
try:
|
| 1586 |
+
import tiktoken
|
| 1587 |
+
return len(tiktoken.get_encoding("cl100k_base").encode(text))
|
| 1588 |
+
except Exception:
|
| 1589 |
+
return max(1, len(text) // 4)
|
| 1590 |
+
|
| 1591 |
+
|
| 1592 |
+
def _messages_token_count(messages: List[Dict[str, str]]) -> int:
|
| 1593 |
+
return sum(_approx_token_count(str(m.get("content", ""))) for m in messages)
|
| 1594 |
+
|
| 1595 |
+
|
| 1596 |
+
def _jina_readpage(url: str) -> Optional[str]:
|
| 1597 |
+
"""Fetch a page via Jina Reader (r.jina.ai). Returns markdown text on
|
| 1598 |
+
success, None on failure (caller falls back to BeautifulSoup)."""
|
| 1599 |
+
if not JINA_API_KEYS:
|
| 1600 |
+
return None
|
| 1601 |
+
headers = {"Authorization": f"Bearer {JINA_API_KEYS}"}
|
| 1602 |
+
for attempt in range(3):
|
| 1603 |
+
try:
|
| 1604 |
+
r = requests.get(f"https://r.jina.ai/{url}", headers=headers, timeout=50)
|
| 1605 |
+
if r.status_code == 200 and r.text:
|
| 1606 |
+
return r.text[:WEBCONTENT_MAXLENGTH]
|
| 1607 |
+
except Exception:
|
| 1608 |
+
if attempt == 2:
|
| 1609 |
+
return None
|
| 1610 |
+
return None
|
| 1611 |
+
|
| 1612 |
+
|
| 1613 |
+
def _llm_extract(webpage_content: str, goal: str) -> Optional[str]:
|
| 1614 |
+
"""Run the SUMMARY model as the visit extractor. Mirrors
|
| 1615 |
+
inference/prompt.py:build_visit_extractor_messages + tool_visit's call."""
|
| 1616 |
+
client = _get_openai_client(SUMMARY_API_KEY, SUMMARY_API_BASE)
|
| 1617 |
+
if client is None or not SUMMARY_MODEL_NAME:
|
| 1618 |
+
return None
|
| 1619 |
+
try:
|
| 1620 |
+
resp = client.chat.completions.create(
|
| 1621 |
+
model=SUMMARY_MODEL_NAME,
|
| 1622 |
+
messages=[
|
| 1623 |
+
{
|
| 1624 |
+
"role": "user",
|
| 1625 |
+
"content": EXTRACTOR_PROMPT.format(
|
| 1626 |
+
webpage_content=webpage_content, goal=goal or "general overview"
|
| 1627 |
+
),
|
| 1628 |
+
}
|
| 1629 |
+
],
|
| 1630 |
+
timeout=120,
|
| 1631 |
+
)
|
| 1632 |
+
return (resp.choices[0].message.content or "").strip() or None
|
| 1633 |
+
except Exception:
|
| 1634 |
+
return None
|
| 1635 |
+
|
| 1636 |
+
|
| 1637 |
+
def _llm_condense(events_text: str, prev_state: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
| 1638 |
+
"""Run the MEMORY model as the State Summarizer. Returns a parsed JSON
|
| 1639 |
+
state dict, or None if condensation failed."""
|
| 1640 |
+
client = _get_openai_client(MEMORY_API_KEY, MEMORY_API_BASE)
|
| 1641 |
+
if client is None or not MEMORY_MODEL_NAME:
|
| 1642 |
+
return None
|
| 1643 |
+
user_payload = json.dumps(
|
| 1644 |
+
{
|
| 1645 |
+
"events": events_text[-30000:], # cap input
|
| 1646 |
+
"prev_state": prev_state or None,
|
| 1647 |
+
},
|
| 1648 |
+
ensure_ascii=False,
|
| 1649 |
+
)
|
| 1650 |
+
try:
|
| 1651 |
+
resp = client.chat.completions.create(
|
| 1652 |
+
model=MEMORY_MODEL_NAME,
|
| 1653 |
+
messages=[
|
| 1654 |
+
{"role": "system", "content": MEMORY_SYSTEM_PROMPT},
|
| 1655 |
+
{"role": "user", "content": user_payload},
|
| 1656 |
+
],
|
| 1657 |
+
timeout=180,
|
| 1658 |
+
)
|
| 1659 |
+
raw = (resp.choices[0].message.content or "").strip()
|
| 1660 |
+
# the prompt says no code fences, but be defensive anyway
|
| 1661 |
+
if raw.startswith("```"):
|
| 1662 |
+
raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.DOTALL)
|
| 1663 |
+
return json.loads(raw)
|
| 1664 |
+
except Exception:
|
| 1665 |
+
return None
|
| 1666 |
+
|
| 1667 |
+
|
| 1668 |
+
def _run_scholar_single(query: str) -> Dict[str, Any]:
|
| 1669 |
+
"""Google Scholar via Serper. Mirrors inference/tool_scholar.py."""
|
| 1670 |
+
q = (query or "").strip()
|
| 1671 |
+
if not q:
|
| 1672 |
+
return {"ok": False, "error": "Scholar query cannot be empty."}
|
| 1673 |
+
if not SERPER_API_KEY:
|
| 1674 |
+
return {
|
| 1675 |
+
"ok": False,
|
| 1676 |
+
"query": q,
|
| 1677 |
+
"error": "SERPER_API_KEY missing β scholar tool unavailable.",
|
| 1678 |
+
}
|
| 1679 |
+
headers = {"X-API-KEY": SERPER_API_KEY, "Content-Type": "application/json"}
|
| 1680 |
+
payload = json.dumps({"q": q})
|
| 1681 |
+
last_err: Optional[str] = None
|
| 1682 |
+
for _ in range(3):
|
| 1683 |
+
try:
|
| 1684 |
+
r = requests.post(
|
| 1685 |
+
"https://google.serper.dev/scholar",
|
| 1686 |
+
data=payload,
|
| 1687 |
+
headers=headers,
|
| 1688 |
+
timeout=20,
|
| 1689 |
+
)
|
| 1690 |
+
if r.status_code == 200:
|
| 1691 |
+
data = r.json()
|
| 1692 |
+
rows = []
|
| 1693 |
+
for page in data.get("organic", []) or []:
|
| 1694 |
+
rows.append(
|
| 1695 |
+
{
|
| 1696 |
+
"title": page.get("title", ""),
|
| 1697 |
+
"link": page.get("link", ""),
|
| 1698 |
+
"year": page.get("year"),
|
| 1699 |
+
"publicationInfo": page.get("publicationInfo"),
|
| 1700 |
+
"snippet": page.get("snippet", ""),
|
| 1701 |
+
"citedBy": page.get("citedBy"),
|
| 1702 |
+
}
|
| 1703 |
+
)
|
| 1704 |
+
return {"ok": True, "query": q, "results": rows, "backend": "serper-scholar"}
|
| 1705 |
+
last_err = f"HTTP {r.status_code}: {r.text[:200]}"
|
| 1706 |
+
except Exception as exc:
|
| 1707 |
+
last_err = f"{type(exc).__name__}: {exc}"
|
| 1708 |
+
return {"ok": False, "query": q, "error": f"Serper scholar failed ({last_err})."}
|
| 1709 |
+
|
| 1710 |
+
|
| 1711 |
def _run_visit_single(url: str, max_chars: int, goal: str = "") -> Dict[str, Any]:
|
| 1712 |
if not url.strip():
|
| 1713 |
return {"ok": False, "error": "URL cannot be empty."}
|
| 1714 |
+
cache_key = f"{url.strip()}::{max_chars}::{goal[:60]}"
|
| 1715 |
if cache_key in VISIT_CACHE:
|
| 1716 |
return {**VISIT_CACHE[cache_key], "cached": True, "goal": goal}
|
| 1717 |
+
|
| 1718 |
+
# Preferred path: Jina Reader for clean markdown β LLM extractor distils
|
| 1719 |
+
# the page content against the requested goal. Matches the research repo's
|
| 1720 |
+
# inference/tool_visit.py behaviour. Either step failing falls through to
|
| 1721 |
+
# the legacy requests + BeautifulSoup path.
|
| 1722 |
+
jina_md = _jina_readpage(url)
|
| 1723 |
+
if jina_md:
|
| 1724 |
+
extract = _llm_extract(jina_md, goal) if SUMMARY_MODEL_NAME else None
|
| 1725 |
+
result = {
|
| 1726 |
+
"ok": True,
|
| 1727 |
+
"url": url,
|
| 1728 |
+
"goal": goal,
|
| 1729 |
+
"content": (extract or jina_md)[:max_chars],
|
| 1730 |
+
"extractor": "llm" if extract else "jina-raw",
|
| 1731 |
+
}
|
| 1732 |
+
VISIT_CACHE[cache_key] = result
|
| 1733 |
+
return result
|
| 1734 |
+
|
| 1735 |
try:
|
| 1736 |
resp = requests.get(
|
| 1737 |
url,
|
|
|
|
| 1943 |
]
|
| 1944 |
|
| 1945 |
final_answer: Optional[str] = None
|
| 1946 |
+
# `prev_state` holds the JSON returned by the State Summarizer LLM. It is
|
| 1947 |
+
# refreshed each time the context tokens cross MEMORY_TOKEN_THRESHOLD and
|
| 1948 |
+
# then injected into the model's next user message as a RESEARCH STATE
|
| 1949 |
+
# SUMMARY block. Matches inference/react_agent.py + inference/tool_memory.py
|
| 1950 |
+
# behaviour.
|
| 1951 |
+
prev_state: Optional[Dict[str, Any]] = None
|
| 1952 |
+
condenser_runs = 0
|
| 1953 |
|
| 1954 |
status_lines.append("π Starting research agent")
|
| 1955 |
yield _emit()
|
|
|
|
| 1959 |
|
| 1960 |
for turn in range(1, max_turns + 1):
|
| 1961 |
_apply_memory_strategy(messages, strategy, turn)
|
| 1962 |
+
# Real LLM-based condenser: when tokens cross the threshold, call the
|
| 1963 |
+
# MEMORY model to produce the structured state JSON, then rebuild the
|
| 1964 |
+
# context as [system, original_question, RESEARCH_STATE_SUMMARY].
|
| 1965 |
+
if (
|
| 1966 |
+
strategy == "condenser"
|
| 1967 |
+
and MEMORY_MODEL_NAME
|
| 1968 |
+
and MEMORY_API_KEY
|
| 1969 |
+
and turn > 1
|
| 1970 |
+
and _messages_token_count(messages) > MEMORY_TOKEN_THRESHOLD
|
| 1971 |
+
):
|
| 1972 |
+
status_lines.append(
|
| 1973 |
+
f"ποΈ turn {turn}: condensing context (tokens > {MEMORY_TOKEN_THRESHOLD})"
|
| 1974 |
+
)
|
| 1975 |
+
yield _emit()
|
| 1976 |
+
events_text = "\n\n".join(
|
| 1977 |
+
f"[{m.get('role')}] {str(m.get('content',''))[:2000]}"
|
| 1978 |
+
for m in messages[2:] # skip system + original question
|
| 1979 |
+
)
|
| 1980 |
+
new_state = _llm_condense(events_text, prev_state)
|
| 1981 |
+
if new_state:
|
| 1982 |
+
prev_state = new_state
|
| 1983 |
+
condenser_runs += 1
|
| 1984 |
+
state.trace.append(
|
| 1985 |
+
{"turn": turn, "condenser_run": condenser_runs, "prev_state": prev_state}
|
| 1986 |
+
)
|
| 1987 |
+
# Reset history to system + question + state summary
|
| 1988 |
+
summary_block = (
|
| 1989 |
+
"RESEARCH STATE SUMMARY (prev_state)\n"
|
| 1990 |
+
+ json.dumps(prev_state, ensure_ascii=False, indent=2)
|
| 1991 |
+
+ "\n\nUse this summary to avoid redundant work and "
|
| 1992 |
+
"follow `information_state.uncertain.need` for next steps."
|
| 1993 |
+
)
|
| 1994 |
+
messages[:] = [messages[0], messages[1], {"role": "user", "content": summary_block}]
|
| 1995 |
+
status_lines[-1] = (
|
| 1996 |
+
f"ποΈ turn {turn}: condensed β "
|
| 1997 |
+
f"{len(prev_state.get('information_state', {}).get('trusted', []))} trusted, "
|
| 1998 |
+
f"{len(prev_state.get('information_state', {}).get('uncertain', []))} uncertain"
|
| 1999 |
+
)
|
| 2000 |
+
yield _emit()
|
| 2001 |
+
elif (
|
| 2002 |
+
strategy == "condenser"
|
| 2003 |
+
and (not MEMORY_MODEL_NAME or not MEMORY_API_KEY)
|
| 2004 |
+
and state.trusted_notes
|
| 2005 |
+
and turn > 1
|
| 2006 |
+
and turn % 3 == 0
|
| 2007 |
+
):
|
| 2008 |
+
# Fallback heuristic when the MEMORY model is not configured.
|
| 2009 |
summary_lines = "\n".join(f"- {n}" for n in state.trusted_notes[-6:])
|
| 2010 |
messages.append(
|
| 2011 |
{
|
|
|
|
| 2178 |
f"β
turn {turn}: read {visit_ok}/{len(urls)} page(s)"
|
| 2179 |
)
|
| 2180 |
yield _emit()
|
| 2181 |
+
elif tool_name in ("google_scholar", "scholar"):
|
| 2182 |
+
raw_query = tool_args.get("query", "")
|
| 2183 |
+
queries: List[str]
|
| 2184 |
+
if isinstance(raw_query, list):
|
| 2185 |
+
queries = [str(q).strip() for q in raw_query if str(q).strip()]
|
| 2186 |
+
else:
|
| 2187 |
+
queries = [str(raw_query).strip()] if str(raw_query).strip() else []
|
| 2188 |
+
queries_preview = ", ".join(f"`{q}`" for q in queries) or "_(empty)_"
|
| 2189 |
+
status_lines.append(f"π turn {turn}: scholar {queries_preview}")
|
| 2190 |
+
yield _emit()
|
| 2191 |
+
per_q = [_run_scholar_single(q) for q in queries]
|
| 2192 |
+
tool_response = (
|
| 2193 |
+
per_q[0] if len(per_q) == 1 else {"ok": True, "results": per_q}
|
| 2194 |
+
)
|
| 2195 |
+
ok_count = sum(1 for r in per_q if r.get("ok"))
|
| 2196 |
+
status_lines.append(
|
| 2197 |
+
f"π turn {turn}: scholar {ok_count}/{len(per_q)} ok"
|
| 2198 |
+
)
|
| 2199 |
+
yield _emit()
|
| 2200 |
else:
|
| 2201 |
tool_response = {"ok": False, "error": f"Unknown tool: {tool_name}"}
|
| 2202 |
status_lines.append(f"β οΈ turn {turn}: unknown tool `{tool_name}`")
|
|
@@ -3,3 +3,5 @@ huggingface_hub==0.31.2
|
|
| 3 |
duckduckgo_search==8.0.1
|
| 4 |
requests==2.32.3
|
| 5 |
beautifulsoup4==4.12.3
|
|
|
|
|
|
|
|
|
| 3 |
duckduckgo_search==8.0.1
|
| 4 |
requests==2.32.3
|
| 5 |
beautifulsoup4==4.12.3
|
| 6 |
+
openai>=1.40.0
|
| 7 |
+
tiktoken>=0.7.0
|