unique_orchestrator 2026.30.0.dev6__tar.gz → 2026.30.0.dev8__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.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/PKG-INFO +3 -3
  2. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/pyproject.toml +3 -3
  3. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_async_modify.py +29 -0
  4. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_execution_timing.py +6 -0
  5. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_loop_debug_params.py +29 -0
  6. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_reference_order.py +1 -0
  7. unique_orchestrator-2026.30.0.dev8/unique_orchestrator/tests/test_unique_ai_token_usage.py +480 -0
  8. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/unique_ai.py +63 -2
  9. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/README.md +0 -0
  10. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/__init__.py +0 -0
  11. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/__init__.py +0 -0
  12. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/history_manager.py +0 -0
  13. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/inject_tool_reminders.py +0 -0
  14. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/loop_iteration_runner.py +0 -0
  15. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/open_file_setup.py +0 -0
  16. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/skill_setup.py +0 -0
  17. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/config.py +0 -0
  18. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/prompts/generic_reference_prompt.jinja2 +0 -0
  19. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/prompts/system_prompt.jinja2 +0 -0
  20. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/prompts/user_message_prompt.jinja2 +0 -0
  21. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/settings.py +0 -0
  22. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_agent_config_rjsf_ui_schema.py +0 -0
  23. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_build_loop_iteration_runner.py +0 -0
  24. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_config_services_validators.py +0 -0
  25. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_qwen_max_loop_iterations.py +0 -0
  26. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_register_code_interpreter_postprocessors.py +0 -0
  27. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_get_filtered_user_metadata.py +0 -0
  28. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_log_tool_calls.py +0 -0
  29. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_persist_tool_calls.py +0 -0
  30. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_plan_or_execute_include.py +0 -0
  31. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_render_system_prompt_user_instructions.py +0 -0
  32. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_utils_resolve_other_options.py +0 -0
  33. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/unique_ai_builder.py +0 -0
  34. {unique_orchestrator-2026.30.0.dev6 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-orchestrator
3
- Version: 2026.30.0.dev6
3
+ Version: 2026.30.0.dev8
4
4
  Summary:
5
5
  Author: Andreas Hauri
6
6
  Author-email: Andreas Hauri <andreas.hauri@unique.ai>
@@ -9,9 +9,9 @@ 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.30.0.dev17,<2026.30.0rc0
12
+ Requires-Dist: unique-toolkit>=2026.30.0.dev20,<2026.30.0rc0
13
13
  Requires-Dist: unique-stock-ticker>=2026.30.0.dev0,<2026.30.0rc0
14
- Requires-Dist: unique-follow-up-questions>=2026.30.0.dev0,<2026.30.0rc0
14
+ Requires-Dist: unique-follow-up-questions>=2026.30.0.dev1,<2026.30.0rc0
15
15
  Requires-Dist: unique-internal-search>=2026.30.0.dev0,<2026.30.0rc0
16
16
  Requires-Dist: unique-deep-research>=2026.30.0.dev0,<2026.30.0rc0
17
17
  Requires-Dist: unique-web-search>=2026.30.0.dev3,<2026.30.0rc0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_orchestrator"
3
- version = "2026.30.0.dev6"
3
+ version = "2026.30.0.dev8"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "Proprietary" }
@@ -13,9 +13,9 @@ 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.30.0.dev17,<2026.30.0rc0",
16
+ "unique-toolkit>=2026.30.0.dev20,<2026.30.0rc0",
17
17
  "unique-stock-ticker>=2026.30.0.dev0,<2026.30.0rc0",
18
- "unique-follow-up-questions>=2026.30.0.dev0,<2026.30.0rc0",
18
+ "unique-follow-up-questions>=2026.30.0.dev1,<2026.30.0rc0",
19
19
  "unique-internal-search>=2026.30.0.dev0,<2026.30.0rc0",
20
20
  "unique-deep-research>=2026.30.0.dev0,<2026.30.0rc0",
21
21
  "unique-web-search>=2026.30.0.dev3,<2026.30.0rc0",
@@ -3,6 +3,9 @@
3
3
  from unittest.mock import AsyncMock, MagicMock
4
4
 
5
5
  import pytest
6
+ from unique_toolkit.agentic.tools.openai_builtin.code_interpreter import (
7
+ DisplayCodeInterpreterFilesPostProcessor,
8
+ )
6
9
 
7
10
  from unique_orchestrator.unique_ai import UniqueAI
8
11
 
@@ -53,10 +56,12 @@ def _make_ua(monkeypatch, *, feature_flag_enabled: bool = False):
53
56
  mock_postprocessor_manager = MagicMock()
54
57
  mock_postprocessor_manager.run_postprocessors = AsyncMock(return_value=None)
55
58
  mock_postprocessor_manager.get_execution_times.return_value = {}
59
+ mock_postprocessor_manager.get_invocation_stats.return_value = []
56
60
 
57
61
  mock_evaluation_manager = MagicMock()
58
62
  mock_evaluation_manager.run_evaluations = AsyncMock(return_value=[])
59
63
  mock_evaluation_manager.get_execution_times.return_value = {}
