1+ import asyncio
12from typing import Any
23
4+ import tenacity
35from langchain .prompts import PromptTemplate
46from langchain .retrievers import ContextualCompressionRetriever
57from langchain .retrievers .document_compressors import LLMChainFilter
1214from langchain_experimental .utilities import PythonREPL
1315from langchain_openai import ChatOpenAI
1416from langchain_text_splitters import RecursiveCharacterTextSplitter
17+ from loguru import logger
18+ from openai import RateLimitError
1519
1620from apex .common .config import Config
1721from apex .services .deep_research .deep_research_base import DeepResearchBase
@@ -77,7 +81,7 @@ def __init__(
7781 openai_api_base = final_base_url if final_base_url is not None else base_url ,
7882 max_retries = 3 ,
7983 temperature = 0.01 ,
80- max_tokens = 1600 ,
84+ max_tokens = 2000 ,
8185 )
8286 # Caution: PythonREPL can execute arbitrary code on the host machine.
8387 # Use with caution and consider sandboxing for untrusted inputs.
@@ -118,7 +122,7 @@ def _create_research_chain(self) -> RunnableSerializable[dict[str, Any], str]:
118122- Executive Summary
119123- Key Findings
120124- Evidence (quote or paraphrase context with attributions)
121- - Limitations and Uncertainties
125+ - Limitations
122126- Conclusion
123127
124128Explain reasoning explicitly in prose. Prefer depth over breadth.
@@ -141,9 +145,9 @@ async def invoke(
141145
142146 # Seed notes with any provided documents
143147 notes : list [str ] = []
144- if body and "documents" in body and body [ "documents" ] :
148+ if body is not None and "documents" in body :
145149 for doc in body ["documents" ]:
146- if doc and "page_content" in doc and doc [ "page_content" ] is not None :
150+ if doc and doc . get ( "page_content" ) is not None :
147151 content = str (doc ["page_content" ])[:1000 ]
148152 notes .append (f"Provided document snippet: { content } " )
149153
@@ -155,79 +159,19 @@ async def invoke(
155159 collected_sources : list [dict [str , str ]] = []
156160 seen_urls : set [str ] = set ()
157161
158- def _render_sources (max_items : int = 12 ) -> str :
159- if not collected_sources :
160- return "(none)"
161- lines : list [str ] = []
162- for i , src in enumerate (collected_sources [:max_items ], start = 1 ):
163- title = src .get ("title" ) or "untitled"
164- url = src .get ("url" ) or ""
165- lines .append (f"[{ i } ] { title } - { url } " )
166- return "\n " .join (lines )
167-
168- def _render_notes (max_items : int = 8 ) -> str :
169- if not notes :
170- return "(none yet)"
171- clipped = notes [- max_items :]
172- return "\n " .join (f"- { item } " for item in clipped )
173-
174- def _build_agent_chain () -> RunnableSerializable [dict [str , Any ], str ]:
175- prompt = PromptTemplate (
176- input_variables = ["question" , "notes" , "sources" ],
177- template = (
178- "You are DeepResearcher, a meticulous, tool-using research agent.\n "
179- "You can use exactly these tools: websearch, python_repl.\n \n "
180- "Tool: websearch\n "
181- "- description: Search the web for relevant information.\n "
182- "- args: keys: 'query' (string), 'max_results' (integer <= 10)\n \n "
183- "Tool: python_repl\n "
184- "- description: A Python shell for executing Python commands.\n "
185- "- note: Print values to see output, e.g., `print(...)`.\n "
186- "- args: keys: 'code' (string: valid python command).\n \n "
187- "Follow an iterative think-act-observe loop. "
188- "Prefer rich internal reasoning over issuing many tool calls.\n "
189- "Spend time thinking: produce substantial, explicit reasoning in each 'thought'.\n "
190- "Avoid giving a final answer too early. Aim for at least 6 detailed thoughts before finalizing,\n "
191- "unless the question is truly trivial. "
192- "If no tool use is needed in a step, still provide a reflective 'thought'\n "
193- "that evaluates evidence, identifies gaps, and plans the next step.\n \n "
194- "Always respond in strict JSON. Use one of the two schemas:\n \n "
195- "1) Action step (JSON keys shown with dot-paths):\n "
196- "- thought: string\n "
197- "- action.tool: 'websearch' | 'python_repl'\n "
198- "- action.input: for websearch -> {{query: string, max_results: integer}}\n "
199- "- action.input: for python_repl -> {{code: string}}\n \n "
200- "2) Final answer step:\n "
201- "- thought: string\n "
202- "- final_answer: string\n \n "
203- "In every step, make 'thought' a detailed paragraph (120-200 words) that:\n "
204- "- Summarizes what is known and unknown so far\n "
205- "- Justifies the chosen next action or decision not to act\n "
206- "- Evaluates evidence quality and cites source numbers when applicable\n "
207- "- Identifies risks, uncertainties, and alternative hypotheses\n \n "
208- "Executive Summary, Key Findings, Evidence, Limitations, Conclusion.\n "
209- "Use inline numeric citations like [1], [2] that refer to Sources.\n "
210- "Include a final section titled 'Sources' listing the numbered citations.\n \n "
211- "Question:\n {question}\n \n "
212- "Notes and observations so far:\n {notes}\n \n "
213- "Sources (use these for citations):\n {sources}\n \n "
214- "Respond with JSON only."
215- ),
216- )
217- return prompt | self .research_model | StrOutputParser ()
218-
219- agent_chain = _build_agent_chain ()
162+ agent_chain = self ._build_agent_chain ()
220163
221164 while step_index < max_iterations :
165+ logger .debug (f"Starting deep researcher { step_index + 1 } /{ max_iterations } step" )
222166 step_index += 1
223- agent_output : str = await agent_chain .ainvoke (
167+ agent_output : str = await self ._try_invoke (
168+ agent_chain ,
224169 {
225170 "question" : question ,
226- "notes" : _render_notes (),
227- "sources" : _render_sources (),
228- }
171+ "notes" : self . _render_notes (notes = notes ),
172+ "sources" : self . _render_sources (collected_sources = collected_sources ),
173+ },
229174 )
230-
231175 parsed = self ._safe_parse_json (agent_output )
232176 if parsed is None :
233177 reasoning_traces .append (
@@ -246,6 +190,7 @@ def _build_agent_chain() -> RunnableSerializable[dict[str, Any], str]:
246190
247191 # Final answer branch
248192 if "final_answer" in parsed :
193+ logger .debug ("Early-stopping deep research due to the final answer" )
249194 final_answer = str (parsed .get ("final_answer" , "" ))
250195 reasoning_traces .append (
251196 {
@@ -274,7 +219,7 @@ def _build_agent_chain() -> RunnableSerializable[dict[str, Any], str]:
274219 observations : list [str ] = []
275220 for idx , website in enumerate (websites [:max_results ]):
276221 if website .content :
277- snippet = str (website .content )[:500 ]
222+ snippet = str (website .content )[:1000 ]
278223 observations .append (
279224 f"Result { idx + 1 } : { website .title or website .url or 'untitled' } \n { snippet } "
280225 )
@@ -304,8 +249,10 @@ def _build_agent_chain() -> RunnableSerializable[dict[str, Any], str]:
304249 continue
305250
306251 if action .get ("tool" ) == "python_repl" :
252+ logger .debug (f"Applying tool: { action .get ('tool' )} " )
307253 action_input = action .get ("input" ) or {}
308254 code = str (action_input .get ("code" , "" )).strip ()
255+ logger .debug (f"Code to be executed:\n { code } " )
309256 # Record the tool use (truncate long code for history)
310257 self .tool_history .append ({"tool" : "python_repl" , "args" : code [:200 ]})
311258
@@ -316,17 +263,19 @@ def _build_agent_chain() -> RunnableSerializable[dict[str, Any], str]:
316263 # PythonREPL returns only printed output (may include trailing newline)
317264 repl_output = self .python_repl .run (code )
318265 observation_text = repl_output if repl_output else "(no output)"
266+ logger .debug (f"Code execution result:\n { observation_text } " )
319267 except Exception as e : # noqa: BLE001
320268 observation_text = f"Error while executing code: { e } "
321269
322270 notes .append (f"Thought: { thought } " )
271+ logger .debug (f"Thought: { thought } " )
323272 notes .append (f"Observation from python_repl:\n { observation_text } " )
324273 reasoning_traces .append (
325274 {
326275 "step" : f"iteration-{ step_index } " ,
327276 "model" : getattr (self .research_model , "model_name" , "unknown" ),
328277 "thought" : thought ,
329- "action" : {"tool" : "python_repl" , "code" : code [:500 ]},
278+ "action" : {"tool" : "python_repl" , "code" : code [:1000 ]},
330279 "observation" : observation_text [:1000 ],
331280 }
332281 )
@@ -344,26 +293,33 @@ def _build_agent_chain() -> RunnableSerializable[dict[str, Any], str]:
344293 notes .append ("Agent returned an unsupported action. Use the websearch tool or provide final_answer." )
345294
346295 # Fallback: if loop ends without final answer, ask final model to synthesize from notes
296+ logger .debug ("Generating final answer" )
347297 final_prompt = PromptTemplate (
348298 input_variables = ["question" , "notes" , "sources" ],
349299 template = (
350- "You are a senior researcher. Write a research report with sections:\n "
300+ "You are a senior interdisciplinary researcher with expertise across "
301+ "science, technology, humanities, and social sciences.\n "
302+ "Provide report only in plain text using natural language.\n "
303+ "Write your response in the form of a well-structured research report with sections:\n "
351304 "Executive Summary, Key Findings, Evidence, Limitations, Conclusion.\n "
352305 "Use inline numeric citations like [1], [2] that refer to Sources.\n "
353306 "At the end, include a 'Sources' section listing the numbered citations.\n \n "
307+ "Do NOT use JSON, or any other structured data format.\n "
354308 "Question:\n {question}\n \n "
355309 "Notes:\n {notes}\n \n "
356310 "Sources:\n {sources}\n \n "
357311 "Research Report:"
358312 ),
359313 )
360314 final_chain = final_prompt | self .final_model | StrOutputParser ()
361- final_report : str = await final_chain .ainvoke (
315+
316+ final_report : str = await self ._try_invoke (
317+ final_chain ,
362318 {
363319 "question" : question ,
364- "notes" : _render_notes (12 ),
365- "sources" : _render_sources (20 ),
366- }
320+ "notes" : self . _render_notes (notes = notes , max_items = 12 ),
321+ "sources" : self . _render_sources (collected_sources = collected_sources , max_items = 20 ),
322+ },
367323 )
368324 reasoning_traces .append (
369325 {
@@ -374,6 +330,76 @@ def _build_agent_chain() -> RunnableSerializable[dict[str, Any], str]:
374330 )
375331 return final_report , self .tool_history , reasoning_traces
376332
333+ def _render_sources (self , collected_sources : list [dict [str , str ]], max_items : int = 12 ) -> str :
334+ if not collected_sources :
335+ return "(none)"
336+ lines : list [str ] = []
337+ for i , src in enumerate (collected_sources [:max_items ], start = 1 ):
338+ title = src .get ("title" ) or "untitled"
339+ url = src .get ("url" ) or ""
340+ lines .append (f"[{ i } ] { title } - { url } " )
341+ return "\n " .join (lines )
342+
343+ def _render_notes (self , notes : list [str ], max_items : int = 8 ) -> str :
344+ if not notes :
345+ return "(none yet)"
346+ clipped = notes [- max_items :]
347+ return "\n " .join (f"- { item } " for item in clipped )
348+
349+ def _build_agent_chain (self ) -> RunnableSerializable [dict [str , Any ], str ]:
350+ prompt = PromptTemplate (
351+ input_variables = ["question" , "notes" , "sources" ],
352+ template = (
353+ "You are DeepResearcher, a meticulous, tool-using research agent.\n "
354+ "You can use exactly these tools: websearch, python_repl.\n \n "
355+ "Tool: websearch\n "
356+ "- description: Search the web for relevant information.\n "
357+ "- args: keys: 'query' (string), 'max_results' (integer <= 10)\n \n "
358+ "Tool: python_repl\n "
359+ "- description: A Python shell for executing Python commands.\n "
360+ "- note: Print values to see output, e.g., `print(...)`.\n "
361+ "- args: keys: 'code' (string: valid python command).\n \n "
362+ "Follow an iterative think-act-observe loop. "
363+ "Prefer rich internal reasoning over issuing many tool calls.\n "
364+ "Spend time thinking: produce substantial, explicit reasoning in each 'thought'.\n "
365+ "Avoid giving a final answer too early. Aim for at least 6 detailed thoughts before finalizing,\n "
366+ "unless the question is truly trivial. "
367+ "If no tool use is needed in a step, still provide a reflective 'thought'\n "
368+ "that evaluates evidence, identifies gaps, and plans the next step.\n \n "
369+ "Always respond in strict JSON. Use one of the two schemas:\n \n "
370+ "1) Action step (JSON keys shown with dot-paths):\n "
371+ "- thought: string\n "
372+ "- action.tool: 'websearch' | 'python_repl'\n "
373+ "- action.input: for websearch -> {{query: string, max_results: integer}}\n "
374+ "- action.input: for python_repl -> {{code: string}}\n \n "
375+ "2) Final answer step:\n "
376+ "- thought: string\n "
377+ "- final_answer: string (use plain text for final answer, not a JSON)\n \n "
378+ "In every step, make 'thought' a detailed paragraph (120-200 words) that:\n "
379+ "- Summarizes what is known and unknown so far\n "
380+ "- Justifies the chosen next action or decision not to act\n "
381+ "- Evaluates evidence quality and cites source numbers when applicable\n "
382+ "- Identifies risks, uncertainties, and alternative hypotheses\n \n "
383+ "Executive Summary, Key Findings, Evidence, Limitations, Conclusion.\n "
384+ "Use inline numeric citations like [1], [2] that refer to Sources.\n "
385+ "Include a final section titled 'Sources' listing the numbered citations.\n \n "
386+ "Question:\n {question}\n \n "
387+ "Notes and observations so far:\n {notes}\n \n "
388+ "Sources (use these for citations):\n {sources}\n \n "
389+ "Respond with JSON always, except for final_anwer (use plain text)."
390+ ),
391+ )
392+ return prompt | self .research_model | StrOutputParser ()
393+
394+ @tenacity .retry (
395+ retry = tenacity .retry_if_exception_type (RateLimitError ),
396+ stop = tenacity .stop_after_attempt (5 ),
397+ wait = tenacity .wait_fixed (10 ),
398+ reraise = True ,
399+ )
400+ async def _try_invoke (self , chain : RunnableSerializable [dict [str , Any ], str ], inputs : dict [str , Any ]) -> str :
401+ return await chain .ainvoke (inputs )
402+
377403 def _safe_parse_json (self , text : str ) -> dict [str , Any ] | None :
378404 """Attempt to parse a JSON object from model output.
379405
0 commit comments