unique_orchestrator 2026.30.0.dev7__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.dev7 → unique_orchestrator-2026.30.0.dev8}/PKG-INFO +3 -3
  2. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/pyproject.toml +3 -3
  3. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_async_modify.py +5 -0
  4. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_execution_timing.py +6 -0
  5. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_loop_debug_params.py +1 -0
  6. {unique_orchestrator-2026.30.0.dev7 → 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.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/unique_ai.py +41 -0
  9. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/README.md +0 -0
  10. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/__init__.py +0 -0
  11. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/__init__.py +0 -0
  12. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/history_manager.py +0 -0
  13. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/inject_tool_reminders.py +0 -0
  14. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/loop_iteration_runner.py +0 -0
  15. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/open_file_setup.py +0 -0
  16. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/_builders/skill_setup.py +0 -0
  17. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/config.py +0 -0
  18. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/prompts/generic_reference_prompt.jinja2 +0 -0
  19. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/prompts/system_prompt.jinja2 +0 -0
  20. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/prompts/user_message_prompt.jinja2 +0 -0
  21. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/settings.py +0 -0
  22. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_agent_config_rjsf_ui_schema.py +0 -0
  23. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_build_loop_iteration_runner.py +0 -0
  24. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_config_services_validators.py +0 -0
  25. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_qwen_max_loop_iterations.py +0 -0
  26. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_register_code_interpreter_postprocessors.py +0 -0
  27. {unique_orchestrator-2026.30.0.dev7 → 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.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_log_tool_calls.py +0 -0
  29. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_unique_ai_persist_tool_calls.py +0 -0
  30. {unique_orchestrator-2026.30.0.dev7 → 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.dev7 → 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.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/tests/test_utils_resolve_other_options.py +0 -0
  33. {unique_orchestrator-2026.30.0.dev7 → unique_orchestrator-2026.30.0.dev8}/unique_orchestrator/unique_ai_builder.py +0 -0
  34. {unique_orchestrator-2026.30.0.dev7 → 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.dev7
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.dev19,<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.dev7"
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.dev19,<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",
@@ -56,10 +56,12 @@ def _make_ua(monkeypatch, *, feature_flag_enabled: bool = False):
56
56
  mock_postprocessor_manager = MagicMock()
57
57
  mock_postprocessor_manager.run_postprocessors = AsyncMock(return_value=None)
58
58
  mock_postprocessor_manager.get_execution_times.return_value = {}
59
+ mock_postprocessor_manager.get_invocation_stats.return_value = []
59
60
 
60
61
  mock_evaluation_manager = MagicMock()
61
62
  mock_evaluation_manager.run_evaluations = AsyncMock(return_value=[])
62
63
  mock_evaluation_manager.get_execution_times.return_value = {}
64
+ mock_evaluation_manager.get_invocation_stats.return_value = []
63
65
 
64
66
  dummy_event = MagicMock()
65
67
  dummy_event.payload.assistant_message.id = "assist_1"
@@ -89,8 +91,10 @@ def _make_ua(monkeypatch, *, feature_flag_enabled: bool = False):
89
91
  ua._execution_times = []
90
92
  ua._current_loop_timing = {}
91
93
  ua._generated_files_info = None
94
+ ua._invocation_stats = []
92
95
  ua.current_iteration_index = 0
93
96
  ua._skill_choices = []
97
+ ua._loop_iteration_runner = MagicMock()
94
98
 
95
99
  return ua
96
100
 
@@ -107,6 +111,7 @@ async def test_run_calls_modify_async_when_feature_flag_disabled(monkeypatch):
107
111
  empty_response.message.references = []
108
112
  empty_response.tool_calls = None
109
113
  empty_response.is_empty.return_value = True
114
+ empty_response.usage = None
110
115
 
111
116
  ua._plan_or_execute = AsyncMock(return_value=empty_response)
112
117
 
@@ -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
 
@@ -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 (
@@ -49,6 +50,9 @@ from unique_toolkit.chat.service import ChatService
49
50
  from unique_toolkit.content import Content
50
51
  from unique_toolkit.content.service import ContentService
51
52
  from unique_toolkit.language_model import LanguageModelAssistantMessage
53
+ from unique_toolkit.language_model.invocation_stats import (
54
+ LanguageModelInvocationStats,
55
+ )
52
56
  from unique_toolkit.language_model.schemas import (
53
57
  LanguageModelMessages,
54
58
  LanguageModelStreamResponse,
@@ -204,6 +208,7 @@ class UniqueAI:
204
208
  self._current_loop_timing: dict[str, Any] = {}
205
209
  self._loop_debug_params: list[dict[str, Any]] = []
206
210
  self._generated_files_info: ArtifactsDebugInfo | None = None
211
+ self._invocation_stats: list[LanguageModelInvocationStats] = []
207
212
 
208
213
  async def _on_cancellation(self, _event: CancellationEvent) -> None:
209
214
  """Subscriber called by the cancellation event bus."""
@@ -233,6 +238,7 @@ class UniqueAI:
233
238
  # reaching _handle_no_tool_calls (tool takes control / empty response /
234
239
  # cancellation) must not report the previous run's artifacts.
235
240
  self._generated_files_info = None
241
+ self._invocation_stats = []
236
242
  run_start = time.perf_counter()
237
243
 
238
244
  await preload_invoked_skills(
@@ -268,6 +274,27 @@ class UniqueAI:
268
274
  time.perf_counter() - planning_start, 3
269
275
  )
270
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
+ )
271
298
 
272
299
  if await self._chat_service.cancellation.check_cancellation_async():
273
300
  self._finalize_loop_timing(loop_start)
@@ -329,6 +356,13 @@ class UniqueAI:
329
356
  self._debug_info_manager.add("loop_params", self._loop_debug_params)
330
357
  skills_debug_info = self._get_activated_skills_debug_info()
331
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
+ )
332
366
  reference_sources = {
333
367
  ("url", reference.url)
334
368
  if reference.url is not None
@@ -724,6 +758,9 @@ class UniqueAI:
724
758
  self._current_loop_timing["post_processing"].update(
725
759
  self._postprocessor_manager.get_execution_times()
726
760
  )
761
+ self._invocation_stats.extend(
762
+ self._postprocessor_manager.get_invocation_stats()
763
+ )
727
764
 
728
765
  evaluation_times = self._evaluation_manager.get_execution_times()
729
766
  for name in selected_evaluation_names:
@@ -731,6 +768,7 @@ class UniqueAI:
731
768
  self._current_loop_timing["evaluation"][name_str] = evaluation_times.get(
732
769
  name_str, 0
733
770
  )
771
+ self._invocation_stats.extend(self._evaluation_manager.get_invocation_stats())
734
772
 
735
773
  if evaluation_results.success and not all(
736
774
  result.is_positive for result in evaluation_results.unpack()
@@ -838,6 +876,9 @@ class UniqueAI:
838
876
  )
839
877
  execution_total = round(time.perf_counter() - execution_start, 3)
840
878
 
879
+ for response in tool_call_responses:
880
+ self._invocation_stats.extend(response.invocation_stats)
881
+
841
882
  tool_times: dict[str, float] = {}
842
883
  for response in tool_call_responses:
843
884
  if response.debug_info and "execution_time_s" in response.debug_info: