unique_orchestrator 2026.18.0.dev3__tar.gz → 2026.18.0.dev4__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 (31) hide show
  1. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/PKG-INFO +3 -1
  2. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/pyproject.toml +8 -4
  3. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/_builders/__init__.py +4 -0
  4. unique_orchestrator-2026.18.0.dev4/unique_orchestrator/_builders/inject_tool_reminders.py +88 -0
  5. unique_orchestrator-2026.18.0.dev4/unique_orchestrator/_builders/skill_setup.py +399 -0
  6. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/config.py +5 -0
  7. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_unique_ai_builder_uploaded_search.py +1 -5
  8. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/unique_ai.py +25 -2
  9. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/unique_ai_builder.py +19 -2
  10. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/README.md +0 -0
  11. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/__init__.py +0 -0
  12. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/_builders/loop_iteration_runner.py +0 -0
  13. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/_builders/open_file_setup.py +0 -0
  14. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/prompts/generic_reference_prompt.jinja2 +0 -0
  15. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/prompts/system_prompt.jinja2 +0 -0
  16. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/prompts/user_message_prompt.jinja2 +0 -0
  17. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_build_loop_iteration_runner.py +0 -0
  18. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_config_services_validators.py +0 -0
  19. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_qwen_max_loop_iterations.py +0 -0
  20. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_register_code_interpreter_postprocessors.py +0 -0
  21. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_unique_ai_async_modify.py +0 -0
  22. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_unique_ai_builder_selected_uploaded_files.py +0 -0
  23. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_unique_ai_execution_timing.py +0 -0
  24. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_unique_ai_get_filtered_user_metadata.py +0 -0
  25. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_unique_ai_log_tool_calls.py +0 -0
  26. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_unique_ai_persist_tool_calls.py +0 -0
  27. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_unique_ai_plan_or_execute_include.py +0 -0
  28. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_unique_ai_reference_order.py +0 -0
  29. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_unique_ai_render_system_prompt_user_instructions.py +0 -0
  30. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/tests/test_unique_ai_selected_uploaded_files.py +0 -0
  31. {unique_orchestrator-2026.18.0.dev3 → unique_orchestrator-2026.18.0.dev4}/unique_orchestrator/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-orchestrator
3
- Version: 2026.18.0.dev3
3
+ Version: 2026.18.0.dev4
4
4
  Summary:
5
5
  Author: Andreas Hauri
6
6
  Author-email: Andreas Hauri <andreas.hauri@unique.ai>
@@ -15,7 +15,9 @@ Requires-Dist: unique-internal-search>=2026.18.0.dev2
15
15
  Requires-Dist: unique-deep-research>=2026.18.0.dev2
16
16
  Requires-Dist: unique-web-search>=2026.18.0.dev2
17
17
  Requires-Dist: unique-swot>=2026.18.0.dev2
18
+ Requires-Dist: unique-skill-tool>=2026.18.0.dev0
18
19
  Requires-Dist: openai>=1.109.1,<3
20
+ Requires-Dist: python-frontmatter>=1.1.0,<2
19
21
  Requires-Python: >=3.12, <4
20
22
  Description-Content-Type: text/markdown
21
23
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_orchestrator"
3
- version = "2026.18.0.dev3"
3
+ version = "2026.18.0.dev4"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "Proprietary" }
@@ -19,7 +19,9 @@ dependencies = [
19
19
  "unique-deep-research>=2026.18.0.dev2",
20
20
  "unique-web-search>=2026.18.0.dev2",
21
21
  "unique-swot>=2026.18.0.dev2",
22
+ "unique-skill-tool>=2026.18.0.dev0",
22
23
  "openai>=1.109.1,<3",
24
+ "python-frontmatter>=1.1.0,<2",
23
25
  ]
24
26
 
25
27
  [build-system]
@@ -43,6 +45,7 @@ exclude-newer = "2 weeks"
43
45
  "unique-stock-ticker" = false
44
46
  "unique-swot" = false
45
47
  "unique-toolkit" = false
48
+ "unique-skill-tool" = false
46
49
  "unique-web-search" = false
47
50
 
