unique_deep_research 2026.32.0.dev0__tar.gz → 2026.32.0.dev2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/PKG-INFO +3 -3
  2. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/pyproject.toml +3 -3
  3. unique_deep_research-2026.32.0.dev2/unique_deep_research/invocation_stats.py +84 -0
  4. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/service.py +115 -38
  5. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/unique_custom/agents.py +20 -5
  6. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/unique_custom/utils.py +12 -1
  7. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/README.md +0 -0
  8. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/__init__.py +0 -0
  9. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/config.py +0 -0
  10. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/markdown_utils.py +0 -0
  11. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/templates/clarifying_agent.j2 +0 -0
  12. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/templates/openai/oai_research_system_message.j2 +0 -0
  13. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/templates/report_cleanup_prompt.j2 +0 -0
  14. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/templates/research_instructions_agent.j2 +0 -0
  15. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/templates/unique/compress_research_system.j2 +0 -0
  16. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/templates/unique/lead_agent_system.j2 +0 -0
  17. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/templates/unique/report_writer_system_open_deep_research.j2 +0 -0
  18. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/templates/unique/research_agent_system.j2 +0 -0
  19. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/unique_custom/__init__.py +0 -0
  20. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/unique_custom/citation.py +0 -0
  21. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/unique_custom/state.py +0 -0
  22. {unique_deep_research-2026.32.0.dev0 → unique_deep_research-2026.32.0.dev2}/unique_deep_research/unique_custom/tools.py +0 -0
@@ -1,18 +1,18 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-deep-research
3
- Version: 2026.32.0.dev0
3
+ Version: 2026.32.0.dev2
4
4
  Summary: Deep Research Tool for complex research tasks
5
5
  Author: Martin Fadler, Sadique Sheik, Fabian Schläpfer, Pascal Hauri
6
6
  Author-email: Martin Fadler <martin.fadler@unique.ch>, Sadique Sheik <sadique@unique.ch>, Fabian Schläpfer <fabian@unique.ch>, Pascal Hauri <pascal@unique.ch>
7
7
  License: Proprietary
8
8
  Requires-Dist: pydantic>=2.8.2,<3
9
9
  Requires-Dist: typing-extensions>=4.9.0,<5
10
- Requires-Dist: unique-toolkit>=2026.32.0.dev0,<2026.32.0rc0
10
+ Requires-Dist: unique-toolkit>=2026.32.0.dev9,<2026.32.0rc0
11
11
  Requires-Dist: jinja2>=3.1.2,<4
12
12
  Requires-Dist: openai>=1.99.0,<3
13
13
  Requires-Dist: langgraph>=1.0.0,<2
14
14
  Requires-Dist: langchain-core>=1.0.0,<2
15
- Requires-Dist: unique-web-search>=2026.32.0.dev0,<2026.32.0rc0
15
+ Requires-Dist: unique-web-search>=2026.32.0.dev3,<2026.32.0rc0
16
16
  Requires-Dist: httpx>=0.28.0,<0.29
17
17
  Requires-Dist: timeout-decorator>=0.5.0,<0.6
18
18
  Requires-Dist: beautifulsoup4>=4.12.0,<5
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_deep_research"
3
- version = "2026.32.0.dev0"
3
+ version = "2026.32.0.dev2"
4
4
  description = "Deep Research Tool for complex research tasks"
5
5
  readme = "README.md"
6
6
  license = { text = "Proprietary" }