64
+ mock_evaluation_manager.get_invocation_stats.return_value = []
60
65
 
61
66
  dummy_event = MagicMock()
62
67
  dummy_event.payload.assistant_message.id = "assist_1"
@@ -85,8 +90,11 @@ def _make_ua(monkeypatch, *, feature_flag_enabled: bool = False):
85
90
  ua._mcp_servers = []
86
91
  ua._execution_times = []
87
92
  ua._current_loop_timing = {}
93
+ ua._generated_files_info = None
94
+ ua._invocation_stats = []
88
95
  ua.current_iteration_index = 0
89
96
  ua._skill_choices = []
97
+ ua._loop_iteration_runner = MagicMock()
90
98
 
91
99
  return ua
92
100
 
@@ -103,6 +111,7 @@ async def test_run_calls_modify_async_when_feature_flag_disabled(monkeypatch):
103
111
  empty_response.message.references = []
104
112
  empty_response.tool_calls = None
105
113
  empty_response.is_empty.return_value = True
114
+ empty_response.usage = None
106
115
 
107
116
  ua._plan_or_execute = AsyncMock(return_value=empty_response)
108
117
 
@@ -126,3 +135,23 @@ async def test_process_plan_calls_modify_async_on_empty_response(monkeypatch):
126
135
 
127
136
  assert result is True
128
137
  ua._chat_service.modify_assistant_message_async.assert_called()
138
+
139
+
140
+ @pytest.mark.ai
141
+ @pytest.mark.asyncio
142
+ async def test_handle_no_tool_calls_stores_returned_artifacts_for_analytics(
143
+ monkeypatch,
144
+ ):
145
+ ua = _make_ua(monkeypatch)
146
+ artifacts = {"count": 1, "filetypes": ["csv"]}
147
+ ua._postprocessor_manager.run_postprocessors.return_value = {
148
+ DisplayCodeInterpreterFilesPostProcessor.__name__: artifacts
149
+ }
150
+ ua._current_loop_timing = {"post_processing": {}, "evaluation": {}}
151
+
152
+ response = MagicMock()
153
+ response.model_copy.return_value = response
154
+
155
+ await ua._handle_no_tool_calls(response)
156
+
157
+ assert ua._generated_files_info == artifacts
@@ -65,6 +65,7 @@ def _make_tool_response(name: str, execution_time_s: float | None = None) -> Mag
65
65
  response.debug_info = (
66
66
  {"execution_time_s": execution_time_s} if execution_time_s is not None else None
67
67
  )
68
+ response.invocation_stats = []
68
69
  return response
69
70
 
70
71
 
@@ -75,6 +76,7 @@ def _make_loop_response(tool_calls: list | None = None) -> MagicMock:
75
76
  response.message.references = []
76
77
  response.message.text = "response text"
77
78
  response.is_empty.return_value = False
79
+ response.usage = None
78
80
  return response
79
81
 
80
82
 
@@ -397,9 +399,11 @@ class TestHandleNoToolCallsTiming:
397
399
  "evaluation": {},
398
400
  }
399
401
  instance._evaluation_manager.run_evaluations = AsyncMock(return_value=[])
402
+ instance._evaluation_manager.get_invocation_stats.return_value = []
400
403
  instance._postprocessor_manager.run_postprocessors = AsyncMock(
401
404
  return_value=None
402
405
  )
406
+ instance._postprocessor_manager.get_invocation_stats.return_value = []
403
407
  return instance
404
408
 
405
409
  @pytest.mark.ai
@@ -666,10 +670,12 @@ class TestRunExecutionTimingIntegration:
666
670
  mock_postprocessor_manager = MagicMock()
667
671
  mock_postprocessor_manager.run_postprocessors = AsyncMock(return_value=None)
668
672
  mock_postprocessor_manager.get_execution_times.return_value = {}
673
+ mock_postprocessor_manager.get_invocation_stats.return_value = []
669
674
 
670
675
  mock_evaluation_manager = MagicMock()
671
676
  mock_evaluation_manager.run_evaluations = AsyncMock(return_value=[])
672
677
  mock_evaluation_manager.get_execution_times.return_value = {}
678
+ mock_evaluation_manager.get_invocation_stats.return_value = []
673
679
 
674
680
  dummy_event = MagicMock()
675
681
  dummy_event.payload.assistant_message.id = "assist_1"
@@ -78,6 +78,7 @@ def _make_loop_response() -> MagicMock:
78
78
  response.message.text = "response text"
79
79
  response.message.original_text = "original response text"
80
80
  response.is_empty.return_value = False
81
+ response.usage = None
81
82
  return response
82
83
 
83
84
 
@@ -478,6 +479,34 @@ class TestRunLoopDebugParams:
478
479
  analytics_call = ua._debug_info_manager.add_analytics.call_args
479
480
  assert analytics_call.kwargs["total_time_to_answer_ms"] is None
480
481
 