48
51
  [tool.uv.sources]
@@ -52,6 +55,7 @@ unique-stock-ticker = { workspace = true }
52
55
  unique-follow-up-questions = { workspace = true }
53
56
  unique-internal-search = { workspace = true }
54
57
  unique-swot = { workspace = true }
58
+ unique-skill-tool = { workspace = true }
55
59
  unique-web-search = { workspace = true }
56
60
  unique-deep-research = { workspace = true }
57
61
 
@@ -82,12 +86,12 @@ filterwarnings = [
82
86
  ]
83
87
 
84
88
  [tool.deptry]
85
- known_first_party = ["unique_orchestrator", "unique_toolkit", "unique_stock_ticker", "unique_follow_up_questions", "unique_internal_search", "unique_deep_research", "unique_web_search", "unique_swot"]
89
+ known_first_party = ["unique_orchestrator", "unique_toolkit", "unique_stock_ticker", "unique_follow_up_questions", "unique_internal_search", "unique_deep_research", "unique_web_search", "unique_swot", "unique_skill_tool"]
86
90
  extend_exclude = ["unique_orchestrator/tests"]
87
91
 
88
92
  [tool.deptry.per_rule_ignores]
89
- DEP002 = ["unique-toolkit", "unique-stock-ticker", "unique-follow-up-questions", "unique-internal-search", "unique-deep-research", "unique-web-search", "unique-swot"]
90
- DEP003 = ["unique_toolkit", "unique_stock_ticker", "unique_follow_up_questions", "unique_internal_search", "unique_deep_research", "unique_web_search", "unique_swot"]
93
+ DEP002 = ["unique-toolkit", "unique-stock-ticker", "unique-follow-up-questions", "unique-internal-search", "unique-deep-research", "unique-web-search", "unique-swot", "unique-skill-tool"]
94
+ DEP003 = ["unique_toolkit", "unique_stock_ticker", "unique_follow_up_questions", "unique_internal_search", "unique_deep_research", "unique_web_search", "unique_swot", "unique_skill_tool"]
91
95
 
92
96
  [tool.basedpyright]
93
97
  typeCheckingMode = "standard"
@@ -6,10 +6,14 @@ from unique_orchestrator._builders.open_file_setup import (
6
6
  configure_file_payload,
7
7
  handle_uploaded_file_tool_choices,
8
8
  )
9
+ from unique_orchestrator._builders.skill_setup import (
10
+ configure_skill_tool,
11
+ )
9
12
 
10
13
  __all__ = [
11
14
  "build_loop_iteration_runner",
12
15
  "configure_file_payload",
16
+ "configure_skill_tool",
13
17
  "handle_uploaded_file_tool_choices",
14
18
  "build_responses_loop_iteration_runner",
15
19
  ]
