unique_orchestrator 2026.32.0.dev0__tar.gz → 2026.32.0.dev1__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 (34) hide show
  1. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/PKG-INFO +7 -7
  2. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/pyproject.toml +7 -7
  3. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_unique_ai_token_usage.py +333 -0
  4. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/unique_ai.py +131 -9
  5. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/README.md +0 -0
  6. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/__init__.py +0 -0
  7. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/_builders/__init__.py +0 -0
  8. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/_builders/history_manager.py +0 -0
  9. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/_builders/inject_tool_reminders.py +0 -0
  10. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/_builders/loop_iteration_runner.py +0 -0
  11. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/_builders/open_file_setup.py +0 -0
  12. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/_builders/skill_setup.py +0 -0
  13. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/config.py +0 -0
  14. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/prompts/generic_reference_prompt.jinja2 +0 -0
  15. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/prompts/system_prompt.jinja2 +0 -0
  16. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/prompts/user_message_prompt.jinja2 +0 -0
  17. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/settings.py +0 -0
  18. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_agent_config_rjsf_ui_schema.py +0 -0
  19. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_build_loop_iteration_runner.py +0 -0
  20. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_config_services_validators.py +0 -0
  21. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_qwen_max_loop_iterations.py +0 -0
  22. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_register_code_interpreter_postprocessors.py +0 -0
  23. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_unique_ai_async_modify.py +0 -0
  24. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_unique_ai_execution_timing.py +0 -0
  25. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_unique_ai_get_filtered_user_metadata.py +0 -0
  26. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_unique_ai_log_tool_calls.py +0 -0
  27. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_unique_ai_loop_debug_params.py +0 -0
  28. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_unique_ai_persist_tool_calls.py +0 -0
  29. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_unique_ai_plan_or_execute_include.py +0 -0
  30. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_unique_ai_reference_order.py +0 -0
  31. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_unique_ai_render_system_prompt_user_instructions.py +0 -0
  32. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/tests/test_utils_resolve_other_options.py +0 -0
  33. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/unique_ai_builder.py +0 -0
  34. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev1}/unique_orchestrator/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-orchestrator
3
- Version: 2026.32.0.dev0
3
+ Version: 2026.32.0.dev1
4
4
  Summary:
5
5
  Author: Andreas Hauri
6
6
  Author-email: Andreas Hauri <andreas.hauri@unique.ai>
@@ -9,15 +9,15 @@ Requires-Dist: pydantic>=2.8.2,<3
9
9
  Requires-Dist: pydantic-settings>=2.10.1,<3
10
10
  Requires-Dist: typing-extensions>=4.9.0,<5
11
11
  Requires-Dist: jinja2>=3.1.0,<4
12
- Requires-Dist: unique-toolkit>=2026.32.0.dev0,<2026.32.0rc0
13
- Requires-Dist: unique-stock-ticker>=2026.32.0.dev0,<2026.32.0rc0
12
+ Requires-Dist: unique-toolkit>=2026.32.0.dev4,<2026.32.0rc0
13
+ Requires-Dist: unique-stock-ticker>=2026.32.0.dev1,<2026.32.0rc0
14
14
  Requires-Dist: unique-follow-up-questions>=2026.32.0.dev0,<2026.32.0rc0
15
- Requires-Dist: unique-internal-search>=2026.32.0.dev0,<2026.32.0rc0
16
- Requires-Dist: unique-deep-research>=2026.32.0.dev0,<2026.32.0rc0
17
- Requires-Dist: unique-web-search>=2026.32.0.dev0,<2026.32.0rc0
15
+ Requires-Dist: unique-internal-search>=2026.32.0.dev1,<2026.32.0rc0
16
+ Requires-Dist: unique-deep-research>=2026.32.0.dev1,<2026.32.0rc0
17
+ Requires-Dist: unique-web-search>=2026.32.0.dev1,<2026.32.0rc0
18
18
  Requires-Dist: unique-swot>=2026.32.0.dev0,<2026.32.0rc0