482
+ @pytest.mark.ai
483
+ @pytest.mark.asyncio
484
+ async def test_run__generated_files_info__reset_at_start_prevents_stale_artifacts(
485
+ self, monkeypatch
486
+ ) -> None:
487
+ """
488
+ Purpose: Verify run() resets _generated_files_info at the start, so a rerun
489
+ that exits without reaching _handle_no_tool_calls does not report the previous
490
+ run's artifacts.
491
+ Why this matters: _generated_files_info persists on the UniqueAI instance;
492
+ without a reset it leaks a prior run's artifact count/filetypes into the
493
+ current answer's analytics (Cursor bot, UN-22110).
494
+ Setup summary: Seed a stale value, force an early loop exit via _process_plan
495
+ returning True (a tool took control — _handle_no_tool_calls never runs), and
496
+ assert add_analytics received artifacts=None.
497
+ """
498
+ ua = self._build_run_ua(monkeypatch)
499
+ # Leftover from a hypothetical previous run on the same instance.
500
+ ua._generated_files_info = {"count": 99, "filetypes": ["stale"]}
501
+ # Exit the loop as if a tool took control: _handle_no_tool_calls (which sets
502
+ # _generated_files_info) is skipped, but finalization still calls add_analytics.
503
+ ua._process_plan = AsyncMock(return_value=True) # type: ignore[method-assign]
504
+
505
+ await ua.run()
506
+
507
+ analytics_call = ua._debug_info_manager.add_analytics.call_args
508
+ assert analytics_call.kwargs["artifacts"] is None
509
+
481
510
  @pytest.mark.ai
482
511
  @pytest.mark.asyncio