@@ -0,0 +1,88 @@
1
+ """Inject per-turn tool reminders as standalone user content parts.
2
+
3
+ Tools expose two per-turn hooks:
4
+
5
+ - `Tool.tool_system_reminder_for_user_prompt` — a ``<system-reminder>`` block
6
+ describing the tool's current state (e.g. the Skill tool's listing
7
+ of currently-available skills). Regenerated every loop iteration.
8
+
9
+ The orchestrator collects it, filters empties, and hands the list
10
+ to this module. We surface each string as its own
11
+ ``{"type": "text"}`` entry on the latest user message, prepended
12
+ before the residual query text::
13
+
14
+ {
15
+ "role": "user",
16
+ "content": [
17
+ {"type": "text", "text": "<system-reminder>...skills...</system-reminder>"},
18
+ {"type": "text", "text": "commit my staged changes"}
19
+ ]
20
+ }
21
+
22
+ The user message may already be a multi-part list when this runs
23
+ (the open-file tool injects file parts before this step), so both
24
+ ``str`` and ``list`` content shapes are supported. Non-text parts
25
+ (e.g. ``image_url``, ``file``) are preserved.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Any
31
+
32
+ from unique_toolkit.language_model.schemas import (
33
+ LanguageModelMessageRole,
34
+ LanguageModelMessages,
35
+ LanguageModelUserMessage,
36
+ )
37
+
38
+
39
+ def _find_last_user_index(messages: LanguageModelMessages) -> int | None:
40
+ for i in range(len(messages.root) - 1, -1, -1):
41
+ if messages.root[i].role == LanguageModelMessageRole.USER:
42
+ return i
43
+ return None
44
+
45
+
46
+ def _content_as_parts(content: object) -> list[dict[str, Any]] | None:
47
+ """Normalise a user-message ``content`` value to a parts list.
48
+
49
+ Returns ``None`` when the content is neither a string nor a list
50
+ of part dicts (i.e. nothing for us to process).
51
+ """
52
+ if isinstance(content, str):
53
+ return [{"type": "text", "text": content}]
54
+ if isinstance(content, list):
55
+ return [p for p in content if isinstance(p, dict)] # pyright: ignore[reportUnnecessaryIsInstance]
56
+ return None
57
+
58
+
59
+ def inject_tool_reminders_into_user_message(
60
+ messages: LanguageModelMessages,
61
+ tool_reminders: list[str],
62
+ ) -> LanguageModelMessages:
63
+ """Prepend each non-empty ``tool_reminder`` as a text part.
64
+
65
+ The reminders are inserted at the head of the latest user-role
66
+ message's ``content`` array, in the order given, ahead of the
67
+ residual query text and any other existing parts. Empty strings
68
+ are skipped. Returns ``messages`` unchanged if there are no
69
+ non-empty reminders or no user message in the stream.
70
+ """
71
+ non_empty_reminders = [r for r in tool_reminders if r]
72
+ if not non_empty_reminders or not messages.root:
73
+ return messages
74
+
75
+ user_idx = _find_last_user_index(messages)
76
+ if user_idx is None:
77
+ return messages
78
+
79
+ existing_parts = _content_as_parts(messages.root[user_idx].content)
80
+ if existing_parts is None:
81
+ return messages
82
+
83
+ new_parts: list[dict[str, Any]] = [
84
+ {"type": "text", "text": reminder} for reminder in non_empty_reminders
85
+ ]
86
+ new_parts.extend(existing_parts)
87
+ messages.root[user_idx] = LanguageModelUserMessage(content=new_parts)
88
+ return messages
@@ -0,0 +1,399 @@
1
+ """Helpers for the Skill tool.
2
+
3
+ Loads skill definitions from the knowledge base and registers the
4
+ SkillTool with the tool manager.
5
+
6
+ Skill files are ``.md`` documents with optional YAML frontmatter::
7
+
8
+ ---
9
+ name: summarize-report
10
+ description: >-
11
+ Summarize a document into key findings.
12
+ Use when the user asks for a summary or overview.
13
+ ---
14
+
15
+ # Summarize Report
16
+ ...instructions...
17
+
18
+ The frontmatter fields are used for the skill listing shown to the
19
+ LLM. The body (everything after the frontmatter) is the prompt
20
+ content injected when the skill is invoked.
21
+
22
+ TODO: Add a skill-path reconciliation module that supports both KB
23
+ layouts and infers the skill name when frontmatter ``name:`` is
24
+ missing. Today every ``.md`` is treated as an independent skill and
25
+ is skipped unless the frontmatter carries both ``name`` and
26
+ ``description`` — a folder-based skill (``<scope>/draft-email/
27
+ SKILL.md`` + assets) is indistinguishable from a flat-layout skill
28
+ (``<scope>/draft-email.md``), and authors must repeat the name in
29
+ frontmatter even though the filename or folder already implies it.
30
+
31
+ Target layouts:
32
+ * flat: ``<scope>/draft-email.md``
33
+ -> name inferred from filename stem
34
+ * folder: ``<scope>/draft-email/SKILL.md`` + sibling assets
35
+ -> name inferred from folder name; other ``.md`` files in
36
+ the folder are treated as assets, not separate skills
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import asyncio
42
+ from logging import Logger
43
+ from typing import TYPE_CHECKING, Any
44
+
45
+ import frontmatter
46
+ from pydantic import ValidationError
47
+ from unique_skill_tool.schemas import SkillDefinition
48
+ from unique_skill_tool.service import SkillTool
49
+ from unique_skill_tool.utils import extract_prefix_skills
50
+ from unique_toolkit.agentic.tools.schemas import ToolCallResponse
51
+ from unique_toolkit.content.schemas import Content, ContentInfo
52
+ from unique_toolkit.content.smart_rules import (
53
+ AndStatement,
54
+ Operator,
55
+ OrStatement,
56
+ Statement,
57
+ )
58
+ from unique_toolkit.language_model.schemas import LanguageModelFunction
59
+ from unique_toolkit.services.knowledge_base import KnowledgeBaseService
60
+
61
+ if TYPE_CHECKING:
62
+ from unique_toolkit.agentic.history_manager.history_manager import HistoryManager
63
+ from unique_toolkit.agentic.tools.tool_manager import (
64
+ ResponsesApiToolManager,
65
+ ToolManager,
66
+ )
67
+ from unique_toolkit.app.schemas import ChatEvent
68
+ from unique_toolkit.content.service import ContentService
69
+
70
+ from unique_orchestrator.config import UniqueAIConfig
71
+
72
+
73
+ _SUBTREE_PAGE_SIZE = 100
74
+ _MAX_SUBTREE_ITEMS = 10_000
75
+
76
+
77
+ def _is_markdown(*, content: Content | ContentInfo) -> bool:
78
+ return content.key.lower().endswith(".md")
79
+
80
+
81
+ def _build_subtree_metadata_filter(*, scope_ids: list[str]) -> dict[str, Any]:
82
+ """Build a UniqueQL filter matching any file under the given scope IDs.
83
+
84
+ For each configured scope ID we add a ``folderIdPath CONTAINS
85
+ uniquepathid://<scope_id>`` predicate, then OR them together. The
86
+ backend's ACL is then applied to the matching set, so only files
87
+ the current user can access are returned.
88
+ """
89
+ statements: list[Statement | AndStatement | OrStatement] = [
90
+ Statement(
91
+ operator=Operator.CONTAINS,
92
+ value=f"uniquepathid://{scope_id}",
93
+ path=["folderIdPath"],
94
+ )
95
+ for scope_id in scope_ids
96
+ ]
97
+ if len(statements) == 1:
98
+ return statements[0].model_dump(mode="json")
99
+ return OrStatement(or_list=statements).model_dump(mode="json")
100
+
101
+
102
+ def _parse_frontmatter(*, text: str) -> tuple[dict[str, Any], str]:
103
+ """Split YAML frontmatter from the markdown body.
104
+
105
+ Thin wrapper around ``python-frontmatter``. On a YAML parse error,
106
+ or when the frontmatter parses to a non-mapping (e.g. a top-level
107
+ YAML list), we fall back to ``({}, <stripped body>)`` so a broken
108
+ skill file never leaks its raw ``---\\nname: ...\\n---`` block into
109
+ the LLM prompt, but also never crashes ``_build_skill`` with an
110
+ unhandled ``TypeError``/``ValueError`` from ``dict(...)``.
111
+ """
112
+ try:
113
+ post = frontmatter.loads(text)
114
+ metadata = post.metadata
115
+ body = post.content
116
+ except Exception:
117
+ return {}, text
118
+
119
+ if not isinstance(metadata, dict):
120
+ return {}, body
121
+
122
+ return dict(metadata), body
123
+
124
+
125
+ def _build_skill(
126
+ *,
127
+ content: Content | ContentInfo,
128
+ file_text: str,
129
+ logger: Logger,
130
+ ) -> SkillDefinition | None:
131
+ """Build a ``SkillDefinition`` from the raw file text of a KB document.
132
+
133
+ Parses YAML frontmatter for ``name`` and ``description``. A malformed
134
+ ``name`` (non-kebab-case, contains whitespace/punctuation, too long)
135
+ is rejected by ``SkillDefinition`` rather than silently flowing into
136
+ the OpenAI tool enum where the model could never emit it verbatim.
137
+ """
138
+ if not file_text.strip():
139
+ return None
140
+
141
+ metadata, body = _parse_frontmatter(text=file_text)
142
+
143
+ name = metadata.get("name")
144
+ description = metadata.get("description")
145
+
146
+ if not name or not description:
147
+ logger.warning(
148
+ "Skipping '%s' (%s): wrong skill format.",
149
+ content.key,
150
+ content.id,
151
+ )
152
+ return None
153
+
154
+ try:
155
+ return SkillDefinition(
156
+ name=name,
157
+ description=description,
158
+ content=body,
159
+ )
160
+ except ValidationError as exc:
161
+ logger.warning(
162
+ "Skipping '%s' (%s): invalid skill definition: %s",
163
+ content.key,
164
+ content.id,
165
+ exc.errors(include_url=False),
166
+ )
167
+ return None
168
+
169
+
170
+ async def load_skills_from_knowledge_base(
171
+ *,
172
+ content_service: ContentService,
173
+ knowledge_base_service: KnowledgeBaseService,
174
+ scope_ids: list[str],
175
+ logger: Logger,
176
+ ) -> dict[str, SkillDefinition]:
177
+ """Load all ``.md`` files from *scope_ids* (recursively) into a skill registry.
178
+
179
+ Pages through ``/content/infos`` with a UniqueQL metadata filter that
180
+ OR's ``folderIdPath CONTAINS uniquepathid://<scope_id>`` for every
181
+ configured scope. The public API caps ``take`` at 100, so results are
182
+ accumulated across pages up to ``_MAX_SUBTREE_ITEMS``. This matches
183
+ any file whose folder path contains the configured scope at any
184
+ depth, so:
185
+
186
+ * Each configured scope ID acts as a subtree root — all files inside
187
+ it and any of its descendants are considered.
188
+ * The backend's ACL is applied to the matching set, so only files
189
+ the current user can access are returned.
190
+ * To expose skills to users who only have access to specific
191
+ sub-folders, configure those sub-folder scope IDs directly.
192
+
193
+ File downloads are fanned out concurrently via ``asyncio.gather`` so
194
+ per-request build latency stays ~O(1) in the number of skills rather
195
+ than O(N) synchronous HTTP round-trips.
196
+ """
197
+ if not scope_ids:
198
+ logger.info("SkillTool has no scope_ids configured.")
199
+ return {}
200
+
201
+ metadata_filter = _build_subtree_metadata_filter(scope_ids=scope_ids)
202
+
203
+ all_infos: list[ContentInfo] = []
204
+ skip = 0
205
+ try:
206
+ while skip < _MAX_SUBTREE_ITEMS:
207
+ paginated = knowledge_base_service.get_paginated_content_infos(
208
+ metadata_filter=metadata_filter,
209
+ skip=skip,
210
+ take=_SUBTREE_PAGE_SIZE,
211
+ )
212
+ all_infos.extend(paginated.content_infos)
213
+ skip += len(paginated.content_infos)
214
+ if (
215
+ len(paginated.content_infos) < _SUBTREE_PAGE_SIZE
216
+ or skip >= paginated.total_count
217
+ ):
218
+ break
219
+ except Exception:
220
+ logger.warning(
221
+ "Failed to list contents for scope_ids=%s — "
222
+ "SkillTool will have an empty skill registry.",
223
+ scope_ids,
224
+ exc_info=True,
225
+ )
226
+ return {}
227
+
228
+ md_infos = [ci for ci in all_infos if _is_markdown(content=ci)]
229
+ if not md_infos:
230
+ logger.info("No .md files found for scope_ids=%s.", scope_ids)
231
+ return {}
232
+
233
+ download_results = await asyncio.gather(
234
+ *(
235
+ content_service.download_content_to_bytes_async(content_id=info.id)
236
+ for info in md_infos
237
+ ),
238
+ return_exceptions=True,
239
+ )
240
+
241
+ skill_registry: dict[str, SkillDefinition] = {}
242
+ for info, result in zip(md_infos, download_results, strict=True):
243
+ if isinstance(result, BaseException):
244
+ logger.warning(
245
+ "Failed to download '%s' (%s) — skipping.",
246
+ info.key,
247
+ info.id,
248
+ exc_info=result,
249
+ )
250
+ continue
251
+
252
+ try:
253
+ file_text = result.decode("utf-8")
254
+ except UnicodeDecodeError:
255
+ logger.warning(
256
+ "Failed to decode '%s' (%s) as UTF-8 — skipping.",
257
+ info.key,
258
+ info.id,
259
+ exc_info=True,
260
+ )
261
+ continue
262
+
263
+ skill = _build_skill(content=info, file_text=file_text, logger=logger)
264
+ if skill is None:
265
+ logger.debug(
266
+ "Skipping '%s' (%s): empty file.",
267
+ info.key,
268
+ info.id,
269
+ )
270
+ continue
271
+
272
+ if skill.name in skill_registry:
273
+ logger.warning(
274
+ "Duplicate skill name '%s' — keeping the first occurrence.",
275
+ skill.name,
276
+ )
277
+ continue
278
+
279
+ skill_registry[skill.name] = skill
280
+
281
+ logger.info(
282
+ "Loaded %d skill(s) from knowledge base (scope_ids=%s).",
283
+ len(skill_registry),
284
+ scope_ids,
285
+ )
286
+ return skill_registry
287
+
288
+
289
+ async def configure_skill_tool(
290
+ *,
291
+ config: UniqueAIConfig,
292
+ event: ChatEvent,
293
+ logger: Logger,
294
+ content_service: ContentService,
295
+ tool_manager: ToolManager | ResponsesApiToolManager,
296
+ ) -> None:
297
+ """Register the SkillTool if enabled in the config.
298
+
299
+ Lists all ``.md`` files in the configured ``scope_ids`` (and every
300
+ sub-folder reachable from them), downloads each one concurrently,
301
+ and registers the SkillTool with the parsed skills.
302
+ """
303
+ skill_config = config.agent.experimental.skill_tool_config
304
+ if not skill_config.enabled:
305
+ return
306
+
307
+ if not skill_config.scope_ids:
308
+ logger.warning(
309
+ "SkillTool is enabled but no scope_ids are configured — "
310
+ "no skills will be loaded."
311
+ )
312
+ return
313
+
314
+ knowledge_base_service = KnowledgeBaseService(
315
+ company_id=event.company_id,
316
+ user_id=event.user_id,
317
+ )
318
+
319
+ skill_registry = await load_skills_from_knowledge_base(
320
+ content_service=content_service,
321
+ knowledge_base_service=knowledge_base_service,
322
+ scope_ids=skill_config.scope_ids,
323
+ logger=logger,
324
+ )
325
+
326
+ if not skill_registry:
327
+ logger.info(
328
+ "SkillTool has an empty skill registry — tool will not be registered."
329
+ )
330
+ return
331
+
332
+ tool_manager.add_tool(
333
+ SkillTool(
334
+ event=event,
335
+ skill_registry=skill_registry,
336
+ config=skill_config,
337
+ )
338
+ )
339
+
340
+
341
+ async def preload_invoked_skills(
342
+ *,
343
+ event: ChatEvent,
344
+ tool_manager: ToolManager | ResponsesApiToolManager,
345
+ history_manager: HistoryManager,
346
+ logger: Logger,
347
+ ) -> str | None:
348
+ """Preload skills invoked as ``/skill-name`` prefix(es) in the user message.
349
+
350
+ Mirrors the normal mid-loop activation path so preloaded skills are
351
+ indistinguishable from skills the model activates itself:
352
+
353
+ 1. Parses consecutive ``/skill-name`` tokens from the start of the
354
+ user message.
355
+ 2. For each matched skill, synthesizes a ``LanguageModelFunction``
356
+ and runs it through the already-registered ``SkillTool`` — the
357
+ exact same code path ``UniqueAI._handle_tool_calls`` uses.
358
+ 3. Appends the synthetic assistant tool-call message plus the
359
+ resulting tool-result messages to history, so the first model
360
+ turn sees the skills as already-activated tool calls.
361
+ 4. Strips the matched ``/skill-name`` tokens from the user message
362
+ so the rendered turn shows only the user's actual query.
363
+
364
+ No-ops when the SkillTool is not registered or no tokens match.
365
+ """
366
+ skill_tool = tool_manager.get_tool_by_name(SkillTool.name)
367
+ if not isinstance(skill_tool, SkillTool):
368
+ return None
369
+
370
+ original_text = event.payload.user_message.text or ""
371
+
372
+ skills, stripped_text = extract_prefix_skills(
373
+ original_text, skill_tool.skill_registry
374
+ )
375
+ if not skills:
376
+ return None
377
+
378
+ tool_calls: list[LanguageModelFunction] = []
379
+ responses: list[ToolCallResponse] = []
380
+ for skill in skills:
381
+ tool_call = LanguageModelFunction(
382
+ name=SkillTool.name,
383
+ arguments={"skill_name": skill.name},
384
+ )
385
+ response = await skill_tool.run(tool_call)
386
+ tool_calls.append(tool_call)
387
+ responses.append(response)
388
+ history_manager.add_tool_call(tool_call)
389
+
390
+ history_manager._append_tool_calls_to_history(tool_calls)
391
+ history_manager.add_tool_call_results(responses)
392
+
393
+ logger.info(
394
+ "Preloaded %d skill(s) from slash invocation: %s",
395
+ len(skills),
396
+ [s.name for s in skills],
397
+ )
398
+
399
+ return stripped_text
@@ -9,6 +9,9 @@ from unique_deep_research.service import DeepResearchTool
9
9
  from unique_follow_up_questions.config import FollowUpQuestionsConfig
10
10
  from unique_internal_search.config import InternalSearchConfig
11
11
  from unique_internal_search.service import InternalSearchTool
12
+ from unique_skill_tool.config import (
13
+ SkillToolConfig,
14
+ )
12
15
  from unique_stock_ticker.config import StockTickerConfig
13
16
  from unique_swot import SwotAnalysisTool, SwotAnalysisToolConfig
14
17
  from unique_toolkit._common.validators import (
@@ -348,6 +351,8 @@ class ExperimentalConfig(BaseToolConfig):
348
351
 
349
352
  open_file_tool_config: OpenFileToolConfig = OpenFileToolConfig()
350
353
 
354
+ skill_tool_config: SkillToolConfig = SkillToolConfig()
355
+
351
356
  use_responses_api: bool = Field(
352
357
  default=False,
353
358
  description="If set, the main agent will use the Responses API from OpenAI",
@@ -345,10 +345,6 @@ class TestConfigureUploadedSearchToolIngestionFilter:
345
345
  assert UploadedSearchTool.name not in tool_names
346
346
 
347
347
  def test_skip_excel_ingestion_mode_is_excluded(self):
348
- # SKIP_EXCEL_INGESTION only excludes the doc when the mime type is
349
- # actually Excel/CSV (see unique_toolkit.Content.is_ingested after
350
- # #1454). A SKIP_EXCEL_INGESTION doc with a non-Excel or unknown
351
- # mime type is still considered ingested.
352
348
  doc = self._make_doc(
353
349
  {"uniqueIngestionMode": "SKIP_EXCEL_INGESTION"},
354
350
  mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -381,7 +377,7 @@ class TestConfigureUploadedSearchToolIngestionFilter:
381
377
  self._make_doc({"uniqueIngestionMode": "SKIP_INGESTION"}),
382
378
  self._make_doc(
383
379
  {"uniqueIngestionMode": "SKIP_EXCEL_INGESTION"},
384
- mime_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
380
+ mime_type="text/csv",
385
381
  ),
386
382
  ]
387
383
  common = self._run(docs)
@@ -50,6 +50,10 @@ from unique_toolkit.protocols.support import (
50
50
  SupportCompleteWithReferences,
51
51
  )
52
52
 
53
+ from unique_orchestrator._builders.inject_tool_reminders import (
54
+ inject_tool_reminders_into_user_message,
55
+ )
56
+ from unique_orchestrator._builders.skill_setup import preload_invoked_skills
53
57
  from unique_orchestrator.config import UniqueAIConfig
54
58
  from unique_orchestrator.utils import filter_uploaded_documents_by_selection
55
59
 
@@ -210,6 +214,16 @@ class UniqueAI:
210
214
  self._execution_times = []
211
215
  run_start = time.perf_counter()
212
216
 
217
+ if self._config.agent.experimental.skill_tool_config.enabled:
218
+ stripped_text = await preload_invoked_skills(
219
+ event=self._event,
220
+ tool_manager=self._tool_manager,
221
+ history_manager=self._history_manager,
222
+ logger=self._logger,
223
+ )
224
+ if stripped_text is not None:
225
+ self._event.payload.user_message.text = stripped_text
226
+
213
227
  sub = self._chat_service.cancellation.on_cancellation.subscribe(
214
228
  self._on_cancellation
215
229
  )
@@ -405,6 +419,12 @@ class UniqueAI:
405
419
  messages
406
420
  )
407
421
  )
422
+
423
+ tool_reminders: list[str] = []
424
+ for prompts in self._tool_manager.get_tool_prompts():
425
+ if prompts.tool_system_reminder_for_user_prompt:
426
+ tool_reminders.append(prompts.tool_system_reminder_for_user_prompt)
427
+ messages = inject_tool_reminders_into_user_message(messages, tool_reminders)
408
428
  return messages
409
429
 
410
430
  async def _render_user_prompt(self) -> str:
@@ -635,8 +655,11 @@ class UniqueAI:
635
655
  for tool in self._tool_manager.available_tools
636
656
  }
637
657
 
638
- # Tool names that should not be logged in the message steps
639
- tool_names_not_to_log = ["DeepResearch"]
658
+ # Tool names that should not be logged in the "Triggered Tool Calls"
659
+ # step. The Skill tool emits its own message log entry per invocation
660
+ # (see ``unique_skill_tool.SkillTool._log_skill_loaded``), so it is
661
+ # redundant and noisy to also list it here.
662
+ tool_names_not_to_log = ["DeepResearch", "Skill"]
640
663
 
641
664
  used_tools: dict[str, int] = {}
642
665
  for tool_call in tool_calls:
@@ -83,6 +83,7 @@ from unique_orchestrator._builders.open_file_setup import (
83
83
  configure_file_payload,
84
84
  handle_uploaded_file_tool_choices,
85
85
  )
86
+ from unique_orchestrator._builders.skill_setup import configure_skill_tool
86
87
  from unique_orchestrator.config import UniqueAIConfig
87
88
  from unique_orchestrator.unique_ai import UniqueAI
88
89
  from unique_orchestrator.utils import filter_uploaded_documents_by_selection
@@ -140,7 +141,7 @@ async def build_unique_ai(
140
141
  common_components=common_components,
141
142
  )
142
143
  else:
143
- return _build_completions(
144
+ return await _build_completions(
144
145
  event=event,
145
146
  logger=logger,
146
147
  config=config,
@@ -432,6 +433,14 @@ async def _build_responses(
432
433
  history_manager=history_manager,
433
434
  )
434
435
 
436
+ await configure_skill_tool(
437
+ config=config,
438
+ event=event,
439
+ logger=logger,
440
+ content_service=common_components.content_service,
441
+ tool_manager=tool_manager,
442
+ )
443
+
435
444
  loop_iteration_runner = build_responses_loop_iteration_runner(
436
445
  config=config,
437
446
  history_manager=common_components.history_manager,
@@ -478,7 +487,7 @@ async def _build_responses(
478
487
  )
479
488
 
480
489
 
481
- def _build_completions(
490
+ async def _build_completions(
482
491
  event: ChatEvent,
483
492
  logger: Logger,
484
493
  config: UniqueAIConfig,
@@ -502,6 +511,14 @@ def _build_completions(
502
511
  if not has_tool_choices and has_valid_uploaded_documents:
503
512
  tool_manager.add_forced_tool(UploadedSearchTool.name)
504
513
 
514
+ await configure_skill_tool(
515
+ config=config,
516
+ event=event,
517
+ logger=logger,
518
+ content_service=common_components.content_service,
519
+ tool_manager=tool_manager,
520
+ )
521
+
505
522
  postprocessor_manager = common_components.postprocessor_manager
506
523
 
507
524
  _add_sub_agents_postprocessor(