unique_toolkit 1.35.4__py3-none-any.whl → 1.37.0__py3-none-any.whl

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.
@@ -15,7 +15,7 @@ def _iter_ref_numbers(text: str) -> Generator[int, None, None]:
15
15
 
16
16
 
17
17
  @functools.cache
18
- def _get_detection_pattern_for_ref(ref_number: int) -> re.Pattern[str]:
18
+ def get_detection_pattern_for_ref(ref_number: int) -> re.Pattern[str]:
19
19
  return re.compile(rf"<sup>\s*{ref_number}\s*</sup>")
20
20
 
21
21
 
@@ -35,11 +35,11 @@ def replace_ref_number(text: str, ref_number: int, replacement: int | str) -> st
35
35
  if isinstance(replacement, int):
36
36
  replacement = get_reference_pattern(replacement)
37
37
 
38
- return _get_detection_pattern_for_ref(ref_number).sub(replacement, text)
38
+ return get_detection_pattern_for_ref(ref_number).sub(replacement, text)
39
39
 
40
40
 
41
41
  def remove_ref_number(text: str, ref_number: int) -> str:
42
- return _get_detection_pattern_for_ref(ref_number).sub("", text)
42
+ return get_detection_pattern_for_ref(ref_number).sub("", text)
43
43
 
44
44
 
45
45
  def remove_all_refs(text: str) -> str:
@@ -1,8 +1,11 @@
1
1
  from unique_toolkit.agentic.loop_runner.base import LoopIterationRunner
2
2
  from unique_toolkit.agentic.loop_runner.middleware import (
3
+ QWEN_FORCED_TOOL_CALL_PROMPT_INSTRUCTION,
3
4
  PlanningConfig,
4
5
  PlanningMiddleware,
5
6
  PlanningSchemaConfig,
7
+ QwenForcedToolCallMiddleware,
8
+ is_qwen_model,
6
9
  )