19
19
  Requires-Dist: unique-skill-tool>=2026.32.0.dev0,<2026.32.0rc0
20
- Requires-Dist: unique-user-memory>=2026.32.0.dev0,<2026.32.0rc0
20
+ Requires-Dist: unique-user-memory>=2026.32.0.dev2,<2026.32.0rc0
21
21
  Requires-Dist: openai>=1.109.1,<3
22
22
  Requires-Python: >=3.12, <4
23
23
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_orchestrator"
3
- version = "2026.32.0.dev0"
3
+ version = "2026.32.0.dev1"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "Proprietary" }
@@ -13,15 +13,15 @@ dependencies = [
13
13
  "pydantic-settings>=2.10.1,<3",
14
14
  "typing-extensions>=4.9.0,<5",
15
15
  "jinja2>=3.1.0,<4",
16
- "unique-toolkit>=2026.32.0.dev0,<2026.32.0rc0",
17
- "unique-stock-ticker>=2026.32.0.dev0,<2026.32.0rc0",
16
+ "unique-toolkit>=2026.32.0.dev4,<2026.32.0rc0",
17
+ "unique-stock-ticker>=2026.32.0.dev1,<2026.32.0rc0",
18
18
  "unique-follow-up-questions>=2026.32.0.dev0,<2026.32.0rc0",
19
- "unique-internal-search>=2026.32.0.dev0,<2026.32.0rc0",
20
- "unique-deep-research>=2026.32.0.dev0,<2026.32.0rc0",
21
- "unique-web-search>=2026.32.0.dev0,<2026.32.0rc0",
19
+ "unique-internal-search>=2026.32.0.dev1,<2026.32.0rc0",
20
+ "unique-deep-research>=2026.32.0.dev1,<2026.32.0rc0",
21
+ "unique-web-search>=2026.32.0.dev1,<2026.32.0rc0",
22
22
  "unique-swot>=2026.32.0.dev0,<2026.32.0rc0",
23
23
  "unique-skill-tool>=2026.32.0.dev0,<2026.32.0rc0",
24
- "unique-user-memory>=2026.32.0.dev0,<2026.32.0rc0",
24
+ "unique-user-memory>=2026.32.0.dev2,<2026.32.0rc0",
25
25
  "openai>=1.109.1,<3",
26
26
  ]
27
27
 
@@ -1,6 +1,9 @@
1
1
  from unittest.mock import AsyncMock, MagicMock
2
2
 
3
3
  import pytest
4
+ from unique_toolkit.agentic.debug_info_manager.debug_info_manager import (
5
+ DebugInfoManager,
6
+ )
4
7
  from unique_toolkit.agentic.loop_runner import SupportsInvocationStats
5
8
  from unique_toolkit.agentic.tools.schemas import ToolCallResponse
6
9
  from unique_toolkit.language_model.invocation_stats import LanguageModelInvocationStats
@@ -73,6 +76,10 @@ def _build_run_ua(loop_responses: list[MagicMock], max_iterations: int):
73
76
  mock_postprocessor_manager.run_postprocessors = AsyncMock(return_value=None)
74
77
  mock_postprocessor_manager.get_execution_times.return_value = {}
75
78
  mock_postprocessor_manager.get_invocation_stats.return_value = []
79
+ # A fresh list per call: `.return_value = []` would hand back the *same*
80
+ # list object every time, so mutations from one `ua.run()` (e.g. the
81
+ # main-loop usage append) would leak into the next run's list.
82
+ mock_postprocessor_manager.take_pending_invocation_stats.side_effect = lambda: []
76
83
 
77
84
  mock_evaluation_manager = MagicMock()
78
85
  mock_evaluation_manager.run_evaluations = AsyncMock(return_value=[])
@@ -251,6 +258,203 @@ class TestRunTokenUsageIntegration:
251
258
  assert "llm_invocations" in calls_by_key