@@ -14,12 +14,12 @@ requires-python = ">=3.12,<4"
14
14
  dependencies = [
15
15
  "pydantic>=2.8.2,<3",
16
16
  "typing-extensions>=4.9.0,<5",
17
- "unique-toolkit>=2026.32.0.dev0,<2026.32.0rc0",
17
+ "unique-toolkit>=2026.32.0.dev9,<2026.32.0rc0",
18
18
  "jinja2>=3.1.2,<4",
19
19
  "openai>=1.99.0,<3",
20
20
  "langgraph>=1.0.0,<2",
21
21
  "langchain-core>=1.0.0,<2",
22
- "unique-web-search>=2026.32.0.dev0,<2026.32.0rc0",
22
+ "unique-web-search>=2026.32.0.dev3,<2026.32.0rc0",
23
23
  "httpx>=0.28.0,<0.29",
24
24
  "timeout-decorator>=0.5.0,<0.6",
25
25
  "beautifulsoup4>=4.12.0,<5",
@@ -0,0 +1,84 @@
1
+ """Run-scoped collection of Deep Research language-model usage."""
2
+
3
+ import logging
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar, Token
7
+ from typing import Any
8
+
9
+ from unique_toolkit.language_model.invocation_stats import LanguageModelInvocationStats
10
+ from unique_toolkit.language_model.schemas import LanguageModelTokenUsage
11
+
12
+ _LOGGER = logging.getLogger(__name__)
13
+
14
+ _CURRENT_INVOCATION_STATS: ContextVar[list[LanguageModelInvocationStats] | None] = (
15
+ ContextVar("deep_research_invocation_stats", default=None)
16
+ )
17
+
18
+
19
+ @contextmanager
20
+ def invocation_stats_scope() -> Iterator[list[LanguageModelInvocationStats]]:
21
+ """Collect stats for one Deep Research run and isolate concurrent runs."""
22
+ invocation_stats: list[LanguageModelInvocationStats] = []
23
+ token: Token[list[LanguageModelInvocationStats] | None] = (
24
+ _CURRENT_INVOCATION_STATS.set(invocation_stats)
25
+ )
26
+ try:
27
+ yield invocation_stats
28
+ finally:
29
+ _CURRENT_INVOCATION_STATS.reset(token)
30
+
31
+
32
+ def record_token_usage(
33
+ *,
34
+ model_name: str,
35
+ usage: Any,
36
+ source: str,
37
+ ) -> None:
38
+ """Record provider usage when both a run scope and usage are available."""
39
+ invocation_stats = _CURRENT_INVOCATION_STATS.get()
40
+ if invocation_stats is None or usage is None:
41
+ return
42
+
43
+ try:
44
+ token_usage = LanguageModelTokenUsage.model_validate(usage)
45
+ invocation_stats.append(
46
+ LanguageModelInvocationStats.from_usage(
47
+ model_name=model_name,
48
+ token_usage=token_usage,
49
+ source=source,
50
+ )
51
+ )
52
+ except Exception:
53
+ _LOGGER.warning(
54
+ "Unable to parse Deep Research token usage for %s",
55
+ source,
56
+ exc_info=True,
57
+ )
58
+
59
+
60
+ def record_invocation_stats(
61
+ invocations: list[LanguageModelInvocationStats],
62
+ ) -> None:
63
+ """Add invocation stats collected by a nested Deep Research dependency."""
64
+ invocation_stats = _CURRENT_INVOCATION_STATS.get()
65
+ if invocation_stats is not None:
66
+ invocation_stats.extend(invocations)
67
+
68
+
69
+ def record_language_model_response(
70
+ *,
71
+ model_name: str,
72
+ response: Any,
73
+ source: str,
74
+ ) -> None:
75
+ """Record usage from OpenAI SDK, toolkit, or LangChain responses."""
76
+ usage = getattr(response, "usage", None)
77
+ if usage is None:
78
+ usage = getattr(response, "usage_metadata", None)
79
+ if usage is None:
80
+ response_metadata = getattr(response, "response_metadata", None)
81
+ if isinstance(response_metadata, dict):
82
+ usage = response_metadata.get("token_usage")
83
+
84
+ record_token_usage(model_name=model_name, usage=usage, source=source)
@@ -50,6 +50,9 @@ from unique_toolkit.language_model.schemas import (
50
50
  LanguageModelMessageRole,
51
51
  )
52
52
  from unique_toolkit.short_term_memory.service import ShortTermMemoryService
53
+ from unique_web_search.invocation_stats import (
54
+ invocation_stats_scope as web_search_invocation_stats_scope,
55
+ )
53
56
 
54
57
  from unique_deep_research.unique_custom.tools import crawl_url, get_today_str
55
58
 
@@ -60,6 +63,12 @@ from .config import (
60
63
  DeepResearchToolConfig,
61
64
  UniqueEngine,
62
65
  )
66
+ from .invocation_stats import (
67
+ invocation_stats_scope,
68
+ record_invocation_stats,
69
+ record_language_model_response,
70
+ record_token_usage,
71
+ )
63
72
  from .markdown_utils import (
64
73
  export_report_to_docx,
65
74
  postprocess_research_result_with_chunks,
@@ -261,39 +270,65 @@ class DeepResearchTool(Tool[DeepResearchToolConfig]):
261
270
  )
262
271
 
263
272
  async def run(self, tool_call: LanguageModelFunction) -> ToolCallResponse:
264
- sub = self.chat_service.cancellation.on_cancellation.subscribe(
265
- self._on_cancellation
266
- )
267
- try:
268
- return await self._run(tool_call)
269
- except Exception as e:
270
- self.write_message_log_text_message(
271
- "**Research failed for an unknown reason**"
273
+ with invocation_stats_scope() as invocation_stats:
274
+ sub = self.chat_service.cancellation.on_cancellation.subscribe(
275
+ self._on_cancellation
272
276
  )
273
- if self.is_message_execution():
274
- await self._update_execution_status(MessageExecutionUpdateStatus.FAILED)
277
+ try:
278
+ response = await self._run(tool_call)
279
+ response.invocation_stats.extend(invocation_stats)
280
+ return response
281
+ except Exception as e:
282
+ _LOGGER.exception("Deep Research tool run failed")
283
+
284
+ # The failure-handling below issues several awaits (status
285
+ # update, debugInfo read/write, message edit); if any of them
286
+ # raises it must not escape and take the tokens already spent
287
+ # this run down with it -- the finally block still returns a
288
+ # stats-bearing error response so analytics stay accurate.
289
+ try:
290
+ self.write_message_log_text_message(
291
+ "**Research failed for an unknown reason**"
292
+ )
293
+ if self.is_message_execution():
294
+ await self._update_execution_status(
295
+ MessageExecutionUpdateStatus.FAILED
296
+ )
275
297
 
276
- _LOGGER.exception(f"Deep Research tool run failed: {e}")
298
+ # update_debug_info_async replaces the message's debugInfo
299
+ # wholesale (see ChatService.update_debug_info_async /
300
+ # replace_debug_info), so the existing debugInfo must be read
301
+ # and merged in first -- otherwise this write wipes out
302
+ # `llm_invocations` persisted from an earlier turn of this same
303
+ # research (e.g. the clarification round), and UniqueAI's later
304
+ # merge (unique_ai.py) reads back an empty prior list.
305
+ existing_debug_info = await self.chat_service.get_debug_info_async()
306
+ debug_info = {
307
+ **existing_debug_info,
308
+ **self._get_tool_debug_info(),
309
+ "error": str(e),
310
+ "traceback": traceback.format_exc(),
311
+ }
312
+ await self.chat_service.update_debug_info_async(debug_info)
313
+ await self.chat_service.modify_assistant_message_async(
314
+ content="Deep Research failed to complete for an unknown reason",
315
+ set_completed_at=True,
316
+ )
317
+ except Exception:
318
+ _LOGGER.exception(
319
+ "Failed to persist Deep Research failure state; "
320
+ "returning collected invocation stats anyway"
321
+ )
322
+ finally:
323
+ sub.cancel()
277
324
 
278
- debug_info = {
279
- **self._get_tool_debug_info(),
280
- "error": str(e),
281
- "traceback": traceback.format_exc(),
282
- }
283
- await self.chat_service.update_debug_info_async(debug_info)
284
- await self.chat_service.modify_assistant_message_async(
285
- content="Deep Research failed to complete for an unknown reason",
286
- set_completed_at=True,
325
+ return ToolCallResponse(
326
+ id=tool_call.id or "",
327
+ name=self.name,
328
+ content="Failed to complete research",
329
+ error_message="Research process failed or returned empty results",
330
+ invocation_stats=invocation_stats,
287
331
  )
288
- finally:
289
- sub.cancel()
290
-
291
- return ToolCallResponse(
292
- id=tool_call.id or "",
293
- name=self.name,
294
- content="Failed to complete research",
295
- error_message="Research process failed or returned empty results",
296
- )
297
332
 
298
333
  async def _run(self, tool_call: LanguageModelFunction) -> ToolCallResponse:
299
334
  _LOGGER.info("Starting Deep Research tool run")
@@ -337,11 +372,25 @@ class DeepResearchTool(Tool[DeepResearchToolConfig]):
337
372
  if processed_result == "":
338
373
  raise ValueError("Research returned empty result")
339
374
 
340
- await self.chat_service.modify_assistant_message_async(
341
- set_completed_at=True,
342
- )
343
-
344
- await self._update_execution_status(MessageExecutionUpdateStatus.COMPLETED)
375
+ # The real report is already persisted by run_research() above; these
376
+ # two calls are trailing bookkeeping (completed-flag, execution status).
377
+ # If either raises, run()'s except handler would otherwise treat the
378
+ # whole turn as failed and overwrite the already-correct report with a
379
+ # generic failure message -- swallow here instead so a bookkeeping
380
+ # hiccup can't destroy a successful research result.
381
+ try:
382
+ await self.chat_service.modify_assistant_message_async(
383
+ set_completed_at=True,
384
+ )
385
+ await self._update_execution_status(
386
+ MessageExecutionUpdateStatus.COMPLETED
387
+ )
388
+ except Exception:
389
+ _LOGGER.warning(
390
+ "Failed to finalize Deep Research completion bookkeeping "
391
+ "after a successful research result",
392
+ exc_info=True,
393
+ )
345
394
 
346
395
  return ToolCallResponse(
347
396
  id=tool_call.id or "",
@@ -506,10 +555,18 @@ class DeepResearchTool(Tool[DeepResearchToolConfig]):
506
555
  },
507
556
  }
508
557
 
509
- result = await self.chat_service.cancellation.run_with_cancellation(
510
- custom_agent.ainvoke(initial_state, config=config), # type: ignore[arg-type]
511
- cancel_result={"final_report": ""},
512
- )
558
+ with web_search_invocation_stats_scope() as web_search_invocation_stats:
559
+ try:
560
+ result = await self.chat_service.cancellation.run_with_cancellation(
561
+ custom_agent.ainvoke(initial_state, config=config), # type: ignore[arg-type]
562
+ cancel_result={"final_report": ""},
563
+ )
564
+ finally:
565
+ # web_search_invocation_stats is populated live as nested web-search
566
+ # calls happen during ainvoke(); merge it into the outer Deep Research
567
+ # scope even if the graph raises, so tokens already spent before the
568
+ # failure aren't dropped from the failure ToolCallResponse.
569
+ record_invocation_stats(web_search_invocation_stats)
513
570
 
514
571
  research_result = result.get("final_report", "")
515
572
 
@@ -634,6 +691,11 @@ class DeepResearchTool(Tool[DeepResearchToolConfig]):
634
691
  _LOGGER.info(
635
692
  f"OpenAI research token usage: {event.response.usage}"
636
693
  )
694
+ record_token_usage(
695
+ model_name=self.config.engine.research_model.name,
696
+ usage=event.response.usage,
697
+ source="deep_research.openai_research",
698
+ )
637
699
  # Extract the final output with annotations
638
700
  if event.response.output and len(event.response.output) > 0:
639
701
  final_output = event.response.output[-1]
@@ -810,6 +872,11 @@ class DeepResearchTool(Tool[DeepResearchToolConfig]):
810
872
  temperature=0.1,
811
873
  max_tokens=5000,
812
874
  )
875
+ record_language_model_response(
876
+ model_name=self.config.engine.large_model.name,
877
+ response=response,
878
+ source="deep_research.report_cleanup",
879
+ )
813
880
 
814
881
  formatted_result = response.choices[0].message.content
815
882
  if formatted_result:
@@ -870,6 +937,11 @@ class DeepResearchTool(Tool[DeepResearchToolConfig]):
870
937
  model_name=self.config.engine.small_model.name,
871
938
  content_chunks=None,
872
939
  )
