unique_orchestrator 2026.30.0.dev4__tar.gz → 2026.30.0.dev6__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.dev4 → unique_orchestrator-2026.30.0.dev6}/PKG-INFO +2 -2
  2. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/pyproject.toml +2 -2
  3. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/_builders/history_manager.py +5 -0
  4. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_unique_ai_execution_timing.py +28 -4
  5. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_unique_ai_loop_debug_params.py +119 -3
  6. unique_orchestrator-2026.30.0.dev6/unique_orchestrator/tests/test_unique_ai_plan_or_execute_include.py +76 -0
  7. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/unique_ai.py +74 -14
  8. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/unique_ai_builder.py +5 -18
  9. unique_orchestrator-2026.30.0.dev4/unique_orchestrator/tests/test_unique_ai_plan_or_execute_include.py +0 -120
  10. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/README.md +0 -0
  11. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/__init__.py +0 -0
  12. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/_builders/__init__.py +0 -0
  13. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/_builders/inject_tool_reminders.py +0 -0
  14. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/_builders/loop_iteration_runner.py +0 -0
  15. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/_builders/open_file_setup.py +0 -0
  16. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/_builders/skill_setup.py +0 -0
  17. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/config.py +0 -0
  18. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/prompts/generic_reference_prompt.jinja2 +0 -0
  19. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/prompts/system_prompt.jinja2 +0 -0
  20. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/prompts/user_message_prompt.jinja2 +0 -0
  21. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/settings.py +0 -0
  22. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_agent_config_rjsf_ui_schema.py +0 -0
  23. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_build_loop_iteration_runner.py +0 -0
  24. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_config_services_validators.py +0 -0
  25. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_qwen_max_loop_iterations.py +0 -0
  26. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_register_code_interpreter_postprocessors.py +0 -0
  27. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_unique_ai_async_modify.py +0 -0
  28. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_unique_ai_get_filtered_user_metadata.py +0 -0
  29. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_unique_ai_log_tool_calls.py +0 -0
  30. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_unique_ai_persist_tool_calls.py +0 -0
  31. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_unique_ai_reference_order.py +0 -0
  32. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_unique_ai_render_system_prompt_user_instructions.py +0 -0
  33. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/unique_orchestrator/tests/test_utils_resolve_other_options.py +0 -0
  34. {unique_orchestrator-2026.30.0.dev4 → unique_orchestrator-2026.30.0.dev6}/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.dev6
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.dev17,<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.dev6"
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.dev17,<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",
@@ -14,6 +14,9 @@ from unique_toolkit.agentic.history_manager.history_manager import (
14
14
  )
15
15
  from unique_toolkit.agentic.reference_manager.reference_manager import ReferenceManager
16
16
  from unique_toolkit.agentic.tools.openai_builtin.base import OpenAIBuiltInToolName
17
+ from unique_toolkit.agentic.tools.openai_builtin.code_interpreter import (
18
+ CodeInterpreterActivatorTool,
19
+ )
17
20
  from unique_toolkit.agentic.tools.openai_builtin.code_interpreter.postprocessors.artifacts import (
18
21
  load_code_execution_metadata,
19
22
  )
@@ -64,6 +67,8 @@ def _get_file_content_serializer(
64
67
  code_interpreter_available=(
65
68
  tool_manager.get_tool_by_name(OpenAIBuiltInToolName.CODE_INTERPRETER)
66
69
  is not None
70
+ or tool_manager.get_tool_by_name(CodeInterpreterActivatorTool.NAME)
71
+ is not None
67
72
  ),
68
73
  )
69
74
 
@@ -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
@@ -0,0 +1,76 @@
1
+ """Tests for ResponsesStreamingHandler include injection (UN-17972).
2
+
3
+ The handler always requests code_interpreter_call.outputs so UniqueAI stays
4
+ generic (no isinstance checks) and code interpreter execution logs attach to
5
+ tool calls whenever code interpreter runs.
6
+ """
7
+
8
+ from unittest.mock import AsyncMock, MagicMock
9
+
10
+ import pytest
11
+
12
+ from unique_orchestrator.unique_ai_builder import (
13
+ ResponsesStreamingHandler,
14
+ _ChatServiceResponsesStreaming,
15
+ )
16
+
17
+
18
+ def _make_handler() -> ResponsesStreamingHandler:
19
+ inner = MagicMock()
20
+ inner.complete_with_references_async = AsyncMock(return_value=MagicMock())
21
+ inner.complete_with_references = MagicMock(return_value=MagicMock())
22
+ return ResponsesStreamingHandler(inner=inner)
23
+
24
+
25
+ @pytest.mark.ai
26
+ @pytest.mark.asyncio
27
+ async def test_responses_streaming_handler__always_injects_include__async_path() -> (
28
+ None
29
+ ):
30
+ """complete_with_references_async always sets include=code_interpreter_call.outputs."""
31
+ handler = _make_handler()
32
+
33
+ await handler.complete_with_references_async(messages=[], model_name="model")
34
+
35
+ call_kwargs = handler._inner.complete_with_references_async.await_args.kwargs
36
+ assert call_kwargs["include"] == ["code_interpreter_call.outputs"]
37
+
38
+
39
+ @pytest.mark.ai
40
+ def test_responses_streaming_handler__always_injects_include__sync_path() -> None:
41
+ """The synchronous complete_with_references also always sets include."""
42
+ handler = _make_handler()
43
+
44
+ handler.complete_with_references(messages=[], model_name="model")
45
+
46
+ call_kwargs = handler._inner.complete_with_references.call_args.kwargs
47
+ assert call_kwargs["include"] == ["code_interpreter_call.outputs"]
48
+
49
+
50
+ @pytest.mark.ai
51
+ @pytest.mark.asyncio
52
+ async def test_chat_service_responses_streaming__forwards_to_responses_methods() -> (
53
+ None
54
+ ):
55
+ """
56
+ Purpose: The legacy adapter must route the protocol's
57
+ `complete_with_references[_async]` to ChatService's Responses-specific
58
+ methods, not the Chat Completions ones.
59
+ Why this matters: ChatService exposes both; routing to the wrong method
60
+ would silently stream through the Chat Completions API.
61
+ """
62
+ chat_service = MagicMock()
63
+ chat_service.complete_responses_with_references = MagicMock(
64
+ return_value=MagicMock()
65
+ )
66
+ chat_service.complete_responses_with_references_async = AsyncMock(
67
+ return_value=MagicMock()
68
+ )
69
+ adapter = _ChatServiceResponsesStreaming(chat_service)
70
+
71
+ adapter.complete_with_references(messages=[], model_name="model")
72
+ await adapter.complete_with_references_async(messages=[], model_name="model")
73
+
74
+ chat_service.complete_responses_with_references.assert_called_once()
75
+ chat_service.complete_responses_with_references_async.assert_awaited_once()
76
+ chat_service.complete_with_references.assert_not_called()
@@ -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 = (
@@ -700,7 +755,12 @@ class UniqueAI:
700
755
  # (see ``unique_skill_tool.SkillTool._log_skill_loaded``), so it is
701
756
  # redundant and noisy to also list it here.
702
757
 
703
- tool_names_not_to_log: set[str] = {"DeepResearch", "Skill", "AskUser"}
758
+ tool_names_not_to_log: set[str] = {
759
+ "DeepResearch",
760
+ "Skill",
761
+ "AskUser",
762
+ "ActivatePython",
763
+ }
704
764
 
705
765
  used_tools: dict[str, int] = {}
706
766
  for tool_call in tool_calls:
@@ -2,6 +2,7 @@ from logging import Logger
2
2
  from typing import Any, NamedTuple, cast
3
3
 
4
4
  from openai import AsyncOpenAI
5
+ from openai.types.responses import ResponseIncludable
5
6
  from typing_extensions import override
6
7
  from unique_follow_up_questions.follow_up_postprocessor import (
7
8
  FollowUpPostprocessor,
@@ -115,37 +116,24 @@ def _configure_follow_up_questions(
115
116
 
116
117
 
117
118
  class ResponsesStreamingHandler(ResponsesSupportCompleteWithReferences):
118
- """Streaming handler for Responses API runs.
119
+ """Streaming handler for Responses API runs."""
119
120
 
120
- Injects `include` params from the tool manager on every call so the
121
- orchestrator loop stays generic (no isinstance checks in UniqueAI).
122
-
123
- Delegates the actual streaming to an injected
124
- `ResponsesSupportCompleteWithReferences` (the event-routing
125
- `ResponsesCompleteWithReferences` orchestrator), so this class only owns
126
- the `include` injection.
127
- """
121
+ _INCLUDE: list[ResponseIncludable] = ["code_interpreter_call.outputs"]
128
122
 
129
123
  def __init__(
130
124
  self,
131
125
  inner: ResponsesSupportCompleteWithReferences,
132
- tool_manager: ResponsesApiToolManager,
133
126
  ) -> None:
134
127
  self._inner: ResponsesSupportCompleteWithReferences = inner
135
- self._tool_manager: ResponsesApiToolManager = tool_manager
136
128
 
137
129
  @override
138
130
  def complete_with_references(self, *args: Any, **kwargs: Any) -> Any:
139
- include = self._tool_manager.get_required_include_params()
140
- if include:
141
- kwargs["include"] = include
131
+ kwargs["include"] = self._INCLUDE
142
132
  return self._inner.complete_with_references(*args, **kwargs)
143
133
 
144
134
  @override
145
135
  async def complete_with_references_async(self, *args: Any, **kwargs: Any) -> Any:
146
- include = self._tool_manager.get_required_include_params()
147
- if include:
148
- kwargs["include"] = include
136
+ kwargs["include"] = self._INCLUDE
149
137
  return await self._inner.complete_with_references_async(*args, **kwargs)
150
138
 
151
139
 
@@ -602,7 +590,6 @@ async def _build_responses(
602
590
 
603
591
  streaming_handler = ResponsesStreamingHandler(
604
592
  inner=inner,
605
- tool_manager=tool_manager,
606
593
  )
607
594
 
608
595
  _add_sub_agents_postprocessor(
@@ -1,120 +0,0 @@
1
- """Tests for ResponsesStreamingHandler include injection (UN-17972).
2
-
3
- The include forwarding logic lives in ResponsesStreamingHandler in
4
- unique_ai_builder.py so that UniqueAI stays generic (no isinstance checks).
5
- """
6
-
7
- from unittest.mock import AsyncMock, MagicMock
8
-
9
- import pytest
10
- from unique_toolkit.agentic.tools.tool_manager import ResponsesApiToolManager
11
-
12
- from unique_orchestrator.unique_ai_builder import (
13
- ResponsesStreamingHandler,
14
- _ChatServiceResponsesStreaming,
15
- )
16
-
17
-
18
- def _make_handler(
19
- include_params: list[str],
20
- ) -> ResponsesStreamingHandler:
21
- tool_manager = MagicMock(spec=ResponsesApiToolManager)
22
- tool_manager.get_required_include_params.return_value = include_params
23
- inner = MagicMock()
24
- inner.complete_with_references_async = AsyncMock(return_value=MagicMock())
25
- inner.complete_with_references = MagicMock(return_value=MagicMock())
26
- return ResponsesStreamingHandler(
27
- inner=inner,
28
- tool_manager=tool_manager,
29
- )
30
-
31
-
32
- @pytest.mark.ai
33
- @pytest.mark.asyncio
34
- async def test_responses_streaming_handler__injects_include__when_tool_manager_returns_params() -> (
35
- None
36
- ):
37
- """
38
- Purpose: Verify complete_with_references_async injects include when the tool
39
- manager returns a non-empty list.
40
- Why this matters: UN-17972 requires code_interpreter_call.outputs on the
41
- Responses API so execution logs attach to tool calls.
42
- """
43
- handler = _make_handler(include_params=["code_interpreter_call.outputs"])
44
-
45
- await handler.complete_with_references_async(messages=[], model_name="model")
46
-
47
- call_kwargs = handler._inner.complete_with_references_async.await_args.kwargs
48
- assert call_kwargs["include"] == ["code_interpreter_call.outputs"]
49
- handler._tool_manager.get_required_include_params.assert_called_once()
50
-
51
-
52
- @pytest.mark.ai
53
- @pytest.mark.asyncio
54
- async def test_responses_streaming_handler__omits_include__when_tool_manager_returns_empty() -> (
55
- None
56
- ):
57
- """
58
- Purpose: Verify include is not added when get_required_include_params returns [].
59
- Why this matters: No extra include should reach the Responses API when no tool
60
- requests it — preserving legacy behaviour exactly.
61
- """
62
- handler = _make_handler(include_params=[])
63
-
64
- await handler.complete_with_references_async(messages=[], model_name="model")
65
-
66
- call_kwargs = handler._inner.complete_with_references_async.await_args.kwargs
67
- assert "include" not in call_kwargs
68
-
69
-
70
- @pytest.mark.ai
71
- def test_responses_streaming_handler__injects_include__sync_path() -> None:
72
- """
73
- Purpose: Verify the synchronous complete_with_references also injects include.
74
- """
75
- handler = _make_handler(include_params=["code_interpreter_call.outputs"])
76
-
77
- handler.complete_with_references(messages=[], model_name="model")
78
-
79
- call_kwargs = handler._inner.complete_with_references.call_args.kwargs
80
- assert call_kwargs["include"] == ["code_interpreter_call.outputs"]
81
-
82
-
83
- @pytest.mark.ai
84
- def test_responses_streaming_handler__omits_include__sync_path_when_empty() -> None:
85
- """Purpose: No include injected via sync path when tool manager returns []."""
86
- handler = _make_handler(include_params=[])
87
-
88
- handler.complete_with_references(messages=[], model_name="model")
89
-
90
- call_kwargs = handler._inner.complete_with_references.call_args.kwargs
91
- assert "include" not in call_kwargs
92
-
93
-
94
- @pytest.mark.ai
95
- @pytest.mark.asyncio
96
- async def test_chat_service_responses_streaming__forwards_to_responses_methods() -> (
97
- None
98
- ):
99
- """
100
- Purpose: The legacy adapter must route the protocol's
101
- `complete_with_references[_async]` to ChatService's Responses-specific
102
- methods, not the Chat Completions ones.
103
- Why this matters: ChatService exposes both; routing to the wrong method
104
- would silently stream through the Chat Completions API.
105
- """
106
- chat_service = MagicMock()
107
- chat_service.complete_responses_with_references = MagicMock(
108
- return_value=MagicMock()
109
- )
110
- chat_service.complete_responses_with_references_async = AsyncMock(
111
- return_value=MagicMock()
112
- )
113
- adapter = _ChatServiceResponsesStreaming(chat_service)
114
-
115
- adapter.complete_with_references(messages=[], model_name="model")
116
- await adapter.complete_with_references_async(messages=[], model_name="model")
117
-
118
- chat_service.complete_responses_with_references.assert_called_once()
119
- chat_service.complete_responses_with_references_async.assert_awaited_once()
120
- chat_service.complete_with_references.assert_not_called()