252
259
  assert calls_by_key["llm_invocations"] == []
253
260
 
261
+ @pytest.mark.ai
262
+ @pytest.mark.asyncio
263
+ async def test_run__cancelled_before_postprocessing__keeps_invocations_incomplete(
264
+ self,
265
+ ):
266
+ """
267
+ Purpose: Verify cancellation after the model call leaves invocation usage partial.
268
+ Why this matters: Postprocessor and evaluation usage has not been aggregated yet.
269
+ Setup summary: Cancel after planning, then assert the completion flag remains false.
270
+ """
271
+ # Arrange
272
+ ua = _build_run_ua([_make_loop_response(None)], max_iterations=1)
273
+ ua._chat_service.cancellation.check_cancellation_async = AsyncMock(
274
+ side_effect=[False, True]
275
+ )
276
+
277
+ # Act
278
+ await ua.run()
279
+
280
+ # Assert
281
+ calls_by_key = {
282
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
283
+ }
284
+ assert calls_by_key["llm_invocations_complete"] is False
285
+ ua._postprocessor_manager.run_postprocessors.assert_not_awaited()
286
+ ua._evaluation_manager.run_evaluations.assert_not_awaited()
287
+
288
+ @pytest.mark.ai
289
+ @pytest.mark.asyncio
290
+ async def test_run__cancelled_before_postprocessing__still_reports_pending_stats(
291
+ self,
292
+ ):
293
+ """
294
+ Purpose: Verify pre-run usage (e.g. a user-memory load-time condense
295
+ call) is still reported when a turn exits before postprocessors run.
296
+ Why this matters: That usage previously lived inside a postprocessor
297
+ and was only surfaced when its `run()` executed; a turn that
298
+ cancels first silently dropped it from analytics.
299
+ Setup summary: Seed pending postprocessor stats, cancel before
300
+ postprocessing, and assert the stats still reach `llm_invocations`.
301
+ """
302
+ # Arrange
303
+ pending_stats = LanguageModelInvocationStats.from_usage(
304
+ MODEL_NAME,
305
+ LanguageModelTokenUsage(total_tokens=7),
306
+ source="user_memory_load_condense",
307
+ )
308
+ ua = _build_run_ua([_make_loop_response(None)], max_iterations=1)
309
+ ua._postprocessor_manager.take_pending_invocation_stats.side_effect = None
310
+ ua._postprocessor_manager.take_pending_invocation_stats.return_value = [
311
+ pending_stats
312
+ ]
313
+ ua._chat_service.cancellation.check_cancellation_async = AsyncMock(
314
+ side_effect=[False, True]
315
+ )
316
+
317
+ # Act
318
+ await ua.run()
319
+
320
+ # Assert
321
+ calls_by_key = {
322
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
323
+ }
324
+ assert calls_by_key["llm_invocations"] == [
325
+ pending_stats.model_dump(by_alias=True)
326
+ ]
327
+ assert calls_by_key["llm_invocations_complete"] is False
328
+ ua._postprocessor_manager.run_postprocessors.assert_not_awaited()
329
+
330
+ @pytest.mark.ai
331
+ @pytest.mark.asyncio
332
+ async def test_run__tool_takes_control__keeps_invocations_incomplete(self):
333
+ """
334
+ Purpose: Verify a control-taking tool leaves invocation usage partial.
335
+ Why this matters: The agent exits before postprocessors and evaluations can add usage.
336
+ Setup summary: Execute a control-taking tool and assert completion remains false.
337
+ """
338
+ # Arrange
339
+ tool_call = MagicMock()
340
+ tool_call.name = "SubAgent"
341
+ loop_response = _make_loop_response(None)
342
+ loop_response.tool_calls = [tool_call]
343
+ ua = _build_run_ua([loop_response], max_iterations=1)
344
+ ua._tool_manager.does_a_tool_take_control.return_value = True
345
+ ua._tool_manager.execute_selected_tools = AsyncMock(
346
+ return_value=[
347
+ ToolCallResponse(id="call_1", name="SubAgent", invocation_stats=[])
348
+ ]
349
+ )
350
+
351
+ # Act
352
+ await ua.run()
353
+
354
+ # Assert
355
+ calls_by_key = {
356
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
357
+ }
358
+ assert calls_by_key["llm_invocations_complete"] is False
359
+ ua._postprocessor_manager.run_postprocessors.assert_not_awaited()
360
+ ua._evaluation_manager.run_evaluations.assert_not_awaited()
361
+
362
+ @pytest.mark.ai
363
+ @pytest.mark.asyncio
364
+ async def test_run__deep_research_takes_control__marks_invocations_complete(self):
365
+ """
366
+ Purpose: Verify a Deep Research execution run signals complete usage despite
367
+ exiting before postprocessors and evaluations run.
368
+ Why this matters: Deep Research always takes control, so it never reaches
369
+ `_handle_no_tool_calls` and `_invocation_stats_finalized` never becomes
370
+ true; the completion signal for it must come from `message_execution_id`
371
+ alone, not be gated behind that flag.
372
+ Setup summary: Call Deep Research as a tool with an execution id set and
373
+ assert completion is still reported true.
374
+ """
375
+ # Arrange
376
+ tool_call = MagicMock()
377
+ tool_call.name = "DeepResearch"
378
+ loop_response = _make_loop_response(None)
379
+ loop_response.tool_calls = [tool_call]
380
+ ua = _build_run_ua([loop_response], max_iterations=1)
381
+ ua._debug_info_manager = DebugInfoManager()
382
+ ua._event.payload.message_execution_id = "execution-1"
383
+ ua._tool_manager.does_a_tool_take_control.return_value = True
384
+ ua._tool_manager.execute_selected_tools = AsyncMock(
385
+ return_value=[
386
+ ToolCallResponse(id="call_1", name="DeepResearch", invocation_stats=[])
387
+ ]
388
+ )
389
+
390
+ # Act
391
+ await ua.run()
392
+
393
+ # Assert
394
+ debug_info = ua._chat_service.update_debug_info_async.call_args.kwargs[
395
+ "debug_info"
396
+ ]
397
+ assert debug_info["llm_invocations_complete"] is True
398
+ ua._postprocessor_manager.run_postprocessors.assert_not_awaited()
399
+ ua._evaluation_manager.run_evaluations.assert_not_awaited()
400
+
401
+ @pytest.mark.ai
402
+ @pytest.mark.asyncio
403
+ async def test_run__max_iterations_with_tool_calls__keeps_invocations_incomplete(
404
+ self,
405
+ ):
406
+ """
407
+ Purpose: Verify tool-only exhaustion leaves invocation usage partial.
408
+ Why this matters: Max-iteration exit skips postprocessor and evaluation aggregation.
409
+ Setup summary: Exhaust one tool-call iteration and assert completion remains false.
410
+ """
411
+ # Arrange
412
+ tool_call = MagicMock()
413
+ tool_call.name = "WebSearch"
414
+ loop_response = _make_loop_response(None)
415
+ loop_response.tool_calls = [tool_call]
416
+ ua = _build_run_ua([loop_response], max_iterations=1)
417
+ ua._tool_manager.execute_selected_tools = AsyncMock(
418
+ return_value=[
419
+ ToolCallResponse(id="call_1", name="WebSearch", invocation_stats=[])
420
+ ]
421
+ )
422
+
423
+ # Act
424
+ await ua.run()
425
+
426
+ # Assert
427
+ calls_by_key = {
428
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
429
+ }
430
+ assert calls_by_key["llm_invocations_complete"] is False
431
+ ua._postprocessor_manager.run_postprocessors.assert_not_awaited()
432
+ ua._evaluation_manager.run_evaluations.assert_not_awaited()
433
+
434
+ @pytest.mark.ai
435
+ @pytest.mark.asyncio
436
+ async def test_run__empty_response__keeps_invocations_incomplete(self):
437
+ """
438
+ Purpose: Verify an empty model response leaves invocation usage partial.
439
+ Why this matters: Empty responses exit before downstream LLM usage is aggregated.
440
+ Setup summary: Return an empty response and assert completion remains false.
441
+ """
442
+ # Arrange
443
+ loop_response = _make_loop_response(None)
444
+ loop_response.is_empty.return_value = True
445
+ ua = _build_run_ua([loop_response], max_iterations=1)
446
+
447
+ # Act
448
+ await ua.run()
449
+
450
+ # Assert
451
+ calls_by_key = {
452
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
453
+ }
454
+ assert calls_by_key["llm_invocations_complete"] is False
455
+ ua._postprocessor_manager.run_postprocessors.assert_not_awaited()
456
+ ua._evaluation_manager.run_evaluations.assert_not_awaited()
457
+
254
458
  @pytest.mark.ai
