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.
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/PKG-INFO +7 -7
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/pyproject.toml +7 -7
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_unique_ai_token_usage.py +354 -1
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/unique_ai.py +131 -9
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/README.md +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/__init__.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/_builders/__init__.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/_builders/history_manager.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/_builders/inject_tool_reminders.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/_builders/loop_iteration_runner.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/_builders/open_file_setup.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/_builders/skill_setup.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/config.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/prompts/generic_reference_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/prompts/system_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/prompts/user_message_prompt.jinja2 +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/settings.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_agent_config_rjsf_ui_schema.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_build_loop_iteration_runner.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_config_services_validators.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_qwen_max_loop_iterations.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_register_code_interpreter_postprocessors.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_unique_ai_async_modify.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_unique_ai_execution_timing.py +0 -0
- {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
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_unique_ai_log_tool_calls.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_unique_ai_loop_debug_params.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_unique_ai_persist_tool_calls.py +0 -0
- {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
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_unique_ai_reference_order.py +0 -0
- {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
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/tests/test_utils_resolve_other_options.py +0 -0
- {unique_orchestrator-2026.32.0.dev0 → unique_orchestrator-2026.32.0.dev2}/unique_orchestrator/unique_ai_builder.py +0 -0
- {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.
|
|
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.
|
|
13
|
-
Requires-Dist: unique-stock-ticker>=2026.32.0.
|
|
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.
|
|
16
|
-
Requires-Dist: unique-deep-research>=2026.32.0.
|
|
17
|
-
Requires-Dist: unique-web-search>=2026.32.0.
|
|
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.
|
|
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.
|
|
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.
|
|
17
|
-
"unique-stock-ticker>=2026.32.0.
|
|
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.
|
|
20
|
-
"unique-deep-research>=2026.32.0.
|
|
21
|
-
"unique-web-search>=2026.32.0.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
#
|
|
421
|
-
|
|
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
|
-
**
|
|
424
|
-
|
|
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
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|