Lzy01241010 Claude Opus 4.7 commited on
Commit
0bfaafb
Β·
1 Parent(s): 70bbd7b

agent: wire research-repo Jina + LLM extractor + scholar + real condenser

Browse files

Brings 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>

Files changed (2) hide show
  1. app.py +384 -3
  2. requirements.txt +2 -0
app.py CHANGED
@@ -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 avoid repeating searches/visits that have already been executed, use verified information directly in your answer, and follow up on uncertain claims only when needed.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if strategy == "condenser" and state.trusted_notes and turn > 1 and turn % 3 == 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}`")
requirements.txt CHANGED
@@ -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