255
459
  @pytest.mark.asyncio
256
460
  async def test_run__postprocessor_usage__added_to_loop_usage(self):
@@ -461,6 +665,135 @@ class TestRunTokenUsageIntegration:
461
665
  assert payload[0]["tokenUsage"]["totalTokens"] == 3
462
666
  assert payload[1]["tokenUsage"]["totalTokens"] == 9
463
667
 
668
+ @pytest.mark.ai
669
+ @pytest.mark.asyncio
670
+ async def test_run__deep_research__merges_previous_turn_invocations(self):
671
+ """
672
+ Purpose: Verify Deep Research preserves invocation stats from its earlier turns.
673
+ Why this matters: Clarification and message-execution runs update the same debugInfo.
674
+ Setup summary: Seed one persisted invocation, run another turn, and assert both and their analytics remain.
675
+ """
676
+ # Arrange
677
+ current_response = _make_loop_response(
678
+ LanguageModelTokenUsage(
679
+ completion_tokens=10,
680
+ prompt_tokens=20,
681
+ total_tokens=30,
682
+ )
683
+ )
684
+ ua = _build_run_ua([current_response], max_iterations=1)
685
+ ua._debug_info_manager = DebugInfoManager()
686
+ ua._debug_info_manager.debug_info["tools"] = [
687
+ {"name": "DeepResearch", "info": {}}
688
+ ]
689
+ ua._event.payload.message_execution_id = "execution-1"
690
+ previous_invocation = LanguageModelInvocationStats.from_usage(
691
+ MODEL_NAME,
692
+ LanguageModelTokenUsage(
693
+ completion_tokens=2,
694
+ prompt_tokens=3,
695
+ total_tokens=5,
696
+ ),
697
+ source="deep_research.clarification",
698
+ )
699
+ ua._chat_service.get_debug_info_async.return_value = {
700
+ "llm_invocations": [previous_invocation.model_dump(by_alias=True)],
701
+ "preserved": True,
702
+ }
703
+
704
+ # Act
705
+ await ua.run()
706
+
707
+ # Assert
708
+ debug_info = ua._chat_service.update_debug_info_async.call_args.kwargs[
709
+ "debug_info"
710
+ ]
711
+ assert debug_info["preserved"] is True
712
+ assert [
713
+ invocation["source"] for invocation in debug_info["llm_invocations"]
714
+ ] == ["deep_research.clarification", "main_loop[1]"]
715
+ assert debug_info["analytics"]["tokens"][0]["total_tokens"] == 35
716
+ assert debug_info["llm_invocations_complete"] is True
717
+
718
+ @pytest.mark.ai
719
+ @pytest.mark.asyncio
720
+ async def test_run__deep_research_partial_persist__does_not_duplicate_previous_invocations(
721
+ self,
722
+ ):
723
+ """
724
+ Purpose: Verify failed Deep Research persistence does not merge prior usage twice.
725
+ Why this matters: Duplicate invocations overstate token analytics for aborted runs.
726
+ Setup summary: Fail the full persist after merging stored usage, then inspect the partial retry.
727
+ """
728
+ # Arrange
729
+ current_response = _make_loop_response(
730
+ LanguageModelTokenUsage(
731
+ completion_tokens=10,
732
+ prompt_tokens=20,
733
+ total_tokens=30,
734
+ )
735
+ )
736
+ ua = _build_run_ua([current_response], max_iterations=1)
737
+ ua._debug_info_manager = DebugInfoManager()
738
+ ua._debug_info_manager.debug_info["tools"] = [
739
+ {"name": "DeepResearch", "info": {}}
740
+ ]
741
+ ua._event.payload.message_execution_id = "execution-1"
742
+ previous_invocation = LanguageModelInvocationStats.from_usage(
743
+ MODEL_NAME,
744
+ LanguageModelTokenUsage(
745
+ completion_tokens=2,
746
+ prompt_tokens=3,
747
+ total_tokens=5,
748
+ ),
749
+ source="deep_research.clarification",
750
+ )
751
+ ua._chat_service.get_debug_info_async.return_value = {
752
+ "llm_invocations": [previous_invocation.model_dump(by_alias=True)],
753
+ }
754
+ ua._chat_service.update_debug_info_async.side_effect = [
755
+ RuntimeError("full persist failed"),
756
+ None,
757
+ ]
758
+
759
+ # Act
760
+ with pytest.raises(RuntimeError, match="full persist failed"):
761
+ await ua.run()
762
+
763
+ # Assert
764
+ partial_debug_info = ua._chat_service.update_debug_info_async.call_args_list[
765
+ 1
766
+ ].kwargs["debug_info"]
767
+ assert [
768
+ invocation["source"] for invocation in partial_debug_info["llm_invocations"]
769
+ ] == ["deep_research.clarification", "main_loop[1]"]
770
+ assert partial_debug_info["llm_invocations_complete"] is False
771
+
772
+ @pytest.mark.ai
773
+ @pytest.mark.asyncio
774
+ async def test_run__deep_research_clarification__keeps_invocations_incomplete(self):
775
+ """
776
+ Purpose: Verify Deep Research does not signal complete usage before research execution.
777
+ Why this matters: Readers otherwise stop waiting after seeing only clarification calls.
778
+ Setup summary: Run a Deep Research turn without an execution ID and assert the flag is false.
779
+ """
780
+ # Arrange
781
+ ua = _build_run_ua([_make_loop_response(None)], max_iterations=1)
782
+ ua._debug_info_manager = DebugInfoManager()
783
+ ua._debug_info_manager.debug_info["tools"] = [
784
+ {"name": "DeepResearch", "info": {}}
785
+ ]
786
+ ua._event.payload.message_execution_id = None
787
+
788
+ # Act
789
+ await ua.run()
790
+
791
+ # Assert
792
+ debug_info = ua._chat_service.update_debug_info_async.call_args.kwargs[
793
+ "debug_info"
794
+ ]
795
+ assert debug_info["llm_invocations_complete"] is False
796
+
464
797
  @pytest.mark.ai
465
798
  @pytest.mark.asyncio
466
799
  async def test_run__reset_between_runs__does_not_leak_across_calls(self):
@@ -73,6 +73,29 @@ from unique_orchestrator.settings import env_settings
73
73
  from unique_orchestrator.utils import resolve_other_options
74
74
 
75
75
 
76
+ def _load_invocation_stats_from_debug_info(
77
+ debug_info: dict[str, Any],
78
+ logger: Logger,
79
+ ) -> list[LanguageModelInvocationStats]:
80
+ """Deserialize previously persisted invocation stats, ignoring malformed entries."""
81
+ raw_invocations = debug_info.get("llm_invocations")
82
+ if not isinstance(raw_invocations, list):
83
+ return []
84
+
85
+ invocations: list[LanguageModelInvocationStats] = []
86
+ for raw_invocation in raw_invocations:
87
+ try:
88
+ invocations.append(
89
+ LanguageModelInvocationStats.model_validate(raw_invocation)
90
+ )
91
+ except Exception:
92
+ logger.warning(
93
+ "Ignoring malformed persisted LLM invocation stats",
94
+ exc_info=True,
95
+ )
96
+ return invocations
97
+
98
+
76
99
  class UniqueAI:
77
100
  start_text = ""
78
101
  current_iteration_index = 0