483
512
  async def test_run__loop_params__contains_one_entry_per_iteration(
@@ -68,6 +68,7 @@ async def test_history_updated_before_reference_extraction(monkeypatch):
68
68
  self.message = MagicMock()
69
69
  self.message.references = []
70
70
  self.message.text = ""
71
+ self.usage = None
71
72
 
72
73
  def is_empty(self):
73
74
  return False
@@ -0,0 +1,480 @@
1
+ from unittest.mock import AsyncMock, MagicMock
2
+
3
+ import pytest
4
+ from unique_toolkit.agentic.loop_runner import SupportsInvocationStats
5
+ from unique_toolkit.agentic.tools.schemas import ToolCallResponse
6
+ from unique_toolkit.language_model.invocation_stats import LanguageModelInvocationStats
7
+ from unique_toolkit.language_model.schemas import LanguageModelTokenUsage
8
+
9
+ from unique_orchestrator.unique_ai import UniqueAI
10
+
11
+ MODEL_NAME = "gpt-4"
12
+
13
+
14
+ def _make_loop_response(usage: LanguageModelTokenUsage | None) -> MagicMock:
15
+ response = MagicMock()
16
+ response.tool_calls = None
17
+ response.message = MagicMock()
18
+ response.message.references = []
19
+ response.message.text = "response text"
20
+ response.is_empty.return_value = False
21
+ response.usage = usage
22
+ return response
23
+
24
+
25
+ def _build_run_ua(loop_responses: list[MagicMock], max_iterations: int):
26
+ mock_cancellation = MagicMock()
27
+ mock_cancellation.is_cancelled = False
28
+ mock_cancellation.on_cancellation.subscribe = MagicMock(return_value=MagicMock())
29
+ mock_cancellation.check_cancellation_async = AsyncMock(return_value=False)
30
+
31
+ mock_chat_service = MagicMock()
32
+ mock_chat_service.cancellation = mock_cancellation
33
+ mock_chat_service.get_debug_info_async = AsyncMock(return_value={})
34
+ mock_chat_service.update_debug_info_async = AsyncMock(return_value=None)
35
+ mock_chat_service.modify_assistant_message_async = AsyncMock(return_value=None)
36
+ mock_chat_service.create_assistant_message_async = AsyncMock(
37
+ return_value=MagicMock(id="assist_new")
38
+ )
39
+
40
+ mock_tool_manager = MagicMock()
41
+ mock_tool_manager.available_tools = []
42
+ mock_tool_manager.get_forced_tools.return_value = []
43
+ mock_tool_manager.get_tool_definitions.return_value = []
44
+ mock_tool_manager.filter_duplicate_tool_calls.side_effect = lambda x: x
45
+ mock_tool_manager.filter_tool_calls_by_max_tool_calls_allowed.side_effect = (
46
+ lambda x: x
47
+ )
48
+ mock_tool_manager.does_a_tool_take_control.return_value = False
49
+ mock_tool_manager.should_stop_after_tool_calls.return_value = False
50
+ mock_tool_manager.get_evaluation_check_list.return_value = []
51
+
52
+ mock_debug_info_manager = MagicMock()
53
+ mock_debug_info_manager.get.return_value = {"tools": []}
54
+
55
+ mock_config = MagicMock()
56
+ mock_config.effective_max_loop_iterations = max_iterations
57
+ mock_config.agent.prompt_config.user_metadata = []
58
+ mock_config.space.language_model.name = MODEL_NAME
59
+
60
+ mock_history_manager = MagicMock()
61
+ mock_history_manager.get_history_for_model_call = AsyncMock(
62
+ return_value=MagicMock()
63
+ )
64
+ mock_history_manager._append_tool_calls_to_history = MagicMock()
65
+ mock_history_manager.add_tool_call_results = MagicMock()
66
+ mock_history_manager.extract_message_tools.return_value = []
67
+
68
+ mock_postprocessor_manager = MagicMock()
69
+ mock_postprocessor_manager.run_postprocessors = AsyncMock(return_value=None)
70
+ mock_postprocessor_manager.get_execution_times.return_value = {}
71
+ mock_postprocessor_manager.get_invocation_stats.return_value = []
72
+
73
+ mock_evaluation_manager = MagicMock()
74
+ mock_evaluation_manager.run_evaluations = AsyncMock(return_value=[])
75
+ mock_evaluation_manager.get_execution_times.return_value = {}
76
+ mock_evaluation_manager.get_invocation_stats.return_value = []
77
+
78
+ dummy_event = MagicMock()
79
+ dummy_event.payload.assistant_message.id = "assist_1"
80
+ dummy_event.payload.user_message.text = "query"
81
+ dummy_event.payload.assistant_id = "assistant_123"
82
+ dummy_event.payload.name = "TestAssistant"
83
+ dummy_event.payload.user_metadata = {}
84
+ dummy_event.payload.tool_parameters = {}
85
+ dummy_event.company_id = "company_1"
86
+
87
+ ua = UniqueAI(
88
+ logger=MagicMock(),
89
+ event=dummy_event,
90
+ config=mock_config,
91
+ chat_service=mock_chat_service,
92
+ content_service=MagicMock(),
93
+ debug_info_manager=mock_debug_info_manager,
94
+ streaming_handler=MagicMock(),
95
+ reference_manager=MagicMock(),
96
+ thinking_manager=MagicMock(),
97
+ tool_manager=mock_tool_manager,
98
+ history_manager=mock_history_manager,
99
+ evaluation_manager=mock_evaluation_manager,
100
+ postprocessor_manager=mock_postprocessor_manager,
101
+ message_step_logger=MagicMock(),
102
+ mcp_servers=[],
103
+ loop_iteration_runner=AsyncMock(side_effect=loop_responses),
104
+ )
105
+ ua._render_user_prompt = AsyncMock(return_value="user") # type: ignore[method-assign]
106
+ ua._render_system_prompt = AsyncMock(return_value="system") # type: ignore[method-assign]
107
+ ua._thinking_manager.thinking_is_displayed = MagicMock(return_value=True)
108
+ ua._thinking_manager.update_start_text = MagicMock(return_value="")
109
+ return ua
110
+
111
+
112
+ class TestAccumulateUsage:
113
+ """Tests for how UniqueAI._invocation_stats is populated from the main loop."""
114
+
115
+ def test_main_loop__none_usage__is_noop(self):
116
+ ua = _build_run_ua([_make_loop_response(None)], max_iterations=1)
117
+ assert ua._invocation_stats == []
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_main_loop__single_call__appends_one_entry(self):
121
+ loop_response = _make_loop_response(
122
+ LanguageModelTokenUsage(
123
+ completion_tokens=10, prompt_tokens=20, total_tokens=30
124
+ )
125
+ )
126
+ ua = _build_run_ua([loop_response], max_iterations=1)
127
+
128
+ await ua.run()
129
+
130
+ assert len(ua._invocation_stats) == 1
131
+ entry = ua._invocation_stats[0]
132
+ assert entry.model_name == MODEL_NAME
133
+ assert entry.source == "main_loop[1]"
134
+ assert entry.token_usage == LanguageModelTokenUsage(
135
+ completion_tokens=10, prompt_tokens=20, total_tokens=30
136
+ )
137
+
138
+ @pytest.mark.asyncio
139
+ async def test_main_loop__multiple_iterations__appends_one_entry_per_call(self):
140
+ tool_call = MagicMock()
141
+ tool_call.name = "WebSearch"
142
+
143
+ first = _make_loop_response(
144
+ LanguageModelTokenUsage(
145
+ completion_tokens=10, prompt_tokens=20, total_tokens=30
146
+ )
147
+ )
148
+ first.tool_calls = [tool_call]
149
+ second = _make_loop_response(
150
+ LanguageModelTokenUsage(
151
+ completion_tokens=1, prompt_tokens=2, total_tokens=3
152
+ )
153
+ )
154
+ ua = _build_run_ua([first, second], max_iterations=2)
155
+ ua._tool_manager.execute_selected_tools = AsyncMock(
156
+ return_value=[
157
+ ToolCallResponse(id="call_1", name="WebSearch", invocation_stats=[])
158
+ ]
159
+ )
160
+
161
+ await ua.run()
162
+
163
+ assert len(ua._invocation_stats) == 2
164
+ assert [entry.token_usage.total_tokens for entry in ua._invocation_stats] == [
165
+ 30,
166
+ 3,
167
+ ]
168
+ assert [entry.source for entry in ua._invocation_stats] == [
169
+ "main_loop[1]",
170
+ "main_loop[2]",
171
+ ]
172
+
173
+ @pytest.mark.asyncio
174
+ async def test_main_loop__none_mixed_with_real__none_is_skipped(self):
175
+ tool_call = MagicMock()
176
+ tool_call.name = "WebSearch"
177
+
178
+ first = _make_loop_response(
179
+ LanguageModelTokenUsage(
180
+ completion_tokens=10, prompt_tokens=20, total_tokens=30
181
+ )
182
+ )
183
+ first.tool_calls = [tool_call]
184
+ second = _make_loop_response(None)
185
+ ua = _build_run_ua([first, second], max_iterations=2)
186
+ ua._tool_manager.execute_selected_tools = AsyncMock(
187
+ return_value=[
188
+ ToolCallResponse(id="call_1", name="WebSearch", invocation_stats=[])
189
+ ]
190
+ )
191
+
192
+ await ua.run()
193
+
194
+ assert len(ua._invocation_stats) == 1
195
+ assert ua._invocation_stats[0].token_usage == LanguageModelTokenUsage(
196
+ completion_tokens=10, prompt_tokens=20, total_tokens=30
197
+ )
198
+
199
+
200
+ class TestRunTokenUsageIntegration:
201
+ @pytest.mark.ai
202
+ @pytest.mark.asyncio
203
+ async def test_run__debug_info_manager__receives_llm_invocations_key(self):
204
+ """A single-iteration run with real usage on the loop response must
205
+ surface it under debug_info's 'llm_invocations' key."""
206
+ loop_response = _make_loop_response(
207
+ LanguageModelTokenUsage(
208
+ completion_tokens=12, prompt_tokens=34, total_tokens=46
209
+ )
210
+ )
211
+ ua = _build_run_ua([loop_response], max_iterations=1)
212
+
213
+ await ua.run()
214
+
215
+ calls_by_key = {
216
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
217
+ }
218
+ assert "llm_invocations" in calls_by_key
219
+ payload = calls_by_key["llm_invocations"]
220
+ assert len(payload) == 1
221
+ assert payload[0]["modelName"] == MODEL_NAME
222
+ assert payload[0]["source"] == "main_loop[1]"
223
+ assert payload[0]["tokenUsage"] == {
224
+ "completionTokens": 12,
225
+ "promptTokens": 34,
226
+ "totalTokens": 46,
227
+ "reasoningTokens": None,
228
+ "cachedTokens": None,
229
+ "cacheWriteTokens": None,
230
+ }
231
+
232
+ @pytest.mark.ai
233
+ @pytest.mark.asyncio
234
+ async def test_run__no_usage_on_any_response__llm_invocations_is_empty(
235
+ self,
236
+ ):
237
+ """When usage is never populated (e.g. no supporting provider),
238
+ the 'llm_invocations' key must still be present as an empty list,
239
+ not absent/silent."""
240
+ ua = _build_run_ua([_make_loop_response(None)], max_iterations=1)
241
+
242
+ await ua.run()
243
+
244
+ calls_by_key = {
245
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
246
+ }
247
+ assert "llm_invocations" in calls_by_key
248
+ assert calls_by_key["llm_invocations"] == []
249
+
250
+ @pytest.mark.ai
251
+ @pytest.mark.asyncio
252
+ async def test_run__postprocessor_usage__added_to_loop_usage(self):
253
+ """FollowUpPostprocessor (and any postprocessor) makes its own LLM
254
+ call outside _plan_or_execute() -- its usage must appear as its own
255
+ entry in the list, not silently dropped."""
256
+ loop_response = _make_loop_response(
257
+ LanguageModelTokenUsage(
258
+ completion_tokens=12, prompt_tokens=34, total_tokens=46
259
+ )
260
+ )
261
+ ua = _build_run_ua([loop_response], max_iterations=1)
262
+ ua._postprocessor_manager.get_invocation_stats.return_value = [
263
+ LanguageModelInvocationStats.from_usage(
264
+ MODEL_NAME,
265
+ LanguageModelTokenUsage(
266
+ completion_tokens=3, prompt_tokens=7, total_tokens=10
267
+ ),
268
+ source="FollowUpPostprocessor",
269
+ )
270
+ ]
271
+
272
+ await ua.run()
273
+
274
+ calls_by_key = {
275
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
276
+ }
277
+ payload = calls_by_key["llm_invocations"]
278
+ sources = [inv["source"] for inv in payload]
279
+ assert sources == ["main_loop[1]", "FollowUpPostprocessor"]
280
+ assert payload[0]["tokenUsage"]["totalTokens"] == 46
281
+ assert payload[1]["tokenUsage"]["totalTokens"] == 10
282
+
283
+ @pytest.mark.ai
284
+ @pytest.mark.asyncio
285
+ async def test_run__loop_runner_reports_invocation_stats__added_to_loop_usage(
286
+ self,
287
+ ):
288
+ """PlanningMiddleware (and any loop-runner wrapper implementing
289
+ SupportsInvocationStats) makes its own LLM call outside the main
290
+ completion -- e.g. a planning step before the iteration -- and its
291
+ usage must appear as its own entry in the list, not silently dropped."""
292
+ loop_response = _make_loop_response(
293
+ LanguageModelTokenUsage(
294
+ completion_tokens=12, prompt_tokens=34, total_tokens=46
295
+ )
296
+ )
297
+ ua = _build_run_ua([loop_response], max_iterations=1)
298
+ # A bare AsyncMock() satisfies hasattr() for any name but does NOT
299
+ # satisfy isinstance() against a runtime_checkable Protocol -- only
300
+ # spec=<the protocol> does, matching what real PlanningMiddleware
301
+ # instances trigger via isinstance() in production.
302
+ runner = AsyncMock(spec=SupportsInvocationStats)
303
+ runner.side_effect = [loop_response]
304
+ runner.get_invocation_stats.return_value = [
305
+ LanguageModelInvocationStats.from_usage(
306
+ MODEL_NAME,
307
+ LanguageModelTokenUsage(
308
+ completion_tokens=2, prompt_tokens=8, total_tokens=10
309
+ ),
310
+ source="planning",
311
+ )
312
+ ]
313
+ ua._loop_iteration_runner = runner
314
+
315
+ await ua.run()
316
+
317
+ calls_by_key = {
318
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
319
+ }
320
+ payload = calls_by_key["llm_invocations"]
321
+ sources = [inv["source"] for inv in payload]
322
+ assert sources == ["main_loop[1]", "planning"]
323
+ assert payload[0]["tokenUsage"]["totalTokens"] == 46
324
+ assert payload[1]["tokenUsage"]["totalTokens"] == 10
325
+
326
+ @pytest.mark.ai
327
+ @pytest.mark.asyncio
328
+ async def test_run__loop_runner_without_invocation_stats__is_a_noop(self):
329
+ """A plain runner that doesn't implement SupportsInvocationStats
330
+ (the common case: Basic/Qwen/Mistral runners with no planning
331
+ middleware) must not contribute spurious entries."""
332
+ loop_response = _make_loop_response(
333
+ LanguageModelTokenUsage(
334
+ completion_tokens=12, prompt_tokens=34, total_tokens=46
335
+ )
336
+ )
337
+ ua = _build_run_ua([loop_response], max_iterations=1)
338
+ # spec restricts this mock to only __call__ -- no get_invocation_stats
339
+ # attribute exists, so isinstance(..., SupportsInvocationStats) is False,
340
+ # mirroring a real runner (Basic/Qwen/Mistral) that doesn't implement it.
341
+ ua._loop_iteration_runner = AsyncMock(
342
+ side_effect=[loop_response], spec=["__call__"]
343
+ )
344
+
345
+ await ua.run()
346
+
347
+ calls_by_key = {
348
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
349
+ }
350
+ payload = calls_by_key["llm_invocations"]
351
+ sources = [inv["source"] for inv in payload]
352
+ assert sources == ["main_loop[1]"]
353
+
354
+ @pytest.mark.ai
355
+ @pytest.mark.asyncio
356
+ async def test_run__tool_usage__added_to_loop_usage(self):
357
+ """A tool's own internal LLM calls (e.g. web search argument screening)
358
+ report usage via ToolCallResponse.invocation_stats -- it must be folded
359
+ into the same total as the main loop, per tool-call iteration."""
360
+ tool_call = MagicMock()
361
+ tool_call.name = "WebSearch"
362
+
363
+ loop_response_with_tools = _make_loop_response(
364
+ LanguageModelTokenUsage(
365
+ completion_tokens=12, prompt_tokens=34, total_tokens=46
366
+ )
367
+ )
368
+ loop_response_with_tools.tool_calls = [tool_call]
369
+ loop_response_with_tools.is_empty.return_value = False
370
+
371
+ final_response = _make_loop_response(None)
372
+
373
+ ua = _build_run_ua([loop_response_with_tools, final_response], max_iterations=2)
374
+ ua._tool_manager.execute_selected_tools = AsyncMock(
375
+ return_value=[
376
+ ToolCallResponse(
377
+ id="call_1",
378
+ name="WebSearch",
379
+ invocation_stats=[
380
+ LanguageModelInvocationStats.from_usage(
381
+ MODEL_NAME,
382
+ LanguageModelTokenUsage(
383
+ completion_tokens=1, prompt_tokens=2, total_tokens=3
384
+ ),
385
+ source="WebSearch",
386
+ )
387
+ ],
388
+ )
389
+ ]
390
+ )
391
+
392
+ await ua.run()
393
+
394
+ calls_by_key = {
395
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
396
+ }
397
+ payload = calls_by_key["llm_invocations"]
398
+ sources = [inv["source"] for inv in payload]
399
+ assert sources == ["main_loop[1]", "WebSearch"]
400
+ assert payload[0]["tokenUsage"]["totalTokens"] == 46
401
+ assert payload[1]["tokenUsage"]["totalTokens"] == 3
402
+
403
+ @pytest.mark.ai
404
+ @pytest.mark.asyncio
405
+ async def test_run__multiple_tool_calls_in_one_iteration__usage_summed(self):
406
+ """Two (or more) tool calls in the same iteration -- e.g. two parallel
407
+ web searches -- must each contribute their own usage, not overwrite
408
+ or drop one another."""
409
+ tool_call = MagicMock()
410
+ tool_call.name = "WebSearch"
411
+
412
+ loop_response_with_tools = _make_loop_response(None)
413
+ loop_response_with_tools.tool_calls = [tool_call, tool_call]
414
+ loop_response_with_tools.is_empty.return_value = False
415
+
416
+ final_response = _make_loop_response(None)
417
+
418
+ ua = _build_run_ua([loop_response_with_tools, final_response], max_iterations=2)
419
+ ua._tool_manager.execute_selected_tools = AsyncMock(
420
+ return_value=[
421
+ ToolCallResponse(
422
+ id="call_1",
423
+ name="WebSearch",
424
+ invocation_stats=[
425
+ LanguageModelInvocationStats.from_usage(
426
+ MODEL_NAME,
427
+ LanguageModelTokenUsage(
428
+ completion_tokens=1, prompt_tokens=2, total_tokens=3
429
+ ),
430
+ source="WebSearch",
431
+ )
432
+ ],
433
+ ),
434
+ ToolCallResponse(
435
+ id="call_2",
436
+ name="WebSearch",
437
+ invocation_stats=[
438
+ LanguageModelInvocationStats.from_usage(
439
+ MODEL_NAME,
440
+ LanguageModelTokenUsage(
441
+ completion_tokens=4, prompt_tokens=5, total_tokens=9
442
+ ),
443
+ source="WebSearch",
444
+ )
445
+ ],
446
+ ),
447
+ ]
448
+ )
449
+
450
+ await ua.run()
451
+
452
+ calls_by_key = {
453
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
454
+ }
455
+ payload = calls_by_key["llm_invocations"]
456
+ assert len(payload) == 2
457
+ assert payload[0]["tokenUsage"]["totalTokens"] == 3
458
+ assert payload[1]["tokenUsage"]["totalTokens"] == 9
459
+
460
+ @pytest.mark.ai
461
+ @pytest.mark.asyncio
462
+ async def test_run__reset_between_runs__does_not_leak_across_calls(self):
463
+ """_invocation_stats must reset at the start of run(), so a second run()
464
+ on the same UniqueAI instance doesn't inherit the prior run's entries."""
465
+ loop_response = _make_loop_response(
466
+ LanguageModelTokenUsage(
467
+ completion_tokens=5, prompt_tokens=5, total_tokens=10
468
+ )
469
+ )
470
+ ua = _build_run_ua([loop_response, loop_response], max_iterations=1)
471
+
472
+ await ua.run()
473
+ await ua.run()
474
+
475
+ calls_by_key = {
476
+ args[0][0]: args[0][1] for args in ua._debug_info_manager.add.call_args_list
477
+ }
478
+ payload = calls_by_key["llm_invocations"]
479
+ assert len(payload) == 1
480
+ assert payload[0]["tokenUsage"]["totalTokens"] == 10
@@ -21,6 +21,7 @@ from unique_toolkit.agentic.history_manager.utils import (
21
21
  from unique_toolkit.agentic.loop_runner import (
22
22
  LoopIterationRunner,
23
23
  ResponsesLoopIterationRunner,
24
+ SupportsInvocationStats,
24
25
  )
25
26
  from unique_toolkit.agentic.message_log_manager.service import MessageStepLogger
26
27
  from unique_toolkit.agentic.postprocessor.postprocessor_manager import (
@@ -32,6 +33,12 @@ from unique_toolkit.agentic.tools.experimental.open_file_tool import (
32
33
  OpenFileToolRuntime,
33
34
  OpenFileToolRuntimeConfig,
34
35
  )
36
+ from unique_toolkit.agentic.tools.openai_builtin.code_interpreter import (
37
+ DisplayCodeInterpreterFilesPostProcessor,
38
+ )
39
+ from unique_toolkit.agentic.tools.openai_builtin.code_interpreter.postprocessors.generated_files import (
40
+ ArtifactsDebugInfo,
41
+ )
35
42
  from unique_toolkit.agentic.tools.tool_manager import (
36
43
  ResponsesApiToolManager,
37
44
  SafeTaskExecutor,
@@ -43,6 +50,9 @@ from unique_toolkit.chat.service import ChatService
43
50
  from unique_toolkit.content import Content
44
51
  from unique_toolkit.content.service import ContentService
45
52
  from unique_toolkit.language_model import LanguageModelAssistantMessage
53
+ from unique_toolkit.language_model.invocation_stats import (
54
+ LanguageModelInvocationStats,
55
+ )
46
56
  from unique_toolkit.language_model.schemas import (
47
57
  LanguageModelMessages,
48
58
  LanguageModelStreamResponse,
@@ -197,6 +207,8 @@ class UniqueAI:
197
207
  self._execution_times: list[dict[str, Any]] = []
198
208
  self._current_loop_timing: dict[str, Any] = {}
199
209
  self._loop_debug_params: list[dict[str, Any]] = []
210
+ self._generated_files_info: ArtifactsDebugInfo | None = None
211
+ self._invocation_stats: list[LanguageModelInvocationStats] = []
200
212
 
201
213
  async def _on_cancellation(self, _event: CancellationEvent) -> None:
202
214
  """Subscriber called by the cancellation event bus."""
@@ -222,6 +234,11 @@ class UniqueAI:
222
234
 
223
235
  self._execution_times = []
224
236
  self._loop_debug_params = []
237
+ # Reset per-run artifact analytics too: a later run that exits without
238
+ # reaching _handle_no_tool_calls (tool takes control / empty response /
239
+ # cancellation) must not report the previous run's artifacts.
240
+ self._generated_files_info = None
241
+ self._invocation_stats = []
225
242
  run_start = time.perf_counter()
226
243
 
227
244
  await preload_invoked_skills(
@@ -257,6 +274,27 @@ class UniqueAI:
257
274
  time.perf_counter() - planning_start, 3
258
275
  )
259
276
  self._logger.info("Done with _plan_or_execute")
277
+ if loop_response.usage is not None:
278
+ self._invocation_stats.append(
279
+ LanguageModelInvocationStats.from_usage(
280
+ self._config.space.language_model.name,
281
+ loop_response.usage,
282
+ source=f"main_loop[{i + 1}]",
283
+ )
284
+ )
285
+ # TODO(UN-20907): if _plan_or_execute() above raises an exception that
286
+ # propagates out of this loop uncaught, execution never reaches this
287
+ # drain call -- any planning-step usage recorded on the runner this
288
+ # iteration is lost. In practice this is not planning-specific: an
289
+ # uncaught abort here also skips the debug_info["llm_invocations"]
290
+ # write entirely (see the `finally` at the end of run()), so every
291
+ # usage source (main_loop, evaluations, postprocessors) is equally
292
+ # absent from that turn, not just planning. Revisit only if partial
293
+ # debug_info persistence on an aborted run becomes a requirement.
294
+ if isinstance(self._loop_iteration_runner, SupportsInvocationStats):
295
+ self._invocation_stats.extend(
296
+ self._loop_iteration_runner.get_invocation_stats()
297
+ )
260
298
 
261
299
  if await self._chat_service.cancellation.check_cancellation_async():
262
300
  self._finalize_loop_timing(loop_start)
@@ -318,6 +356,13 @@ class UniqueAI:
318
356
  self._debug_info_manager.add("loop_params", self._loop_debug_params)
319
357
  skills_debug_info = self._get_activated_skills_debug_info()
320
358
  self._debug_info_manager.add("skills", skills_debug_info)
359
+ self._debug_info_manager.add(
360
+ "llm_invocations",
361
+ [
362
+ invocation.model_dump(by_alias=True)
363
+ for invocation in self._invocation_stats
364
+ ],
365
+ )
321
366
  reference_sources = {
322
367
  ("url", reference.url)
323
368
  if reference.url is not None
@@ -363,6 +408,7 @@ class UniqueAI:
363
408
  answer_length=len(self._last_assistant_text or ""),
364
409
  loop_iteration_count=len(self._execution_times),
365
410
  total_time_to_answer_ms=total_time_to_answer_ms,
411
+ artifacts=self._generated_files_info,
366
412
  )
367
413
 
368
414
  # Get current debug info from chat service and add debug info from run. Do not update if DeepResearch is in the tool names.
@@ -696,14 +742,25 @@ class UniqueAI:
696
742
  loop_response.model_copy(deep=True),
697
743
  )
698
744
 
699
- _, evaluation_results = await asyncio.gather(
745
+ postprocessor_result, evaluation_results = await asyncio.gather(
700
746
  postprocessor_result,
701
747
  evaluation_results,
702
748
  )
703
-
749
+ postprocessor_outputs = postprocessor_result.unpack() or {}
750
+ # run_postprocessors erases each postprocessor's return type to
751
+ # `object | None` (generic channel), so re-narrow the one we own.
752
+ self._generated_files_info = cast(
753
+ "ArtifactsDebugInfo | None",
754
+ postprocessor_outputs.get(
755
+ DisplayCodeInterpreterFilesPostProcessor.__name__
756
+ ),
757
+ )
704
758
  self._current_loop_timing["post_processing"].update(
705
759
  self._postprocessor_manager.get_execution_times()
706
760
  )
761
+ self._invocation_stats.extend(
762
+ self._postprocessor_manager.get_invocation_stats()
763
+ )
707
764
 
708
765
  evaluation_times = self._evaluation_manager.get_execution_times()
709
766
  for name in selected_evaluation_names:
@@ -711,6 +768,7 @@ class UniqueAI:
711
768
  self._current_loop_timing["evaluation"][name_str] = evaluation_times.get(
712
769
  name_str, 0
713
770
  )
771
+ self._invocation_stats.extend(self._evaluation_manager.get_invocation_stats())
714
772
 
715
773
  if evaluation_results.success and not all(
716
774
  result.is_positive for result in evaluation_results.unpack()
@@ -818,6 +876,9 @@ class UniqueAI:
818
876
  )
819
877
  execution_total = round(time.perf_counter() - execution_start, 3)
820
878
 
879
+ for response in tool_call_responses:
880
+ self._invocation_stats.extend(response.invocation_stats)
881
+
821
882
  tool_times: dict[str, float] = {}
822
883
  for response in tool_call_responses:
823
884
  if response.debug_info and "execution_time_s" in response.debug_info: