unique_orchestrator 2026.30.0.dev4__tar.gz → 2026.30.0.dev5__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 (33) hide show
  1. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/PKG-INFO +2 -2
  2. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/pyproject.toml +2 -2
  3. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_execution_timing.py +28 -4
  4. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_loop_debug_params.py +119 -3
  5. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/unique_ai.py +68 -13
  6. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/README.md +0 -0
  7. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/__init__.py +0 -0
  8. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/_builders/__init__.py +0 -0
  9. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/_builders/history_manager.py +0 -0
  10. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/_builders/inject_tool_reminders.py +0 -0
  11. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/_builders/loop_iteration_runner.py +0 -0
  12. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/_builders/open_file_setup.py +0 -0
  13. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/_builders/skill_setup.py +0 -0
  14. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/config.py +0 -0
  15. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/prompts/generic_reference_prompt.jinja2 +0 -0
  16. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/prompts/system_prompt.jinja2 +0 -0
  17. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/prompts/user_message_prompt.jinja2 +0 -0
  18. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/settings.py +0 -0
  19. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_agent_config_rjsf_ui_schema.py +0 -0
  20. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_build_loop_iteration_runner.py +0 -0
  21. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_config_services_validators.py +0 -0
  22. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_qwen_max_loop_iterations.py +0 -0
  23. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_register_code_interpreter_postprocessors.py +0 -0
  24. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_async_modify.py +0 -0
  25. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_get_filtered_user_metadata.py +0 -0
  26. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_log_tool_calls.py +0 -0
  27. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_persist_tool_calls.py +0 -0
  28. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_plan_or_execute_include.py +0 -0
  29. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_reference_order.py +0 -0
  30. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_unique_ai_render_system_prompt_user_instructions.py +0 -0
  31. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/tests/test_utils_resolve_other_options.py +0 -0
  32. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/unique_orchestrator/unique_ai_builder.py +0 -0
  33. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev5}/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.dev4
3
+ Version: 2026.30.0.dev5
4
4
  Summary:
5
5
  Author: Andreas Hauri
6
6
  Author-email: Andreas Hauri <andreas.hauri@unique.ai>
@@ -9,7 +9,7 @@ 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.dev14,<2026.30.0rc0
12
+ Requires-Dist: unique-toolkit>=2026.30.0.dev15,<2026.30.0rc0
13
13
  Requires-Dist: unique-stock-ticker>=2026.30.0.dev0,<2026.30.0rc0
14
14
  Requires-Dist: unique-follow-up-questions>=2026.30.0.dev0,<2026.30.0rc0
15
15
  Requires-Dist: unique-internal-search>=2026.30.0.dev0,<2026.30.0rc0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_orchestrator"
3
- version = "2026.30.0.dev4"
3
+ version = "2026.30.0.dev5"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "Proprietary" }
@@ -13,7 +13,7 @@ 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.dev14,<2026.30.0rc0",
16
+ "unique-toolkit>=2026.30.0.dev15,<2026.30.0rc0",
17
17
  "unique-stock-ticker>=2026.30.0.dev0,<2026.30.0rc0",
18
18
  "unique-follow-up-questions>=2026.30.0.dev0,<2026.30.0rc0",
19
19
  "unique-internal-search>=2026.30.0.dev0,<2026.30.0rc0",
@@ -1,4 +1,5 @@
1
1
  import time
2
+ from datetime import datetime, timezone
2
3
  from unittest.mock import AsyncMock, MagicMock
3
4
 
4
5
  import pytest
@@ -735,6 +736,27 @@ class TestRunExecutionTimingIntegration:
735
736
  assert "loop_iterations" in payload
736
737
  assert "total_time" in payload
737
738
 
739
+ @pytest.mark.ai
740
+ @pytest.mark.asyncio
741
+ async def test_run__analytics__includes_total_time_to_answer_ms(
742
+ self, monkeypatch
743
+ ) -> None:
744
+ """
745
+ Purpose: Verify analytics measures from user creation through completion.
746
+ Why this matters: Internal loop timing excludes queueing and completion latency.
747
+ Setup summary: Return a known completion timestamp and assert the full delta.
748
+ """
749
+ ua = self._build_run_ua(monkeypatch)
750
+ ua._event.payload.user_message.created_at = "2026-07-13T20:00:00Z"
751
+ ua._chat_service.modify_assistant_message_async.return_value = MagicMock(
752
+ completed_at=datetime(2026, 7, 13, 20, 0, 2, 345000, tzinfo=timezone.utc)
753
+ )
754
+
755
+ await ua.run()
756
+
757
+ analytics_call = ua._debug_info_manager.add_analytics.call_args
758
+ assert analytics_call.kwargs["total_time_to_answer_ms"] == 2345
759
+
738
760
  @pytest.mark.ai
739
761
  @pytest.mark.asyncio
740
762
  async def test_run__execution_times__contains_one_entry_after_single_iteration(
@@ -836,10 +858,10 @@ class TestRunExecutionTimingIntegration:
836
858
  ) -> None:
837
859
  """
838
860
  Purpose: Verify that a failure in update_debug_info_async propagates to the caller.
839
- Why this matters: After refactoring, debug info failures are no longer silently
840
- swallowed; callers must handle them.
861
+ Why this matters: Debug failures remain visible without preventing answer
862
+ completion.
841
863
  Setup summary: Make update_debug_info_async raise; run; assert RuntimeError
842
- propagates and modify_assistant_message_async is never reached.
864
+ propagates after modify_assistant_message_async completes the answer.
843
865
  """
844
866
  ua = self._build_run_ua(monkeypatch)
845
867
  ua._chat_service.update_debug_info_async.side_effect = RuntimeError("boom")
@@ -847,7 +869,9 @@ class TestRunExecutionTimingIntegration:
847
869
  with pytest.raises(RuntimeError, match="boom"):
848
870
  await ua.run()
849
871
 
850
- ua._chat_service.modify_assistant_message_async.assert_not_called()
872
+ ua._chat_service.modify_assistant_message_async.assert_awaited_once_with(
873
+ set_completed_at=True
874
+ )
851
875
 
852
876
  @pytest.mark.ai
853
877
  @pytest.mark.asyncio
@@ -76,6 +76,7 @@ def _make_loop_response() -> MagicMock:
76
76
  response.message = MagicMock()
77
77
  response.message.references = []
78
78
  response.message.text = "response text"
79
+ response.message.original_text = "original response text"
79
80
  response.is_empty.return_value = False
80
81
  return response
81
82
 
@@ -256,6 +257,9 @@ class TestRunLoopDebugParams:
256
257
  mock_chat_service.cancellation = mock_cancellation
257
258
  mock_chat_service.get_debug_info_async = AsyncMock(return_value={})
258
259
  mock_chat_service.update_debug_info_async = AsyncMock(return_value=None)
260
+ # Returns None by default → no completed_at, so total_time_to_answer_ms
261
+ # resolves to None. Tests that exercise timing override this return value
262
+ # with a MagicMock(completed_at=...).
259
263
  mock_chat_service.modify_assistant_message_async = AsyncMock(return_value=None)
260
264
  mock_chat_service.create_assistant_message_async = AsyncMock(
261
265
  return_value=MagicMock(id="assist_new")
@@ -279,6 +283,9 @@ class TestRunLoopDebugParams:
279
283
  mock_config = MagicMock()
280
284
  mock_config.effective_max_loop_iterations = 1
281
285
  mock_config.agent.prompt_config.user_metadata = []
286
+ mock_config.space.language_model.name = "AZURE_GPT_5_2025_0807"
287
+ mock_config.space.language_model.family = "openai"
288
+ mock_config.space.language_model.provider = "AZURE"
282
289
 
283
290
  mock_history_manager = MagicMock()
284
291
  mock_history_manager.get_history_for_model_call = AsyncMock(
@@ -345,6 +352,10 @@ class TestRunLoopDebugParams:
345
352
  as the first argument.
346
353
  """
347
354
  ua = self._build_run_ua(monkeypatch)
355
+ tool = MagicMock()
356
+ tool.name = "InternalSearch"
357
+ tool.display_name.return_value = "Knowledge Base Search"
358
+ ua._tool_manager.available_tools = [tool]
348
359
 
349
360
  await ua.run()
350
361
 
@@ -353,14 +364,119 @@ class TestRunLoopDebugParams:
353
364
  ]
354
365
  assert "loop_params" in keys_written
355
366
  assert "skills" in keys_written
367
+ # This test owns one contract: run() forwards the same skills value it
368
+ # wrote under "skills" as the first positional arg to add_analytics.
369
+ # Per-field kwargs (references / timing / lengths) are covered by the
370
+ # focused sibling tests below, so we don't re-pin the whole call here.
356
371
  skills_call = next(
357
372
  c
358
373
  for c in ua._debug_info_manager.add.call_args_list
359
374
  if c.args[0] == "skills"
360
375
  )
361
- ua._debug_info_manager.add_analytics.assert_called_once_with(
362
- skills_call.args[1]
363
- )
376
+ ua._debug_info_manager.add_analytics.assert_called_once()
377
+ analytics_call = ua._debug_info_manager.add_analytics.call_args
378
+ assert analytics_call.args[0] == skills_call.args[1]
379
+ assert analytics_call.kwargs["tool_display_names"] == {
380
+ "InternalSearch": "Knowledge Base Search"
381
+ }
382
+
383
+ @pytest.mark.ai
384
+ @pytest.mark.asyncio
385
+ async def test_run__analytics__counts_unique_reference_sources(
386
+ self, monkeypatch
387
+ ) -> None:
388
+ ua = self._build_run_ua(monkeypatch)
389
+ references = [
390
+ MagicMock(url="source_1"),
391
+ MagicMock(url="source_1"),
392
+ MagicMock(url="source_2"),
393
+ MagicMock(url="source_3"),
394
+ MagicMock(url="source_3"),
395
+ ]
396
+ ua._loop_iteration_runner.return_value.message.references = references
397
+ ua._reference_manager.get_references.return_value = [references]
398
+
399
+ await ua.run()
400
+
401
+ analytics_call = ua._debug_info_manager.add_analytics.call_args
402
+ assert analytics_call.kwargs["references"] == 3
403
+
404
+ @pytest.mark.ai
405
+ @pytest.mark.asyncio
406
+ async def test_run__analytics__counts_references_across_iterations(
407
+ self, monkeypatch
408
+ ) -> None:
409
+ """
410
+ Purpose: Verify analytics includes reference sources from every loop iteration.
411
+ Why this matters: Earlier citations belong to the same run and must not be
412
+ omitted when a later iteration appends another reference batch.
413
+ Setup summary: Return two stored batches with one duplicate source and assert
414
+ that all three distinct sources are counted.
415
+ """
416
+ ua = self._build_run_ua(monkeypatch)
417
+ first_iteration_references = [
418
+ MagicMock(url="source_1"),
419
+ MagicMock(url="source_2"),
420
+ ]
421
+ latest_iteration_references = [
422
+ MagicMock(url="source_2"),
423
+ MagicMock(url="source_3"),
424
+ ]
425
+ ua._reference_manager.get_references.return_value = [
426
+ first_iteration_references,
427
+ latest_iteration_references,
428
+ ]
429
+
430
+ await ua.run()
431
+
432
+ analytics_call = ua._debug_info_manager.add_analytics.call_args
433
+ assert analytics_call.kwargs["references"] == 3
434
+
435
+ @pytest.mark.ai
436
+ @pytest.mark.asyncio
437
+ async def test_run__analytics__counts_distinct_references_without_urls(
438
+ self, monkeypatch
439
+ ) -> None:
440
+ """
441
+ Purpose: Verify URL-less references are identified by source and source ID.
442
+ Why this matters: MCP citations can omit URLs and must not collapse into one
443
+ analytics source.
444
+ Setup summary: Run with two distinct URL-less references and assert both count.
445
+ """
446
+ ua = self._build_run_ua(monkeypatch)
447
+ references = [
448
+ MagicMock(url=None, source="mcp", source_id="resource_1"),
449
+ MagicMock(url=None, source="mcp", source_id="resource_2"),
450
+ ]
451
+ ua._loop_iteration_runner.return_value.message.references = references
452
+ ua._reference_manager.get_references.return_value = [references]
453
+
454
+ await ua.run()
455
+
456
+ analytics_call = ua._debug_info_manager.add_analytics.call_args
457
+ assert analytics_call.kwargs["references"] == 2
458
+
459
+ @pytest.mark.ai
460
+ @pytest.mark.asyncio
461
+ async def test_run__analytics__total_time_to_answer_ms_none_without_completed_at(
462
+ self, monkeypatch
463
+ ) -> None:
464
+ """
465
+ Purpose: Verify total_time_to_answer_ms is None when the completed message
466
+ carries no completed_at.
467
+ Why this matters: Makes the None case deliberate — run() must degrade to
468
+ None (not crash) when completion time is unavailable, e.g. a tool took
469
+ control. Pairs with test_run__analytics__includes_total_time_to_answer_ms
470
+ (execution-timing suite) which covers the populated case.
471
+ Setup summary: modify_assistant_message_async returns None (fixture default);
472
+ assert the kwarg is present and None.
473
+ """
474
+ ua = self._build_run_ua(monkeypatch)
475
+
476
+ await ua.run()
477
+
478
+ analytics_call = ua._debug_info_manager.add_analytics.call_args
479
+ assert analytics_call.kwargs["total_time_to_answer_ms"] is None
364
480
 
365
481
  @pytest.mark.ai
366
482
  @pytest.mark.asyncio
@@ -1,6 +1,6 @@
1
1
  import asyncio
2
2
  import time
3
- from datetime import datetime
3
+ from datetime import datetime, timezone
4
4
  from logging import Logger
5
5
  from typing import Any, cast, overload
6
6
 
@@ -8,6 +8,7 @@ import jinja2
8
8
  from typing_extensions import deprecated
9
9
  from unique_skill_tool.service import SkillTool
10
10
  from unique_toolkit.agentic.debug_info_manager.debug_info_manager import (
11
+ AnalyticsLanguageModel,
11
12
  DebugInfoManager,
12
13
  )
13
14
  from unique_toolkit.agentic.evaluation.evaluation_manager import EvaluationManager
@@ -261,9 +262,8 @@ class UniqueAI:
261
262
  self._finalize_loop_timing(loop_start)
262
263
  break
263
264
 
264
- self._reference_manager.add_references(
265
- loop_response.message.references or []
266
- )
265
+ references = loop_response.message.references or []
266
+ self._reference_manager.add_references(references)
267
267
  self._last_assistant_text = (
268
268
  loop_response.message.original_text or loop_response.message.text
269
269
  )
@@ -318,12 +318,53 @@ class UniqueAI:
318
318
  self._debug_info_manager.add("loop_params", self._loop_debug_params)
319
319
  skills_debug_info = self._get_activated_skills_debug_info()
320
320
  self._debug_info_manager.add("skills", skills_debug_info)
321
- self._debug_info_manager.add_analytics(skills_debug_info)
322
-
321
+ reference_sources = {
322
+ ("url", reference.url)
323
+ if reference.url is not None
324
+ else ("source", reference.source, reference.source_id)
325
+ for references in self._reference_manager.get_references()
326
+ for reference in references
327
+ }
328
+ language_model = self._config.space.language_model
329
+ tool_display_names = {
330
+ str(tool.name): tool.display_name() or str(tool.name)
331
+ for tool in self._tool_manager.available_tools
332
+ }
323
333
  tool_names = [
324
334
  tool["name"] for tool in self._debug_info_manager.get()["tools"]
325
335
  ]
326
336
 
337
+ total_time_to_answer_ms: int | None = None
338
+ if not self._chat_service.cancellation.is_cancelled:
339
+ if self._config.agent.input_token_distribution.enable_tool_call_persistence:
340
+ await self._persist_tool_calls()
341
+ completed_message = (
342
+ await self._chat_service.modify_assistant_message_async(
343
+ set_completed_at=not self._tool_took_control,
344
+ )
345
+ )
346
+ total_time_to_answer_ms = self._calculate_total_time_to_answer_ms(
347
+ user_message_created_at=self._event.payload.user_message.created_at,
348
+ assistant_completed_at=getattr(
349
+ completed_message, "completed_at", None
350
+ ),
351
+ )
352
+
353
+ self._debug_info_manager.add_analytics(
354
+ skills_debug_info,
355
+ language_model=AnalyticsLanguageModel(
356
+ name=str(language_model.name),
357
+ family=str(language_model.family),
358
+ provider=str(language_model.provider),
359
+ ),
360
+ tool_display_names=tool_display_names,
361
+ references=len(reference_sources),
362
+ user_prompt_length=len(self._event.payload.user_message.text),
363
+ answer_length=len(self._last_assistant_text or ""),
364
+ loop_iteration_count=len(self._execution_times),
365
+ total_time_to_answer_ms=total_time_to_answer_ms,
366
+ )
367
+
327
368
  # Get current debug info from chat service and add debug info from run. Do not update if DeepResearch is in the tool names.
328
369
  if "DeepResearch" not in tool_names:
329
370
  debug_info = {
@@ -331,16 +372,30 @@ class UniqueAI:
331
372
  **self._debug_info_manager.get(),
332
373
  }
333
374
  await self._chat_service.update_debug_info_async(debug_info=debug_info)
334
-
335
- if not self._chat_service.cancellation.is_cancelled:
336
- if self._config.agent.input_token_distribution.enable_tool_call_persistence:
337
- await self._persist_tool_calls()
338
- await self._chat_service.modify_assistant_message_async(
339
- set_completed_at=not self._tool_took_control,
340
- )
341
375
  finally:
342
376
  sub.cancel()
343
377
 
378
+ @staticmethod
379
+ def _calculate_total_time_to_answer_ms(
380
+ user_message_created_at: str,
381
+ assistant_completed_at: datetime | None,
382
+ ) -> int | None:
383
+ if assistant_completed_at is None:
384
+ return None
385
+
386
+ try:
387
+ user_created_at = datetime.fromisoformat(user_message_created_at)
388
+ except (ValueError, TypeError):
389
+ # ValueError: malformed timestamp; TypeError: created_at not a str.
390
+ return None
391
+
392
+ if user_created_at.tzinfo is None:
393
+ user_created_at = user_created_at.replace(tzinfo=timezone.utc)
394
+ if assistant_completed_at.tzinfo is None:
395
+ assistant_completed_at = assistant_completed_at.replace(tzinfo=timezone.utc)
396
+
397
+ return round((assistant_completed_at - user_created_at).total_seconds() * 1000)
398
+
344
399
  def _record_loop_debug_params(self, other_options: dict) -> None:
345
400
  reasoning = other_options.get("reasoning")
346
401
  thinking_level: str = (