unique_orchestrator 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 (34) hide show
  1. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/PKG-INFO +7 -7
  2. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/pyproject.toml +7 -7
  3. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_unique_ai_token_usage.py +354 -1
  4. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/unique_ai.py +131 -9
  5. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/README.md +0 -0
  6. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/__init__.py +0 -0
  7. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/_builders/__init__.py +0 -0
  8. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/_builders/history_manager.py +0 -0
  9. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/_builders/inject_tool_reminders.py +0 -0
  10. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/_builders/loop_iteration_runner.py +0 -0
  11. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/_builders/open_file_setup.py +0 -0
  12. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/_builders/skill_setup.py +0 -0
  13. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/config.py +0 -0
  14. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/prompts/generic_reference_prompt.jinja2 +0 -0
  15. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/prompts/system_prompt.jinja2 +0 -0
  16. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/prompts/user_message_prompt.jinja2 +0 -0
  17. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/settings.py +0 -0
  18. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_agent_config_rjsf_ui_schema.py +0 -0
  19. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_build_loop_iteration_runner.py +0 -0
  20. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_config_services_validators.py +0 -0
  21. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_qwen_max_loop_iterations.py +0 -0
  22. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_register_code_interpreter_postprocessors.py +0 -0
  23. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_unique_ai_async_modify.py +0 -0
  24. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_unique_ai_execution_timing.py +0 -0
  25. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/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.dev2}/unique_orchestrator/tests/test_unique_ai_log_tool_calls.py +0 -0
  27. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_unique_ai_loop_debug_params.py +0 -0
  28. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_unique_ai_persist_tool_calls.py +0 -0
  29. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/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.dev2}/unique_orchestrator/tests/test_unique_ai_reference_order.py +0 -0
  31. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/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.dev2}/unique_orchestrator/tests/test_utils_resolve_other_options.py +0 -0
  33. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/unique_ai_builder.py +0 -0
  34. {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/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.dev2
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.dev6,<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.dev2,<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.dev2"
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.dev6,<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.dev2,<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,9 +1,14 @@
1
+ from pathlib import Path
1
2
  from unittest.mock import AsyncMock, MagicMock
2
3
 
3
4
  import pytest
5
+ from unique_toolkit.agentic.debug_info_manager.debug_info_manager import (
6
+ DebugInfoManager,
7
+ )
4
8
  from unique_toolkit.agentic.loop_runner import SupportsInvocationStats
5
9
  from unique_toolkit.agentic.tools.schemas import ToolCallResponse
6
10
  from unique_toolkit.language_model.invocation_stats import LanguageModelInvocationStats
11
+ from unique_toolkit.language_model.model_costs import MODEL_COSTS_FILE_ENV
7
12
  from unique_toolkit.language_model.schemas import LanguageModelTokenUsage
8
13
 
9
14
  from unique_orchestrator.unique_ai import UniqueAI
@@ -73,6 +78,10 @@ def _build_run_ua(loop_responses: list[MagicMock], max_iterations: int):
73
78
  mock_postprocessor_manager.run_postprocessors = AsyncMock(return_value=None)
74
79
  mock_postprocessor_manager.get_execution_times.return_value = {}
75
80
  mock_postprocessor_manager.get_invocation_stats.return_value = []
81
+ # A fresh list per call: `.return_value = []` would hand back the *same*
82
+ # list object every time, so mutations from one `ua.run()` (e.g. the
83
+ # main-loop usage append) would leak into the next run's list.
84
+ mock_postprocessor_manager.take_pending_invocation_stats.side_effect = lambda: []
76
85
 
77
86
  mock_evaluation_manager = MagicMock()
78
87
  mock_evaluation_manager.run_evaluations = AsyncMock(return_value=[])
@@ -204,9 +213,25 @@ class TestAccumulateUsage:
204
213
  class TestRunTokenUsageIntegration:
205
214
  @pytest.mark.ai
206
215
  @pytest.mark.asyncio
207
- async def test_run__debug_info_manager__receives_llm_invocations_key(self):
216
+ async def test_run__debug_info_manager__receives_llm_invocations_key(
217
+ self,
218
+ tmp_path: Path,
219
+ monkeypatch: pytest.MonkeyPatch,
220
+ ):
208
221
  """A single-iteration run with real usage on the loop response must
209
222
  surface it under debug_info's 'llm_invocations' key."""
223
+ cost_file = tmp_path / "costs.yaml"
224
+ cost_file.write_text(
225
+ f"""
226
+ costSchemaVersion: 1
227
+ models:
228
+ {MODEL_NAME}:
229
+ input: 2
230
+ completion: 8
231
+ """,
232
+ encoding="utf-8",
233
+ )
234
+ monkeypatch.setenv(MODEL_COSTS_FILE_ENV, str(cost_file))
210
235
  loop_response = _make_loop_response(
211
236
  LanguageModelTokenUsage(
212
237
  completion_tokens=12, prompt_tokens=34, total_tokens=46
@@ -224,6 +249,7 @@ class TestRunTokenUsageIntegration:
224
249
  assert len(payload) == 1
225
250
  assert payload[0]["modelName"] == MODEL_NAME
226
251
  assert payload[0]["source"] == "main_loop[1]"
252
+ assert payload[0]["costUsd"] == pytest.approx(0.000164)
227
253
  assert payload[0]["tokenUsage"] == {
228
254
  "completionTokens": 12,
229
255
  "promptTokens": 34,
@@ -251,6 +277,203 @@ class TestRunTokenUsageIntegration:
251
277
  assert "llm_invocations" in calls_by_key
252
278
  assert calls_by_key["llm_invocations"] == []
253
279
 
280
+ @pytest.mark.ai
281
+ @pytest.mark.asyncio
282
+ async def test_run__cancelled_before_postprocessing__keeps_invocations_incomplete(
283
+ self,
284
+ ):
285
+ """
286
+ Purpose: Verify cancellation after the model call leaves invocation usage partial.
287
+ Why this matters: Postprocessor and evaluation usage has not been aggregated yet.
288
+ Setup summary: Cancel after planning, then assert the completion flag remains false.
289
+ """
290
+ # Arrange
291
+ ua = _build_run_ua([_make_loop_response(None)], max_iterations=1)
292
+ ua._chat_service.cancellation.check_cancellation_async = AsyncMock(
293
+ side_effect=[False, True]
294
+ )
295
+
296
+ # Act
297
+ await ua.run()
298
+
299
+ # Assert
300
+ calls_by_key = {
301
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
302
+ }
303
+ assert calls_by_key["llm_invocations_complete"] is False
304
+ ua._postprocessor_manager.run_postprocessors.assert_not_awaited()
305
+ ua._evaluation_manager.run_evaluations.assert_not_awaited()
306
+
307
+ @pytest.mark.ai
308
+ @pytest.mark.asyncio
309
+ async def test_run__cancelled_before_postprocessing__still_reports_pending_stats(
310
+ self,
311
+ ):
312
+ """
313
+ Purpose: Verify pre-run usage (e.g. a user-memory load-time condense
314
+ call) is still reported when a turn exits before postprocessors run.
315
+ Why this matters: That usage previously lived inside a postprocessor
316
+ and was only surfaced when its `run()` executed; a turn that
317
+ cancels first silently dropped it from analytics.
318
+ Setup summary: Seed pending postprocessor stats, cancel before
319
+ postprocessing, and assert the stats still reach `llm_invocations`.
320
+ """
321
+ # Arrange
322
+ pending_stats = LanguageModelInvocationStats.from_usage(
323
+ MODEL_NAME,
324
+ LanguageModelTokenUsage(total_tokens=7),
325
+ source="user_memory_load_condense",
326
+ )
327
+ ua = _build_run_ua([_make_loop_response(None)], max_iterations=1)
328
+ ua._postprocessor_manager.take_pending_invocation_stats.side_effect = None
329
+ ua._postprocessor_manager.take_pending_invocation_stats.return_value = [
330
+ pending_stats
331
+ ]
332
+ ua._chat_service.cancellation.check_cancellation_async = AsyncMock(
333
+ side_effect=[False, True]
334
+ )
335
+
336
+ # Act
337
+ await ua.run()
338
+
339
+ # Assert
340
+ calls_by_key = {
341
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
342
+ }
343
+ assert calls_by_key["llm_invocations"] == [
344
+ pending_stats.model_dump(by_alias=True)
345
+ ]
346
+ assert calls_by_key["llm_invocations_complete"] is False
347
+ ua._postprocessor_manager.run_postprocessors.assert_not_awaited()
348
+
349
+ @pytest.mark.ai
350
+ @pytest.mark.asyncio
351
+ async def test_run__tool_takes_control__keeps_invocations_incomplete(self):
352
+ """
353
+ Purpose: Verify a control-taking tool leaves invocation usage partial.
354
+ Why this matters: The agent exits before postprocessors and evaluations can add usage.
355
+ Setup summary: Execute a control-taking tool and assert completion remains false.
356
+ """
357
+ # Arrange
358
+ tool_call = MagicMock()
359
+ tool_call.name = "SubAgent"
360
+ loop_response = _make_loop_response(None)
361
+ loop_response.tool_calls = [tool_call]
362
+ ua = _build_run_ua([loop_response], max_iterations=1)
363
+ ua._tool_manager.does_a_tool_take_control.return_value = True
364
+ ua._tool_manager.execute_selected_tools = AsyncMock(
365
+ return_value=[
366
+ ToolCallResponse(id="call_1", name="SubAgent", invocation_stats=[])
367
+ ]
368
+ )
369
+
370
+ # Act
371
+ await ua.run()
372
+
373
+ # Assert
374
+ calls_by_key = {
375
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
376
+ }
377
+ assert calls_by_key["llm_invocations_complete"] is False
378
+ ua._postprocessor_manager.run_postprocessors.assert_not_awaited()
379
+ ua._evaluation_manager.run_evaluations.assert_not_awaited()
380
+
381
+ @pytest.mark.ai
382
+ @pytest.mark.asyncio
383
+ async def test_run__deep_research_takes_control__marks_invocations_complete(self):
384
+ """
385
+ Purpose: Verify a Deep Research execution run signals complete usage despite
386
+ exiting before postprocessors and evaluations run.
387
+ Why this matters: Deep Research always takes control, so it never reaches
388
+ `_handle_no_tool_calls` and `_invocation_stats_finalized` never becomes
389
+ true; the completion signal for it must come from `message_execution_id`
390
+ alone, not be gated behind that flag.
391
+ Setup summary: Call Deep Research as a tool with an execution id set and
392
+ assert completion is still reported true.
393
+ """
394
+ # Arrange
395
+ tool_call = MagicMock()
396
+ tool_call.name = "DeepResearch"
397
+ loop_response = _make_loop_response(None)
398
+ loop_response.tool_calls = [tool_call]
399
+ ua = _build_run_ua([loop_response], max_iterations=1)
400
+ ua._debug_info_manager = DebugInfoManager()
401
+ ua._event.payload.message_execution_id = "execution-1"
402
+ ua._tool_manager.does_a_tool_take_control.return_value = True
403
+ ua._tool_manager.execute_selected_tools = AsyncMock(
404
+ return_value=[
405
+ ToolCallResponse(id="call_1", name="DeepResearch", invocation_stats=[])
406
+ ]
407
+ )
408
+
409
+ # Act
410
+ await ua.run()
411
+
412
+ # Assert
413
+ debug_info = ua._chat_service.update_debug_info_async.call_args.kwargs[
414
+ "debug_info"
415
+ ]
416
+ assert debug_info["llm_invocations_complete"] is True
417
+ ua._postprocessor_manager.run_postprocessors.assert_not_awaited()
418
+ ua._evaluation_manager.run_evaluations.assert_not_awaited()
419
+
420
+ @pytest.mark.ai
421
+ @pytest.mark.asyncio
422
+ async def test_run__max_iterations_with_tool_calls__keeps_invocations_incomplete(
423
+ self,
424
+ ):
425
+ """
426
+ Purpose: Verify tool-only exhaustion leaves invocation usage partial.
427
+ Why this matters: Max-iteration exit skips postprocessor and evaluation aggregation.
428
+ Setup summary: Exhaust one tool-call iteration and assert completion remains false.
429
+ """
430
+ # Arrange
431
+ tool_call = MagicMock()
432
+ tool_call.name = "WebSearch"
433
+ loop_response = _make_loop_response(None)
434
+ loop_response.tool_calls = [tool_call]
435
+ ua = _build_run_ua([loop_response], max_iterations=1)
436
+ ua._tool_manager.execute_selected_tools = AsyncMock(
437
+ return_value=[
438
+ ToolCallResponse(id="call_1", name="WebSearch", invocation_stats=[])
439
+ ]
440
+ )
441
+
442
+ # Act
443
+ await ua.run()
444
+
445
+ # Assert
446
+ calls_by_key = {
447
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
448
+ }
449
+ assert calls_by_key["llm_invocations_complete"] is False
450
+ ua._postprocessor_manager.run_postprocessors.assert_not_awaited()
451
+ ua._evaluation_manager.run_evaluations.assert_not_awaited()
452
+
453
+ @pytest.mark.ai
454
+ @pytest.mark.asyncio
455
+ async def test_run__empty_response__keeps_invocations_incomplete(self):
456
+ """
457
+ Purpose: Verify an empty model response leaves invocation usage partial.
458
+ Why this matters: Empty responses exit before downstream LLM usage is aggregated.
459
+ Setup summary: Return an empty response and assert completion remains false.
460
+ """
461
+ # Arrange
462
+ loop_response = _make_loop_response(None)
463
+ loop_response.is_empty.return_value = True
464
+ ua = _build_run_ua([loop_response], max_iterations=1)
465
+
466
+ # Act
467
+ await ua.run()
468
+
469
+ # Assert
470
+ calls_by_key = {
471
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
472
+ }
473
+ assert calls_by_key["llm_invocations_complete"] is False
474
+ ua._postprocessor_manager.run_postprocessors.assert_not_awaited()
475
+ ua._evaluation_manager.run_evaluations.assert_not_awaited()
476
+
254
477
  @pytest.mark.ai
255
478
  @pytest.mark.asyncio
256
479
  async def test_run__postprocessor_usage__added_to_loop_usage(self):
@@ -461,6 +684,136 @@ class TestRunTokenUsageIntegration:
461
684
  assert payload[0]["tokenUsage"]["totalTokens"] == 3
462
685
  assert payload[1]["tokenUsage"]["totalTokens"] == 9
463
686
 
687
+ @pytest.mark.ai
688
+ @pytest.mark.asyncio
689
+ async def test_run__deep_research__merges_previous_turn_invocations(self):
690
+ """
691
+ Purpose: Verify Deep Research preserves invocation stats from its earlier turns.
692
+ Why this matters: Clarification and message-execution runs update the same debugInfo.
693
+ Setup summary: Seed one persisted invocation, run another turn, and assert both and their analytics remain.
694
+ """
695
+ # Arrange
696
+ current_response = _make_loop_response(
697
+ LanguageModelTokenUsage(
698
+ completion_tokens=10,
699
+ prompt_tokens=20,
700
+ total_tokens=30,
701
+ )
702
+ )
703
+ ua = _build_run_ua([current_response], max_iterations=1)
704
+ ua._debug_info_manager = DebugInfoManager()
705
+ ua._debug_info_manager.debug_info["tools"] = [
706
+ {"name": "DeepResearch", "info": {}}
707
+ ]
708
+ ua._event.payload.message_execution_id = "execution-1"
709
+ previous_invocation = LanguageModelInvocationStats.from_usage(
710
+ MODEL_NAME,
711
+ LanguageModelTokenUsage(
712
+ completion_tokens=2,
713
+ prompt_tokens=3,
714
+ total_tokens=5,
715
+ ),
716
+ source="deep_research.clarification",
717
+ )
718
+ ua._chat_service.get_debug_info_async.return_value = {
719
+ "llm_invocations": [previous_invocation.model_dump(by_alias=True)],
720
+ "preserved": True,
721
+ }
722
+
723
+ # Act
724
+ await ua.run()
725
+
726
+ # Assert
727
+ debug_info = ua._chat_service.update_debug_info_async.call_args.kwargs[
728
+ "debug_info"
729
+ ]
730
+ assert debug_info["preserved"] is True
731
+ assert [
732
+ invocation["source"] for invocation in debug_info["llm_invocations"]
733
+ ] == ["deep_research.clarification", "main_loop[1]"]
734
+ assert debug_info["analytics"]["consumption_by_llm"][0]["total_tokens"] == 35
735
+ assert debug_info["analytics"]["consumption"]["total_tokens"] == 35
736
+ assert debug_info["llm_invocations_complete"] is True
737
+
738
+ @pytest.mark.ai
739
+ @pytest.mark.asyncio
740
+ async def test_run__deep_research_partial_persist__does_not_duplicate_previous_invocations(
741
+ self,
742
+ ):
743
+ """
744
+ Purpose: Verify failed Deep Research persistence does not merge prior usage twice.
745
+ Why this matters: Duplicate invocations overstate token analytics for aborted runs.
746
+ Setup summary: Fail the full persist after merging stored usage, then inspect the partial retry.
747
+ """
748
+ # Arrange
749
+ current_response = _make_loop_response(
750
+ LanguageModelTokenUsage(
751
+ completion_tokens=10,
752
+ prompt_tokens=20,
753
+ total_tokens=30,
754
+ )
755
+ )
756
+ ua = _build_run_ua([current_response], max_iterations=1)
757
+ ua._debug_info_manager = DebugInfoManager()
758
+ ua._debug_info_manager.debug_info["tools"] = [
759
+ {"name": "DeepResearch", "info": {}}
760
+ ]
761
+ ua._event.payload.message_execution_id = "execution-1"
762
+ previous_invocation = LanguageModelInvocationStats.from_usage(
763
+ MODEL_NAME,
764
+ LanguageModelTokenUsage(
765
+ completion_tokens=2,
766
+ prompt_tokens=3,
767
+ total_tokens=5,
768
+ ),
769
+ source="deep_research.clarification",
770
+ )
771
+ ua._chat_service.get_debug_info_async.return_value = {
772
+ "llm_invocations": [previous_invocation.model_dump(by_alias=True)],
773
+ }
774
+ ua._chat_service.update_debug_info_async.side_effect = [
775
+ RuntimeError("full persist failed"),
776
+ None,
777
+ ]
778
+
779
+ # Act
780
+ with pytest.raises(RuntimeError, match="full persist failed"):
781
+ await ua.run()
782
+
783
+ # Assert
784
+ partial_debug_info = ua._chat_service.update_debug_info_async.call_args_list[
785
+ 1
786
+ ].kwargs["debug_info"]
787
+ assert [
788
+ invocation["source"] for invocation in partial_debug_info["llm_invocations"]
789
+ ] == ["deep_research.clarification", "main_loop[1]"]
790
+ assert partial_debug_info["llm_invocations_complete"] is False
791
+
792
+ @pytest.mark.ai
793
+ @pytest.mark.asyncio
794
+ async def test_run__deep_research_clarification__keeps_invocations_incomplete(self):
795
+ """
796
+ Purpose: Verify Deep Research does not signal complete usage before research execution.
797
+ Why this matters: Readers otherwise stop waiting after seeing only clarification calls.
798
+ Setup summary: Run a Deep Research turn without an execution ID and assert the flag is false.
799
+ """
800
+ # Arrange
801
+ ua = _build_run_ua([_make_loop_response(None)], max_iterations=1)
802
+ ua._debug_info_manager = DebugInfoManager()
803
+ ua._debug_info_manager.debug_info["tools"] = [
804
+ {"name": "DeepResearch", "info": {}}
805
+ ]
806
+ ua._event.payload.message_execution_id = None
807
+
808
+ # Act
809
+ await ua.run()
810
+
811
+ # Assert
812
+ debug_info = ua._chat_service.update_debug_info_async.call_args.kwargs[
813
+ "debug_info"
814
+ ]
815
+ assert debug_info["llm_invocations_complete"] is False
816
+
464
817
  @pytest.mark.ai
465
818
  @pytest.mark.asyncio
466
819
  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()