940
+ record_language_model_response(
941
+ model_name=self.config.engine.small_model.name,
942
+ response=response,
943
+ source="deep_research.clarification",
944
+ )
873
945
  assert isinstance(response.choices[0].message.content, str), (
874
946
  "No clarifying questions generated"
875
947
  )
@@ -897,6 +969,11 @@ class DeepResearchTool(Tool[DeepResearchToolConfig]):
897
969
  model=self.config.engine.large_model.name,
898
970
  messages=chat_messages,
899
971
  )
972
+ record_language_model_response(
973
+ model_name=self.config.engine.large_model.name,
974
+ response=research_response,
975
+ source="deep_research.research_brief",
976
+ )
900
977
 
901
978
  research_instructions = research_response.choices[0].message.content
902
979
  assert research_instructions, "No research instructions generated"
@@ -198,7 +198,10 @@ async def research_supervisor(
198
198
  supervisor_messages = state.get("supervisor_messages", [])
199
199
 
200
200
  response = await ainvoke_with_token_handling(
201
- research_model, supervisor_messages, model_info=engine_config.research_model
201
+ research_model,
202
+ supervisor_messages,
203
+ model_info=engine_config.research_model,
204
+ source="deep_research.supervisor",
202
205
  )
203
206
  if should_force_complete and not response.tool_calls:
204
207
  _LOGGER.error("Failed to force research_complete tool call")
@@ -347,7 +350,10 @@ async def researcher(
347
350
  researcher_messages = state.get("researcher_messages", [])
348
351
  messages = [SystemMessage(content=researcher_prompt)] + researcher_messages
349
352
  response = await ainvoke_with_token_handling(
350
- research_model, messages, model_info=engine_config.research_model
353
+ research_model,
354
+ messages,
355
+ model_info=engine_config.research_model,
356
+ source="deep_research.researcher",
351
357
  )
352
358
 
353
359
  return Command(
@@ -496,7 +502,10 @@ async def compress_research(
496
502
 
497
503
  # Use proactive token handling instead of retry logic
498
504
  response = await ainvoke_with_token_handling(
499
- compression_model, compression_messages, model_info=custom_config.large_model
505
+ compression_model,
506
+ compression_messages,
507
+ model_info=custom_config.large_model,
508
+ source="deep_research.research_compression",
500
509
  )
501
510
 
502
511
  compressed_research = (
@@ -549,7 +558,10 @@ async def final_report_generation(
549
558
  HumanMessage(content=message),
550
559
  ]
551
560
  raw_report = await ainvoke_with_token_handling(
552
- llm, messages, model_info=custom_config.large_model
561
+ llm,
562
+ messages,
563
+ model_info=custom_config.large_model,
564
+ source="deep_research.final_report",
553
565
  )
554
566
 
555
567
  refinement_prompt = TEMPLATE_ENV.get_template("report_cleanup_prompt.j2").render(
@@ -560,7 +572,10 @@ async def final_report_generation(
560
572
  HumanMessage(content=raw_report.content),
561
573
  ]
562
574
  refined_report = await ainvoke_with_token_handling(
563
- llm, messages, model_info=custom_config.large_model
575
+ llm,
576
+ messages,
577
+ model_info=custom_config.large_model,
578
+ source="deep_research.final_report_refinement",
564
579
  )
565
580
 
566
581
  # Return successful report generation
@@ -29,6 +29,7 @@ from unique_toolkit.content.service import ContentService
29
29
  from unique_toolkit.language_model.infos import LanguageModelInfo
30
30
 
31
31
  from ..config import UniqueEngine
32
+ from ..invocation_stats import record_language_model_response
32
33
  from .citation import GlobalCitationManager
33
34
  from .state import AgentState, ResearcherState, SupervisorState
34
35
 
@@ -533,6 +534,8 @@ async def ainvoke_with_token_handling(
533
534
  model: Runnable[List[BaseMessage], Any],
534
535
  messages: List[BaseMessage],
535
536
  model_info: LanguageModelInfo,
537
+ *,
538
+ source: str,
536
539
  ) -> Any:
537
540
  """
538
541
  Invoke model with proactive token filtering and retry logic with exponential backoff.
@@ -543,6 +546,7 @@ async def ainvoke_with_token_handling(
543
546
  model: The model to invoke
544
547
  messages: List of messages to send
545
548
  model_info: Model info for token limits
549
+ source: Stable identifier for the LLM call site
546
550
 
547
551
  Returns:
548
552
  Model response
@@ -556,7 +560,7 @@ async def ainvoke_with_token_handling(
556
560
 
557
561
  for attempt in range(max_retries + 1):
558
562
  try:
559
- return await model.ainvoke(prepared_messages)
563
+ response = await model.ainvoke(prepared_messages)
560
564
  except Exception as e:
561
565
  _LOGGER.exception(f"Error invoking model: {e}")
562
566
  # Handle token errors by truncating history and retrying
@@ -581,3 +585,10 @@ async def ainvoke_with_token_handling(
581
585
  f"Model invocation failed after {max_retries + 1} attempts: {str(e)}"
582
586
  )
583
587
  raise e
588
+ else:
589
+ record_language_model_response(
590
+ model_name=model_info.name,
591
+ response=response,
592
+ source=source,
593
+ )
594
+ return response