7
10
  from unique_toolkit.agentic.loop_runner.runners import (
8
11
  BasicLoopIterationRunner,
@@ -11,9 +14,12 @@ from unique_toolkit.agentic.loop_runner.runners import (
11
14
 
12
15
  __all__ = [
13
16
  "LoopIterationRunner",
17
+ "QWEN_FORCED_TOOL_CALL_PROMPT_INSTRUCTION",
14
18
  "PlanningConfig",
15
19
  "PlanningMiddleware",
16
20
  "PlanningSchemaConfig",
21
+ "QwenForcedToolCallMiddleware",
22
+ "is_qwen_model",
17
23
  "BasicLoopIterationRunnerConfig",
18
24
  "BasicLoopIterationRunner",
19
25
  ]
@@ -3,5 +3,17 @@ from unique_toolkit.agentic.loop_runner.middleware.planning import (
3
3
  PlanningMiddleware,
4
4
  PlanningSchemaConfig,
5
5
  )
6
+ from unique_toolkit.agentic.loop_runner.middleware.qwen_forced_tool_call import (
7
+ QWEN_FORCED_TOOL_CALL_PROMPT_INSTRUCTION,
8
+ QwenForcedToolCallMiddleware,
9
+ is_qwen_model,
10
+ )
6
11
 
7
- __all__ = ["PlanningConfig", "PlanningMiddleware", "PlanningSchemaConfig"]
12
+ __all__ = [
13
+ "PlanningConfig",
14
+ "PlanningMiddleware",
15
+ "PlanningSchemaConfig",
16
+ "QWEN_FORCED_TOOL_CALL_PROMPT_INSTRUCTION",
17
+ "QwenForcedToolCallMiddleware",
18
+ "is_qwen_model",
19
+ ]
@@ -0,0 +1,13 @@
1
+ from unique_toolkit.agentic.loop_runner.middleware.qwen_forced_tool_call.helpers import (
2
+ is_qwen_model,
3
+ )
4
+ from unique_toolkit.agentic.loop_runner.middleware.qwen_forced_tool_call.qwen_forced_tool_call import (
5
+ QWEN_FORCED_TOOL_CALL_PROMPT_INSTRUCTION,
6
+ QwenForcedToolCallMiddleware,
7
+ )
8
+
9
+ __all__ = [
10
+ "QwenForcedToolCallMiddleware",
11
+ "QWEN_FORCED_TOOL_CALL_PROMPT_INSTRUCTION",
12
+ "is_qwen_model",
13
+ ]
@@ -0,0 +1,33 @@
1
+ from unique_toolkit.language_model.infos import LanguageModelInfo
2
+ from unique_toolkit.language_model.schemas import (
3
+ LanguageModelMessageRole,
4
+ LanguageModelMessages,
5
+ )
6
+
7
+
8
+ def is_qwen_model(*, model: str | LanguageModelInfo | None) -> bool:
9
+ """Check if the model is a Qwen model."""
10
+ if isinstance(model, LanguageModelInfo):
11
+ name = model.name
12
+ # name is an Enum with a .value attribute
13
+ return "qwen" in str(getattr(name, "value", name)).lower()
14
+ elif isinstance(model, str):
15
+ return "qwen" in model.lower()
16
+ return False
17
+
18
+
19
+ def append_qwen_forced_tool_call_instruction(
20
+ *,
21
+ messages: LanguageModelMessages,
22
+ forced_tool_call_instruction: str,
23
+ ) -> LanguageModelMessages:
24
+ """Append tool call instruction to the last user message for Qwen models."""
25
+ messages_list = list(messages)
26
+ for i in range(len(messages_list) - 1, -1, -1):
27
+ msg = messages_list[i]
28
+ if msg.role == LanguageModelMessageRole.USER and isinstance(msg.content, str):
29
+ messages_list[i] = msg.model_copy(
30
+ update={"content": msg.content + "\n" + forced_tool_call_instruction}
31
+ )
32
+ break
33
+ return LanguageModelMessages(root=messages_list)
@@ -0,0 +1,50 @@
1
+ import logging
2
+ from typing import Unpack
3
+
4
+ from unique_toolkit.agentic.loop_runner.base import (
5
+ LoopIterationRunner,
6
+ _LoopIterationRunnerKwargs,
7
+ )
8
+ from unique_toolkit.agentic.loop_runner.middleware.qwen_forced_tool_call.helpers import (
9
+ append_qwen_forced_tool_call_instruction,
10
+ )
11
+ from unique_toolkit.chat.service import LanguageModelStreamResponse
12
+
13
+ _LOGGER = logging.getLogger(__name__)
14
+
15
+ QWEN_FORCED_TOOL_CALL_PROMPT_INSTRUCTION = (
16
+ "Tool Call Instruction: \nYou always have to return a tool call. "
17
+ "You must start the response with <tool_call> and end with </tool_call>. "
18
+ "Do NOT provide natural language explanations, summaries, or any text outside the <tool_call> block."
19
+ )
20
+
21
+
22
+ class QwenForcedToolCallMiddleware(LoopIterationRunner):
23
+ def __init__(
24
+ self,
25
+ *,
26
+ loop_runner: LoopIterationRunner,
27
+ qwen_forced_tool_call_prompt_instruction: str,
28
+ ) -> None:
29
+ self._qwen_forced_tool_call_prompt_instruction = (
30
+ qwen_forced_tool_call_prompt_instruction
31
+ )
32
+ self._loop_runner = loop_runner
33
+
34
+ async def __call__(
35
+ self, **kwargs: Unpack[_LoopIterationRunnerKwargs]
36
+ ) -> LanguageModelStreamResponse:
37
+ tool_choices = kwargs.get("tool_choices") or []
38
+ iteration_index = kwargs["iteration_index"]
39
+
40
+ # For Qwen models, append tool call instruction to the last user message. These models ignore the parameter tool_choice.
41
+ if len(tool_choices) > 0 and iteration_index == 0 and kwargs.get("messages"):
42
+ _LOGGER.info(
43
+ "Appending tool call instruction to the last user message for Qwen models to force tool calls."
44
+ )
45
+ kwargs["messages"] = append_qwen_forced_tool_call_instruction(
46
+ messages=kwargs["messages"],
47
+ forced_tool_call_instruction=self._qwen_forced_tool_call_prompt_instruction,
48
+ )
49
+
50
+ return await self._loop_runner(**kwargs)
@@ -56,13 +56,15 @@ class BasicLoopIterationRunner(LoopIterationRunner):
56
56
 
57
57
  responses: list[LanguageModelStreamResponse] = []
58
58
 
59
+ available_tools = {t.name: t for t in kwargs.get("tools") or []}
60
+
59
61
  for opt in tool_choices:
60
- responses.append(
61
- await stream_response(
62
- loop_runner_kwargs=kwargs,
63
- tool_choice=opt,
64
- )
65
- )
62
+ func_name = opt.get("function", {}).get("name")
63
+ limited_tool = available_tools.get(func_name) if func_name else None
64
+ stream_kwargs = {"loop_runner_kwargs": kwargs, "tool_choice": opt}
65
+ if limited_tool:
66
+ stream_kwargs["tools"] = [limited_tool]
67
+ responses.append(await stream_response(**stream_kwargs))
66
68
 
67
69
  # Merge responses and refs:
68
70
  tool_calls = []
@@ -65,7 +65,7 @@ class SubAgentDisplayConfig(BaseModel):
65
65
  )
66
66
  force_include_references: bool = Field(
67
67
  default=False,
68
- description="If set, the sub agent references will be added to the main agent response references even in not mentioned in the main agent response text.",
68
+ description="If set, the sub agent references will be added to the main agent response references even if not mentioned in the main agent response text.",
69
69
  )
70
70
 
71
71
  answer_substrings_config: list[SubAgentAnswerSubstringConfig] = Field(
@@ -37,6 +37,8 @@ References: <sup><name>SubAgentName 4</name>2</sup><sup><name>SubAgentName 4</na
37
37
 
38
38
  6. Fact repetition: If you reuse a fact from SubAgentName, you MUST reference it again inline with the correct format.
39
39
 
40
+ 7. You can ONLY use references if they are present in the subagent response! You must NOT create any references!
41
+
40
42
  Reminder:
41
43
  Inline = directly next to the fact, inside the same sentence or bullet.
42
44
  """.strip()
@@ -13,6 +13,7 @@ class SubAgentSystemReminderType(StrEnum):
13
13
  FIXED = "fixed"
14
14
  REGEXP = "regexp"
15
15
  REFERENCE = "reference"
16
+ NO_REFERENCE = "no_reference"
16
17
 
17
18
 
18
19
  T = TypeVar("T", bound=SubAgentSystemReminderType)
@@ -31,12 +32,24 @@ The reminder to add to the tool response. The reminder can be a Jinja template a
31
32
  """.strip()
32
33
 
33
34
 
35
+ class NoReferenceSystemReminderConfig(SystemReminderConfig):
36
+ """A system reminder that is only added if the sub agent response does not contain any references."""
37
+
38
+ type: Literal[SubAgentSystemReminderType.NO_REFERENCE] = (
39
+ SubAgentSystemReminderType.NO_REFERENCE
40
+ )
41
+ reminder: str = Field(
42
+ default="Do NOT create any references from this sub agent in your response! The sub agent response does not contain any references.",
43
+ description=_SYSTEM_REMINDER_FIELD_DESCRIPTION,
44
+ )
45
+
46
+
34
47
  class ReferenceSystemReminderConfig(SystemReminderConfig):
35
48
  type: Literal[SubAgentSystemReminderType.REFERENCE] = (
36
49
  SubAgentSystemReminderType.REFERENCE
37
50
  )
38
51
  reminder: str = Field(
39
- default="Rememeber to properly reference EACH fact from sub agent {{ display_name }}'s response with the correct format INLINE.",
52
+ default="Rememeber to properly reference EACH fact from sub agent {{ display_name }}'s response with the correct format INLINE. You MUST COPY THE REFERENCE AS PRESENT IN THE SUBAGENT RESPONSE.",
40
53
  description=_SYSTEM_REMINDER_FIELD_DESCRIPTION,
41
54
  )
42
55
 
@@ -69,6 +82,13 @@ class RegExpDetectedSystemReminderConfig(SystemReminderConfig):
69
82
  )
70
83
 
71
84
 
85
+ SystemReminderConfigType = (
86
+ FixedSystemReminderConfig
87
+ | RegExpDetectedSystemReminderConfig
88
+ | ReferenceSystemReminderConfig
89
+ | NoReferenceSystemReminderConfig
90
+ )
91
+
72
92
  DEFAULT_PARAM_DESCRIPTION_SUB_AGENT_USER_MESSAGE = """
73
93
  This is the message that will be sent to the sub-agent.
74
94
  """.strip()
@@ -147,9 +167,7 @@ class SubAgentToolConfig(BaseToolConfig):
147
167
 
148
168
  system_reminders_config: list[
149
169
  Annotated[
150
- FixedSystemReminderConfig
151
- | RegExpDetectedSystemReminderConfig
152
- | ReferenceSystemReminderConfig,
170
+ SystemReminderConfigType,
153
171
  Field(discriminator="type"),
154
172
  ]
155
173
  ] = Field(
@@ -12,7 +12,7 @@ from unique_sdk.utils.chat_in_space import send_message_and_wait_for_completion
12
12
 
13
13
  from unique_toolkit._common.referencing import (
14
14
  get_all_ref_numbers,
15
- remove_all_refs,
15
+ get_detection_pattern_for_ref,
16
16
  replace_ref_number,
17
17
  )
18
18
  from unique_toolkit._common.utils.jinja.render import render_template
@@ -29,6 +29,7 @@ from unique_toolkit.agentic.tools.a2a.tool.config import (
29
29
  RegExpDetectedSystemReminderConfig,
30
30
  SubAgentSystemReminderType,
31
31
  SubAgentToolConfig,
32
+ SystemReminderConfigType,
32
33
  )
33
34
  from unique_toolkit.agentic.tools.factory import ToolFactory
34
35
  from unique_toolkit.agentic.tools.schemas import ToolCallResponse
@@ -201,15 +202,39 @@ class SubAgentTool(Tool[SubAgentToolConfig]):
201
202
  if response["text"] is None:
202
203
  raise ValueError("No response returned from sub agent")
203
204
 
205
+ has_refs = False
206
+ content = ""
207
+ content_chunks = None
204
208
  if self.config.returns_content_chunks:
205
- content = ""
206
209
  content_chunks = _ContentChunkList.validate_json(response["text"])
207
210
  else:
208
- content = self._prepare_response_references(
211
+ has_refs = self.config.use_sub_agent_references and _response_has_refs(
212
+ response
213
+ )
214
+ content = response["text"]
215
+ if has_refs:
216
+ refs = response["references"]
217
+ assert refs is not None # Checked in _response_has_refs
218
+ content = _prepare_sub_agent_response_refs(
219
+ response=content,
220
+ name=self.name,
221
+ sequence_number=sequence_number,
222
+ refs=refs,
223
+ )
224
+ content = _remove_extra_refs(content, refs=refs)
225
+ else:
226
+ content = _remove_extra_refs(content, refs=[])
227
+
228
+ system_reminders = []
229
+ if not self.config.returns_content_chunks:
230
+ system_reminders = _get_sub_agent_system_reminders(
209
231
  response=response["text"],
232
+ configs=self.config.system_reminders_config,
233
+ name=self.name,
234
+ display_name=self.display_name(),
210
235
  sequence_number=sequence_number,
236
+ has_refs=has_refs,
211
237
  )
212
- content_chunks = None
213
238
 
214
239
  await self._notify_progress(
215
240
  tool_call=tool_call,
@@ -223,58 +248,11 @@ class SubAgentTool(Tool[SubAgentToolConfig]):
223
248
  content=_format_response(
224
249
  tool_name=self.name,
225
250
  text=content,
226
- system_reminders=self._get_system_reminders(response),
251
+ system_reminders=system_reminders,
227
252
  ),
228
253
  content_chunks=content_chunks,
229
254
  )
230
255
 
231
- def _get_system_reminders(self, message: unique_sdk.Space.Message) -> list[str]:
232
- reminders = []
233
- for reminder_config in self.config.system_reminders_config:
234
- if reminder_config.type == SubAgentSystemReminderType.FIXED:
235
- reminders.append(
236
- render_template(
237
- reminder_config.reminder,
238
- display_name=self.display_name(),
239
- tool_name=self.name,
240
- )
241
- )
242
- elif (
243
- reminder_config.type == SubAgentSystemReminderType.REFERENCE
244
- and self.config.use_sub_agent_references
245
- and message["references"] is not None
246
- and len(message["references"]) > 0
247
- ):
248
- reminders.append(
249
- render_template(
250
- reminder_config.reminder,
251
- display_name=self.display_name(),
252
- tool_name=self.name,
253
- )
254
- )
255
- elif (
256
- reminder_config.type == SubAgentSystemReminderType.REGEXP
257
- and message["text"] is not None
258
- ):
259
- reminder_config = cast(
260
- RegExpDetectedSystemReminderConfig, reminder_config
261
- )
262
- text_matches = [
263
- match.group(0)
264
- for match in reminder_config.regexp.finditer(message["text"])
265
- ]
266
- if len(text_matches) > 0:
267
- reminders.append(
268
- render_template(
269
- reminder_config.reminder,
270
- display_name=self.display_name(),
271
- tool_name=self.name,
272
- text_matches=text_matches,
273
- )
274
- )
275
-
276
- return reminders
277
-
278
256
  async def _get_chat_id(self) -> str | None:
279
257
  if not self.config.reuse_chat:
280
258
  return None
@@ -290,23 +268,6 @@ class SubAgentTool(Tool[SubAgentToolConfig]):
290
268
 
291
269
  return None
292
270
 
293
- def _prepare_response_references(self, response: str, sequence_number: int) -> str:
294
- if not self.config.use_sub_agent_references:
295
- # Remove all references from the response
296
- response = remove_all_refs(response)
297
- return response
298
-
299
- for ref_number in get_all_ref_numbers(response):
300
- reference = self.get_sub_agent_reference_format(
301
- name=self.name,
302
- sequence_number=sequence_number,
303
- reference_number=ref_number,
304
- )
305
- response = replace_ref_number(
306
- text=response, ref_number=ref_number, replacement=reference
307
- )
308
- return response
309
-
310
271
  async def _save_chat_id(self, chat_id: str) -> None:
311
272
  if not self.config.reuse_chat:
312
273
  return
@@ -390,4 +351,93 @@ def _format_response(tool_name: str, text: str, system_reminders: list[str]) ->
390
351
  return json.dumps(response, indent=2)
391
352
 
392
353
 
354
+ def _response_has_refs(response: unique_sdk.Space.Message) -> bool:
355
+ if (
356
+ response["text"] is None
357
+ or response["references"] is None
358
+ or len(response["references"]) == 0
359
+ ):
360
+ return False
361
+
362
+ for ref in response["references"]:
363
+ if (
364
+ re.search(
365
+ get_detection_pattern_for_ref(ref["sequenceNumber"]), response["text"]
366
+ )
367
+ is not None
368
+ ):
369
+ return True
370
+
371
+ return False
372
+
373
+
374
+ def _remove_extra_refs(response: str, refs: list[unique_sdk.Space.Reference]) -> str:
375
+ text_ref_numbers = set(get_all_ref_numbers(response))
376
+ extra_ref_numbers = text_ref_numbers - set(ref["sequenceNumber"] for ref in refs)
377
+
378
+ for ref_num in extra_ref_numbers:
379
+ response = get_detection_pattern_for_ref(ref_num).sub("", response)
380
+
381
+ return response
382
+
383
+
384
+ def _prepare_sub_agent_response_refs(
385
+ response: str,
386
+ name: str,
387
+ sequence_number: int,
388
+ refs: list[unique_sdk.Space.Reference],
389
+ ) -> str:
390
+ for ref in refs:
391
+ ref_number = ref["sequenceNumber"]
392
+ reference = SubAgentTool.get_sub_agent_reference_format(
393
+ name=name, sequence_number=sequence_number, reference_number=ref_number
394
+ )
395
+ response = replace_ref_number(
396
+ text=response, ref_number=ref_number, replacement=reference
397
+ )
398
+
399
+ return response
400
+
401
+
402
+ def _get_sub_agent_system_reminders(
403
+ response: str,
404
+ configs: list[SystemReminderConfigType],
405
+ name: str,
406
+ display_name: str,
407
+ sequence_number: int,
408
+ has_refs: bool,
409
+ ) -> list[str]:
410
+ reminders = []
411
+
412
+ for reminder_config in configs:
413
+ render_kwargs = {}
414
+ render_kwargs["display_name"] = display_name
415
+ render_kwargs["tool_name"] = name
416
+ template = None
417
+
418
+ if reminder_config.type == SubAgentSystemReminderType.FIXED:
419
+ template = reminder_config.reminder
420
+ elif (
421
+ reminder_config.type == SubAgentSystemReminderType.REFERENCE and has_refs
422
+ ) or (
423
+ reminder_config.type == SubAgentSystemReminderType.NO_REFERENCE
424
+ and not has_refs
425
+ ):
426
+ render_kwargs["tool_name"] = f"{name} {sequence_number}"
427
+ template = reminder_config.reminder
428
+ elif reminder_config.type == SubAgentSystemReminderType.REGEXP:
429
+ reminder_config = cast(RegExpDetectedSystemReminderConfig, reminder_config)
430
+ text_matches = [
431
+ match.group(0) for match in reminder_config.regexp.finditer(response)
432
+ ]
433
+ if len(text_matches) > 0:
434
+ template = reminder_config.reminder
435
+ render_kwargs["text_matches"] = text_matches
436
+
437
+ if template is not None:
438
+ reminders.append(render_template(template, **render_kwargs))
439
+
440
+ return reminders
441
+
442
+
393
443
  ToolFactory.register_tool(SubAgentTool, SubAgentToolConfig)