@@ -213,6 +236,7 @@ class UniqueAI:
213
236
  # True/False when it ran and did/didn't update the stored memory profile.
214
237
  self._context_memory_updated: bool | None = None
215
238
  self._invocation_stats: list[LanguageModelInvocationStats] = []
239
+ self._invocation_stats_finalized = False
216
240
 
217
241
  async def _on_cancellation(self, _event: CancellationEvent) -> None:
218
242
  """Subscriber called by the cancellation event bus."""
@@ -243,7 +267,17 @@ class UniqueAI:
243
267
  # cancellation) must not report the previous run's artifacts.
244
268
  self._generated_files_info = None
245
269
  self._context_memory_updated = None
246
- self._invocation_stats = []
270
+ # Pending pre-run usage (e.g. the user-memory load-time condense call)
271
+ # must be captured unconditionally, before it's known whether this
272
+ # turn will even reach postprocessors -- otherwise a turn that exits
273
+ # early (cancellation, empty response, control-taking tool) drops it.
274
+ self._invocation_stats = (
275
+ self._postprocessor_manager.take_pending_invocation_stats()
276
+ )
277
+ self._invocation_stats_finalized = False
278
+ self._debug_info_manager.add("llm_invocations_complete", False)
279
+ invocations_persisted = False
280
+ persisted_invocations_merged = False
247
281
  run_start = time.perf_counter()
248
282
 
249
283
  await preload_invoked_skills(
@@ -361,6 +395,20 @@ class UniqueAI:
361
395
  self._debug_info_manager.add("loop_params", self._loop_debug_params)
362
396
  skills_debug_info = self._get_activated_skills_debug_info()
363
397
  self._debug_info_manager.add("skills", skills_debug_info)
398
+ tool_names = {
399
+ tool["name"] for tool in self._debug_info_manager.get()["tools"]
400
+ }
401
+ existing_debug_info: dict[str, Any] = {}
402
+ if "DeepResearch" in tool_names:
403
+ existing_debug_info = await self._chat_service.get_debug_info_async()
404
+ self._invocation_stats = [
405
+ *_load_invocation_stats_from_debug_info(
406
+ existing_debug_info,
407
+ self._logger,
408
+ ),
409
+ *self._invocation_stats,
410
+ ]
411
+ persisted_invocations_merged = True
364
412
  self._debug_info_manager.add(
365
413
  "llm_invocations",
366
414
  [
@@ -380,9 +428,6 @@ class UniqueAI:
380
428
  str(tool.name): tool.display_name() or str(tool.name)
381
429
  for tool in self._tool_manager.available_tools
382
430
  }
383
- tool_names = [
384
- tool["name"] for tool in self._debug_info_manager.get()["tools"]
385
- ]
386
431
 
387
432
  total_time_to_answer_ms: int | None = None
388
433
  if not self._chat_service.cancellation.is_cancelled:
@@ -415,16 +460,92 @@ class UniqueAI:
415
460
  total_time_to_answer_ms=total_time_to_answer_ms,
416
461
  artifacts=self._generated_files_info,
417
462
  context_memory_updated=self._context_memory_updated,
463
+ invocations=self._invocation_stats,
418
464
  )
419
465
 
420
- # Get current debug info from chat service and add debug info from run. Do not update if DeepResearch is in the tool names.
421
- if "DeepResearch" not in tool_names:
466
+ # DeepResearch always takes control (see `takes_control()`), so a run
467
+ # that invokes it exits before `_handle_no_tool_calls` and never sets
468
+ # `_invocation_stats_finalized` -- gating on that flag would make this
469
+ # branch unreachable. Its completion signal is instead the message
470
+ # execution callback that runs the actual research (`message_execution_id`
471
+ # set), which is when its merged invocation stats are truly final.
472
+ llm_invocations_complete = (
473
+ self._event.payload.message_execution_id is not None
474
+ if "DeepResearch" in tool_names
475
+ else self._invocation_stats_finalized
476
+ )
477
+ self._debug_info_manager.add(
478
+ "llm_invocations_complete",
479
+ llm_invocations_complete,
480
+ )
481
+ run_debug_info = self._debug_info_manager.get()
482
+ if "DeepResearch" in tool_names:
422
483
  debug_info = {
423
- **await self._chat_service.get_debug_info_async(),
424
- **self._debug_info_manager.get(),
484
+ **existing_debug_info,
485
+ "llm_invocations": run_debug_info["llm_invocations"],
486
+ "llm_invocations_complete": llm_invocations_complete,
487
+ "analytics": run_debug_info["analytics"],
425
488
  }
426
- await self._chat_service.update_debug_info_async(debug_info=debug_info)
489
+ else:
490
+ existing_debug_info = await self._chat_service.get_debug_info_async()
491
+ debug_info = {**existing_debug_info, **run_debug_info}
492
+ await self._chat_service.update_debug_info_async(debug_info=debug_info)
493
+ invocations_persisted = True
427
494
  finally:
495
+ if not invocations_persisted:
496
+ if isinstance(self._loop_iteration_runner, SupportsInvocationStats):
497
+ self._invocation_stats.extend(
498
+ self._loop_iteration_runner.get_invocation_stats()
499
+ )
500
+ self._debug_info_manager.add(
501
+ "llm_invocations",
502
+ [
503
+ invocation.model_dump(by_alias=True)
504
+ for invocation in self._invocation_stats
505
+ ],
506
+ )
507
+ self._debug_info_manager.add("llm_invocations_complete", False)
508
+ try:
509
+ existing_debug_info = (
510
+ await self._chat_service.get_debug_info_async()
511
+ )
512
+ partial_debug_info = self._debug_info_manager.get()
513
+ is_deep_research = any(
514
+ tool.get("name") == "DeepResearch"
515
+ for tool in partial_debug_info.get("tools", [])
516
+ )
517
+ if is_deep_research:
518
+ if persisted_invocations_merged:
519
+ merged_invocations = partial_debug_info["llm_invocations"]
520
+ else:
521
+ previous_invocations = (
522
+ _load_invocation_stats_from_debug_info(
523
+ existing_debug_info,
524
+ self._logger,
525
+ )
526
+ )
527
+ merged_invocations = [
528
+ *[
529
+ invocation.model_dump(by_alias=True)
530
+ for invocation in previous_invocations
531
+ ],
532
+ *partial_debug_info["llm_invocations"],
533
+ ]
534
+ debug_info = {
535
+ **existing_debug_info,
536
+ "llm_invocations": merged_invocations,
537
+ "llm_invocations_complete": False,
538
+ }
539
+ else:
540
+ debug_info = {**existing_debug_info, **partial_debug_info}
541
+ await self._chat_service.update_debug_info_async(
542
+ debug_info=debug_info
543
+ )
544
+ except Exception:
545
+ self._logger.warning(
546
+ "Failed to persist partial LLM invocation usage",
547
+ exc_info=True,
548
+ )
428
549
  sub.cancel()
429
550
 
430
551
  @staticmethod
@@ -801,6 +922,7 @@ class UniqueAI:
801
922
  name_str, 0
802
923
  )
803
924
  self._invocation_stats.extend(self._evaluation_manager.get_invocation_stats())
925
+ self._invocation_stats_finalized = True
804
926
 
805
927
  if evaluation_results.success and not all(
806
928
  result.is_positive for result in evaluation_results.unpack()