abstractcore 2.6.9__py3-none-any.whl → 2.9.1__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.
Files changed (46) hide show
  1. abstractcore/apps/summarizer.py +69 -27
  2. abstractcore/architectures/detection.py +190 -25
  3. abstractcore/assets/architecture_formats.json +129 -6
  4. abstractcore/assets/model_capabilities.json +803 -141
  5. abstractcore/config/main.py +2 -2
  6. abstractcore/config/manager.py +3 -1
  7. abstractcore/events/__init__.py +7 -1
  8. abstractcore/mcp/__init__.py +30 -0
  9. abstractcore/mcp/client.py +213 -0
  10. abstractcore/mcp/factory.py +64 -0
  11. abstractcore/mcp/naming.py +28 -0
  12. abstractcore/mcp/stdio_client.py +336 -0
  13. abstractcore/mcp/tool_source.py +164 -0
  14. abstractcore/processing/__init__.py +2 -2
  15. abstractcore/processing/basic_deepsearch.py +1 -1
  16. abstractcore/processing/basic_summarizer.py +379 -93
  17. abstractcore/providers/anthropic_provider.py +91 -10
  18. abstractcore/providers/base.py +540 -16
  19. abstractcore/providers/huggingface_provider.py +17 -8
  20. abstractcore/providers/lmstudio_provider.py +170 -25
  21. abstractcore/providers/mlx_provider.py +13 -10
  22. abstractcore/providers/ollama_provider.py +42 -26
  23. abstractcore/providers/openai_compatible_provider.py +87 -22
  24. abstractcore/providers/openai_provider.py +12 -9
  25. abstractcore/providers/streaming.py +201 -39
  26. abstractcore/providers/vllm_provider.py +78 -21
  27. abstractcore/server/app.py +116 -30
  28. abstractcore/structured/retry.py +20 -7
  29. abstractcore/tools/__init__.py +46 -24
  30. abstractcore/tools/abstractignore.py +166 -0
  31. abstractcore/tools/arg_canonicalizer.py +61 -0
  32. abstractcore/tools/common_tools.py +2443 -742
  33. abstractcore/tools/core.py +109 -13
  34. abstractcore/tools/handler.py +17 -3
  35. abstractcore/tools/parser.py +894 -159
  36. abstractcore/tools/registry.py +122 -18
  37. abstractcore/tools/syntax_rewriter.py +68 -6
  38. abstractcore/tools/tag_rewriter.py +186 -1
  39. abstractcore/utils/jsonish.py +111 -0
  40. abstractcore/utils/version.py +1 -1
  41. {abstractcore-2.6.9.dist-info → abstractcore-2.9.1.dist-info}/METADATA +56 -2
  42. {abstractcore-2.6.9.dist-info → abstractcore-2.9.1.dist-info}/RECORD +46 -37
  43. {abstractcore-2.6.9.dist-info → abstractcore-2.9.1.dist-info}/WHEEL +0 -0
  44. {abstractcore-2.6.9.dist-info → abstractcore-2.9.1.dist-info}/entry_points.txt +0 -0
  45. {abstractcore-2.6.9.dist-info → abstractcore-2.9.1.dist-info}/licenses/LICENSE +0 -0
  46. {abstractcore-2.6.9.dist-info → abstractcore-2.9.1.dist-info}/top_level.txt +0 -0
@@ -7,16 +7,28 @@ responses based on their architecture.
7
7
 
8
8
  import re
9
9
  import json
10
+ import ast
10
11
  from typing import List, Optional, Dict, Any
11
12
  from enum import Enum
12
13
 
13
14
  from .core import ToolCall, ToolDefinition
14
15
  from ..architectures import detect_architecture, get_architecture_format
16
+ from ..utils.jsonish import loads_dict_like as _jsonish_loads_dict_like
15
17
  from ..utils.structured_logging import get_logger
16
18
 
17
19
  logger = get_logger(__name__)
18
20
 
19
21
 
22
+ def _loads_dict_like(raw: str) -> Optional[Dict[str, Any]]:
23
+ """Parse a JSON-ish or Python-literal dict safely.
24
+
25
+ Many OSS models emit tool arguments with single quotes and Python literals
26
+ (True/False/None) even when asked for strict JSON. We accept both to keep
27
+ tool calling robust.
28
+ """
29
+ return _jsonish_loads_dict_like(raw)
30
+
31
+
20
32
  class ToolFormat(Enum):
21
33
  """Tool call formats for different architectures."""
22
34
 
@@ -41,6 +53,22 @@ def _has_json_tool_pattern(response: str) -> bool:
41
53
  json_pattern = r'\{[^{}]*["\']name["\'][^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
42
54
  return bool(re.search(json_pattern, response, re.DOTALL))
43
55
 
56
+ def _has_bracket_tool_prefix(response: str) -> bool:
57
+ """Check if response contains a `tool: [name]: {...}` style tool call prefix."""
58
+ if not response:
59
+ return False
60
+ return bool(re.search(r'(?im)^\s*tool\s*:\s*\[[^\]]+\]\s*:\s*\{', response))
61
+
62
+ def _has_harmony_tool_prefix(response: str) -> bool:
63
+ """Check if response contains a Harmony/ChatML-style tool call marker.
64
+
65
+ Example emitted by some models:
66
+ <|channel|>commentary to=list_files <|constrain|>json<|message|>{"directory_path": "..."}
67
+ """
68
+ if not response:
69
+ return False
70
+ return "<|channel|>" in response and "<|message|>" in response and "to=" in response
71
+
44
72
 
45
73
  def detect_tool_calls(response: str, model_name: Optional[str] = None) -> bool:
46
74
  """
@@ -59,6 +87,12 @@ def detect_tool_calls(response: str, model_name: Optional[str] = None) -> bool:
59
87
  # Get expected format from architecture
60
88
  tool_format = _get_tool_format(model_name)
61
89
 
90
+ # Some models emit a CLI-like prefix format regardless of architecture.
91
+ if _has_bracket_tool_prefix(response):
92
+ return True
93
+ if _has_harmony_tool_prefix(response):
94
+ return True
95
+
62
96
  # Check format-specific patterns (case-insensitive)
63
97
  response_lower = response.lower()
64
98
  if tool_format == ToolFormat.TOOL_CODE:
@@ -77,6 +111,8 @@ def detect_tool_calls(response: str, model_name: Optional[str] = None) -> bool:
77
111
  "<|tool_call|>" in response_lower,
78
112
  "<function_call" in response_lower,
79
113
  "<tool_call>" in response_lower,
114
+ _has_bracket_tool_prefix(response),
115
+ _has_harmony_tool_prefix(response),
80
116
  _has_json_tool_pattern(response),
81
117
  ])
82
118
 
@@ -113,16 +149,34 @@ def parse_tool_calls(response: str, model_name: Optional[str] = None) -> List[To
113
149
  }
114
150
 
115
151
  parser = parsers.get(tool_format, _parse_any_format)
116
- return parser(response)
117
-
118
-
119
- def format_tool_prompt(tools: List[ToolDefinition], model_name: Optional[str] = None) -> str:
152
+ calls = parser(response)
153
+ # Fallback: some models emit tool syntax that doesn't match their expected architecture format
154
+ # (e.g., `tool: [name]: {...}` or partial tags). Try the generic parser when needed.
155
+ if not calls and parser is not _parse_any_format:
156
+ calls = _parse_any_format(response)
157
+ if calls:
158
+ from .arg_canonicalizer import canonicalize_tool_arguments
159
+
160
+ for call in calls:
161
+ call.arguments = canonicalize_tool_arguments(call.name, call.arguments)
162
+ return calls
163
+
164
+
165
+ def format_tool_prompt(
166
+ tools: List[ToolDefinition],
167
+ model_name: Optional[str] = None,
168
+ *,
169
+ include_tool_list: bool = True,
170
+ include_examples: bool = True,
171
+ ) -> str:
120
172
  """
121
173
  Format tools into a system prompt based on model architecture.
122
174
 
123
175
  Args:
124
176
  tools: List of tool definitions
125
177
  model_name: Optional model name for architecture detection
178
+ include_tool_list: If False, omit per-tool listings (only include tool-call protocol/rules)
179
+ include_examples: If False, omit examples even if tools provide them
126
180
 
127
181
  Returns:
128
182
  Formatted system prompt
@@ -135,47 +189,124 @@ def format_tool_prompt(tools: List[ToolDefinition], model_name: Optional[str] =
135
189
 
136
190
  # Format based on architecture
137
191
  if tool_format == ToolFormat.TOOL_CODE:
138
- return _format_gemma_style(tools)
192
+ return _format_gemma_style(tools, include_tool_list=include_tool_list, include_examples=include_examples)
139
193
  elif tool_format == ToolFormat.SPECIAL_TOKEN:
140
- return _format_qwen_style(tools)
194
+ return _format_qwen_style(tools, include_tool_list=include_tool_list, include_examples=include_examples)
141
195
  elif tool_format == ToolFormat.FUNCTION_CALL:
142
- return _format_llama_style(tools)
196
+ return _format_llama_style(tools, include_tool_list=include_tool_list, include_examples=include_examples)
143
197
  elif tool_format == ToolFormat.XML_WRAPPED:
144
- return _format_xml_style(tools)
198
+ return _format_xml_style(tools, include_tool_list=include_tool_list, include_examples=include_examples)
199
+ elif tool_format == ToolFormat.RAW_JSON:
200
+ return _format_json_style(tools, include_tool_list=include_tool_list, include_examples=include_examples)
145
201
  else:
146
- return _format_generic_style(tools)
202
+ return _format_generic_style(tools, include_tool_list=include_tool_list, include_examples=include_examples)
147
203
 
148
204
 
149
205
  # Internal helpers
150
206
 
207
+ def _sanitize_tool_call_tags(response: str) -> str:
208
+ """
209
+ Sanitize malformed tool call tags before parsing.
210
+
211
+ Handles common LLM output malformations:
212
+ - Doubled opening tags: <|tool_call|><|tool_call|> → <|tool_call|>
213
+ - Doubled closing tags: </|tool_call|></|tool_call|> → </|tool_call|>
214
+ - Malformed closing with }: </|tool_call|} → </|tool_call|>
215
+
216
+ Args:
217
+ response: Raw model response text
218
+
219
+ Returns:
220
+ Sanitized response with normalized tool call syntax
221
+ """
222
+ if not response:
223
+ return response
224
+
225
+ original = response
226
+
227
+ # Fix doubled/multiple opening tags (collapse to single)
228
+ # Handles: <|tool_call|><|tool_call|> or <|tool_call|>\n<|tool_call|>
229
+ response = re.sub(
230
+ r'(<\|tool_call\|>\s*)+',
231
+ r'<|tool_call|>',
232
+ response,
233
+ flags=re.IGNORECASE
234
+ )
235
+
236
+ # Fix malformed closing tags with } instead of |>
237
+ # Handles: </|tool_call|} → </|tool_call|>
238
+ response = re.sub(
239
+ r'</\|tool_call\|\}',
240
+ r'</|tool_call|>',
241
+ response,
242
+ flags=re.IGNORECASE
243
+ )
244
+
245
+ # Fix doubled/multiple closing tags (collapse to single)
246
+ response = re.sub(
247
+ r'(</\|tool_call\|>\s*)+',
248
+ r'</|tool_call|>',
249
+ response,
250
+ flags=re.IGNORECASE
251
+ )
252
+
253
+ if response != original:
254
+ logger.debug(f"Sanitized malformed tool call tags")
255
+
256
+ return response
257
+
258
+
151
259
  def _get_tool_format(model_name: Optional[str]) -> ToolFormat:
152
260
  """Get tool format for a model."""
153
261
  if not model_name:
154
- return ToolFormat.RAW_JSON
262
+ # When no model specified, use NATIVE which triggers _parse_any_format
263
+ # This ensures all formats are tried including <|tool_call|> special tokens
264
+ return ToolFormat.NATIVE
155
265
 
156
266
  architecture = detect_architecture(model_name)
157
267
  arch_format = get_architecture_format(architecture)
158
268
 
159
- tool_format = arch_format.get("tool_format", "json")
269
+ tool_format = str(arch_format.get("tool_format", "json") or "").strip().lower()
270
+ message_format = str(arch_format.get("message_format", "") or "").strip().lower()
160
271
 
272
+ # tool_format values are defined in `abstractcore/assets/architecture_formats.json`.
273
+ # We interpret them as the model's *preferred tool-call syntax* and fall back to
274
+ # `_parse_any_format` when the model emits a different convention.
161
275
  if tool_format == "special_token":
162
276
  return ToolFormat.SPECIAL_TOKEN
163
- elif tool_format == "xml":
277
+ if tool_format == "xml":
164
278
  return ToolFormat.XML_WRAPPED
165
- elif tool_format == "pythonic":
279
+ if tool_format == "pythonic":
166
280
  return ToolFormat.TOOL_CODE
167
- elif tool_format == "native":
281
+ if tool_format == "json":
282
+ return ToolFormat.RAW_JSON
283
+ if tool_format in {"openai_functions", "native", "none"}:
284
+ # Native/OpenAI-functions tool calls are expected in structured response fields, not text.
285
+ # If tool syntax leaks into content, we parse with the generic fallback.
168
286
  return ToolFormat.NATIVE
169
- else:
287
+
288
+ if tool_format == "prompted":
289
+ # "prompted" indicates the model relies on prompt-injected tool syntax.
290
+ # Choose the most likely format based on the architecture's message format.
291
+ # - Qwen/ChatML-like formats generally use <|tool_call|> special tokens.
292
+ if message_format == "im_start_end":
293
+ return ToolFormat.SPECIAL_TOKEN
294
+ # - LLaMA-style prompted tools commonly use <function_call>...</function_call>.
170
295
  return ToolFormat.FUNCTION_CALL
171
296
 
297
+ # Conservative fallback: function-call wrapper (and then _parse_any_format fallback).
298
+ return ToolFormat.FUNCTION_CALL
299
+
172
300
 
173
301
 
174
302
 
175
303
  def _parse_special_token(response: str) -> List[ToolCall]:
176
304
  """Parse Qwen-style <|tool_call|> format with robust fallback."""
177
305
  tool_calls = []
178
-
306
+
307
+ # SANITIZE FIRST: Fix malformed tags (doubled tags, broken closing tags)
308
+ response = _sanitize_tool_call_tags(response)
309
+
179
310
  # Pre-process: Remove markdown code fences that might wrap tool calls
180
311
  # This handles cases like ```json\n<|tool_call|>...\n```
181
312
  cleaned_response = re.sub(r'```(?:json|python|tool_code|tool_call)?\s*\n', '', response, flags=re.IGNORECASE)
@@ -250,8 +381,40 @@ def _parse_special_token(response: str) -> List[ToolCall]:
250
381
  try:
251
382
  tool_data = json.loads(json_str)
252
383
  except json.JSONDecodeError:
253
- # Fallback: fix common LLM JSON issues (unescaped newlines)
254
- fixed_json = json_str.replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t')
384
+ # Fallback: Escape newlines/tabs only inside JSON string values
385
+ # This prevents escaping structural newlines which would break parsing
386
+ # Algorithm: Track when inside/outside strings, only escape within strings
387
+ in_string = False
388
+ escaped = False
389
+ fixed = []
390
+
391
+ for char in json_str:
392
+ if escaped:
393
+ # Previous char was backslash, this is part of escape sequence
394
+ fixed.append(char)
395
+ escaped = False
396
+ elif char == '\\':
397
+ # Start of escape sequence
398
+ fixed.append(char)
399
+ escaped = True
400
+ elif char == '"':
401
+ # Toggle string context
402
+ in_string = not in_string
403
+ fixed.append(char)
404
+ elif in_string and char == '\n':
405
+ # Newline inside string - escape it
406
+ fixed.append('\\n')
407
+ elif in_string and char == '\r':
408
+ # CR inside string - escape it
409
+ fixed.append('\\r')
410
+ elif in_string and char == '\t':
411
+ # Tab inside string - escape it
412
+ fixed.append('\\t')
413
+ else:
414
+ # Normal character or structural whitespace
415
+ fixed.append(char)
416
+
417
+ fixed_json = ''.join(fixed)
255
418
  tool_data = json.loads(fixed_json)
256
419
 
257
420
  if isinstance(tool_data, dict):
@@ -291,7 +454,9 @@ def _parse_function_call(response: str) -> List[ToolCall]:
291
454
  for match in re.finditer(pattern, response, re.DOTALL):
292
455
  try:
293
456
  json_str = match.group(1)
294
- tool_data = json.loads(json_str)
457
+ tool_data = _loads_dict_like(json_str)
458
+ if not isinstance(tool_data, dict):
459
+ continue
295
460
 
296
461
  tool_call = ToolCall(
297
462
  name=tool_data.get("name", ""),
@@ -310,23 +475,73 @@ def _parse_xml_wrapped(response: str) -> List[ToolCall]:
310
475
  """Parse XML-wrapped tool calls."""
311
476
  tool_calls = []
312
477
 
313
- # Pattern for XML format
314
- pattern = r'<tool_call>\s*({.*?})\s*</tool_call>'
478
+ # Pattern for XML format.
479
+ #
480
+ # Supported inner payloads:
481
+ # 1) JSON-ish dict (our canonical prompted-tool wrapper):
482
+ # <tool_call>{"name":"read_file","arguments":{...}}</tool_call>
483
+ # 2) Nemotron XML-ish wrapper (observed in the wild):
484
+ # <tool_call>
485
+ # <function=write_file>
486
+ # <parameter=file_path>...</parameter>
487
+ # <parameter=content>...</parameter>
488
+ # </function>
489
+ # </tool_call>
490
+ pattern = r'<tool_call>\s*(.*?)\s*</tool_call>'
491
+
492
+ for match in re.finditer(pattern, response, re.DOTALL | re.IGNORECASE):
493
+ body = match.group(1)
494
+ if not isinstance(body, str):
495
+ continue
315
496
 
316
- for match in re.finditer(pattern, response, re.DOTALL):
317
- try:
318
- json_str = match.group(1)
319
- tool_data = json.loads(json_str)
497
+ body_stripped = body.strip()
320
498
 
321
- tool_call = ToolCall(
322
- name=tool_data.get("name", ""),
323
- arguments=tool_data.get("arguments", {}),
324
- call_id=tool_data.get("id")
325
- )
326
- tool_calls.append(tool_call)
499
+ # Case 1: JSON-ish dict inside <tool_call>...</tool_call>
500
+ if body_stripped.startswith("{") and body_stripped.endswith("}"):
501
+ try:
502
+ tool_data = _loads_dict_like(body_stripped)
503
+ if not isinstance(tool_data, dict):
504
+ continue
327
505
 
328
- except json.JSONDecodeError as e:
329
- logger.warning(f"Failed to parse XML tool call JSON: {json_str} - {e}")
506
+ tool_calls.append(ToolCall(
507
+ name=tool_data.get("name", ""),
508
+ arguments=tool_data.get("arguments", {}),
509
+ call_id=tool_data.get("id")
510
+ ))
511
+ continue
512
+ except json.JSONDecodeError as e:
513
+ logger.warning(f"Failed to parse XML tool call JSON: {body_stripped} - {e}")
514
+ continue
515
+
516
+ # Case 2: Nemotron XML-ish function/parameter encoding
517
+ func_match = re.search(r'<function\s*=\s*([a-zA-Z0-9_-]+)\s*>', body, re.IGNORECASE)
518
+ if not func_match:
519
+ continue
520
+ func_name = func_match.group(1).strip()
521
+ if not func_name:
522
+ continue
523
+
524
+ arguments: Dict[str, Any] = {}
525
+ for param_match in re.finditer(
526
+ r'<parameter\s*=\s*([a-zA-Z0-9_-]+)\s*>(.*?)</parameter>',
527
+ body,
528
+ re.DOTALL | re.IGNORECASE,
529
+ ):
530
+ key = (param_match.group(1) or "").strip()
531
+ raw_value = param_match.group(2) or ""
532
+ if not key:
533
+ continue
534
+
535
+ # Preserve content as-is, but strip the common leading/trailing newline artifacts
536
+ # introduced by pretty-printed tag blocks.
537
+ value = raw_value.replace("\r\n", "\n")
538
+ if value.startswith("\n"):
539
+ value = value[1:]
540
+ if value.endswith("\n"):
541
+ value = value[:-1]
542
+ arguments[key] = value
543
+
544
+ tool_calls.append(ToolCall(name=func_name, arguments=arguments, call_id=None))
330
545
 
331
546
  return tool_calls
332
547
 
@@ -343,7 +558,9 @@ def _parse_tool_code(response: str) -> List[ToolCall]:
343
558
 
344
559
  # Try to parse as JSON first
345
560
  try:
346
- tool_data = json.loads(code_content)
561
+ tool_data = _loads_dict_like(code_content)
562
+ if not isinstance(tool_data, dict):
563
+ raise json.JSONDecodeError("not a dict", code_content, 0)
347
564
  tool_call = ToolCall(
348
565
  name=tool_data.get("name", ""),
349
566
  arguments=tool_data.get("arguments", {}),
@@ -360,14 +577,31 @@ def _parse_tool_code(response: str) -> List[ToolCall]:
360
577
  func_name = func_match.group(1)
361
578
  args_str = func_match.group(2)
362
579
 
363
- # Simple argument parsing (could be enhanced)
580
+ # Simple, safe argument parsing for common keyword args.
364
581
  arguments = {}
365
582
  if args_str.strip():
366
- try:
367
- # Try eval for simple cases (be careful!)
368
- arguments = eval(f"dict({args_str})")
369
- except:
370
- logger.warning(f"Failed to parse function arguments: {args_str}")
583
+ arg_pattern = r'(\w+)\s*=\s*(".*?"|\'.*?\'|[^,\)]+)'
584
+ for arg_match in re.finditer(arg_pattern, args_str):
585
+ key = arg_match.group(1)
586
+ raw_value = arg_match.group(2).strip()
587
+ value: Any = raw_value
588
+ if (raw_value.startswith('"') and raw_value.endswith('"')) or (
589
+ raw_value.startswith("'") and raw_value.endswith("'")
590
+ ):
591
+ value = raw_value[1:-1]
592
+ elif raw_value.lower() in ("true", "false"):
593
+ value = raw_value.lower() == "true"
594
+ elif raw_value.lower() in ("none", "null"):
595
+ value = None
596
+ else:
597
+ try:
598
+ value = int(raw_value)
599
+ except Exception:
600
+ try:
601
+ value = float(raw_value)
602
+ except Exception:
603
+ value = raw_value
604
+ arguments[str(key)] = value
371
605
 
372
606
  tool_call = ToolCall(
373
607
  name=func_name,
@@ -388,7 +622,9 @@ def _parse_raw_json(response: str) -> List[ToolCall]:
388
622
  for match in re.finditer(json_pattern, response):
389
623
  try:
390
624
  json_str = match.group(0)
391
- tool_data = json.loads(json_str)
625
+ tool_data = _loads_dict_like(json_str)
626
+ if not isinstance(tool_data, dict):
627
+ continue
392
628
 
393
629
  if "name" in tool_data:
394
630
  tool_call = ToolCall(
@@ -406,7 +642,9 @@ def _parse_raw_json(response: str) -> List[ToolCall]:
406
642
  for match in re.finditer(code_block_pattern, response, re.DOTALL):
407
643
  try:
408
644
  json_str = match.group(1).strip()
409
- tool_data = json.loads(json_str)
645
+ tool_data = _loads_dict_like(json_str)
646
+ if not isinstance(tool_data, dict):
647
+ continue
410
648
 
411
649
  if "name" in tool_data:
412
650
  tool_call = ToolCall(
@@ -422,8 +660,245 @@ def _parse_raw_json(response: str) -> List[ToolCall]:
422
660
  return tool_calls
423
661
 
424
662
 
663
+ def _parse_bracket_tool_prefix(response: str) -> List[ToolCall]:
664
+ """Parse `tool: [name]: { ... }` format (arguments-only JSON)."""
665
+ tool_calls: List[ToolCall] = []
666
+ if not response:
667
+ return tool_calls
668
+
669
+ def _find_matching_brace(text: str, start: int) -> int:
670
+ """Return index of the matching '}' for a '{' at `start`, or -1."""
671
+ depth = 0
672
+ in_string = False
673
+ quote = ""
674
+ escaped = False
675
+
676
+ for i in range(start, len(text)):
677
+ ch = text[i]
678
+
679
+ if in_string:
680
+ if escaped:
681
+ escaped = False
682
+ continue
683
+ if ch == "\\":
684
+ escaped = True
685
+ continue
686
+ if ch == quote:
687
+ in_string = False
688
+ quote = ""
689
+ continue
690
+
691
+ if ch in ("'", '"'):
692
+ in_string = True
693
+ quote = ch
694
+ continue
695
+
696
+ if ch == "{":
697
+ depth += 1
698
+ continue
699
+ if ch == "}":
700
+ depth -= 1
701
+ if depth == 0:
702
+ return i
703
+
704
+ return -1
705
+
706
+ # Common in some OSS model tool conventions.
707
+ # Example (single-line):
708
+ # tool: [list_files]: {"directory_path":"rtype","recursive":true}
709
+ # Example (multi-line):
710
+ # tool: [list_files]: {
711
+ # "directory_path": "rtype",
712
+ # "recursive": true
713
+ # }
714
+ header_re = re.compile(r"(?im)^\s*tool\s*:\s*\[([a-zA-Z0-9_\-]+)\]\s*:\s*")
715
+ for match in header_re.finditer(response):
716
+ name = str(match.group(1) or "").strip()
717
+ if not name:
718
+ continue
719
+
720
+ # Find the first opening brace after the header (allow whitespace/newlines).
721
+ brace_start = response.find("{", match.end())
722
+ if brace_start == -1:
723
+ continue
724
+
725
+ # Only allow whitespace between header end and '{' (avoid grabbing unrelated JSON).
726
+ between = response[match.end() : brace_start]
727
+ if between and any(not c.isspace() for c in between):
728
+ continue
729
+
730
+ brace_end = _find_matching_brace(response, brace_start)
731
+ if brace_end == -1:
732
+ continue
733
+
734
+ raw_args = response[brace_start : brace_end + 1]
735
+ args = _loads_dict_like(raw_args)
736
+ if not isinstance(args, dict):
737
+ continue
738
+
739
+ tool_calls.append(ToolCall(name=name, arguments=args))
740
+
741
+ return tool_calls
742
+
743
+
744
+ def _parse_harmony_tool_prefix(response: str) -> List[ToolCall]:
745
+ """Parse Harmony/ChatML-style tool calls embedded in content.
746
+
747
+ Example:
748
+ <|channel|>commentary to=list_files <|constrain|>json<|message|>{"directory_path":"./x","recursive":true}
749
+ """
750
+ tool_calls: List[ToolCall] = []
751
+ if not response:
752
+ return tool_calls
753
+
754
+ if "<|channel|>" not in response or "<|message|>" not in response or "to=" not in response:
755
+ return tool_calls
756
+
757
+ def _find_matching_brace(text: str, start: int) -> int:
758
+ """Return index of the matching '}' for a '{' at `start`, or -1."""
759
+ depth = 0
760
+ in_string = False
761
+ quote = ""
762
+ escaped = False
763
+
764
+ for i in range(start, len(text)):
765
+ ch = text[i]
766
+
767
+ if in_string:
768
+ if escaped:
769
+ escaped = False
770
+ continue
771
+ if ch == "\\":
772
+ escaped = True
773
+ continue
774
+ if ch == quote:
775
+ in_string = False
776
+ quote = ""
777
+ continue
778
+
779
+ if ch in ("'", '"'):
780
+ in_string = True
781
+ quote = ch
782
+ continue
783
+
784
+ if ch == "{":
785
+ depth += 1
786
+ continue
787
+ if ch == "}":
788
+ depth -= 1
789
+ if depth == 0:
790
+ return i
791
+
792
+ return -1
793
+
794
+ # Match "<|channel|>... to=TOOL_NAME" and then find the following <|message|>{...}.
795
+ header_re = re.compile(
796
+ r"(?i)<\|channel\|>\s*[a-zA-Z0-9_\-]+\s+to=([a-zA-Z0-9_\-\.]+)\b"
797
+ )
798
+ for match in header_re.finditer(response):
799
+ raw_name = str(match.group(1) or "").strip()
800
+ if not raw_name:
801
+ continue
802
+
803
+ # Normalize common prefixes used by some tool-call transcripts.
804
+ name = raw_name
805
+ if name.startswith("functions."):
806
+ name = name.split(".", 1)[1].strip()
807
+ if not name:
808
+ continue
809
+
810
+ # Find the next "<|message|>" after the header.
811
+ msg_tag = "<|message|>"
812
+ msg_start = response.find(msg_tag, match.end())
813
+ if msg_start == -1:
814
+ continue
815
+
816
+ brace_start = response.find("{", msg_start + len(msg_tag))
817
+ if brace_start == -1:
818
+ continue
819
+
820
+ # Only allow whitespace between the message tag and '{'.
821
+ between = response[msg_start + len(msg_tag) : brace_start]
822
+ if between and any(not c.isspace() for c in between):
823
+ continue
824
+
825
+ brace_end = _find_matching_brace(response, brace_start)
826
+ if brace_end == -1:
827
+ # Some models occasionally omit the final closing brace(s) when emitting a
828
+ # Harmony tool transcript. Try a best-effort recovery by balancing braces
829
+ # to the end of the message and parsing the result.
830
+ raw_args = response[brace_start:].strip()
831
+
832
+ def _balance_braces(text: str) -> str:
833
+ depth = 0
834
+ in_string = False
835
+ quote = ""
836
+ escaped = False
837
+ for ch in text:
838
+ if in_string:
839
+ if escaped:
840
+ escaped = False
841
+ continue
842
+ if ch == "\\":
843
+ escaped = True
844
+ continue
845
+ if ch == quote:
846
+ in_string = False
847
+ quote = ""
848
+ continue
849
+ if ch in ("'", '"'):
850
+ in_string = True
851
+ quote = ch
852
+ continue
853
+ if ch == "{":
854
+ depth += 1
855
+ continue
856
+ if ch == "}":
857
+ depth -= 1
858
+ continue
859
+ if depth > 0:
860
+ return text + ("}" * depth)
861
+ return text
862
+
863
+ raw_args = _balance_braces(raw_args)
864
+ else:
865
+ raw_args = response[brace_start : brace_end + 1]
866
+ payload = _loads_dict_like(raw_args)
867
+ if not isinstance(payload, dict):
868
+ continue
869
+
870
+ # Some models (notably OpenAI's gpt-oss via LM Studio) emit a wrapper payload:
871
+ # {"name":"tool_name","arguments":{...},"call_id": "..."}
872
+ # In that case, unwrap `arguments` so runtime tool execution receives only
873
+ # the tool kwargs (and not unexpected keys like "name").
874
+ call_id = None
875
+ args: Any = payload
876
+ if "arguments" in payload:
877
+ inner_args = payload.get("arguments")
878
+ if isinstance(inner_args, dict):
879
+ args = inner_args
880
+ elif isinstance(inner_args, str):
881
+ parsed = _loads_dict_like(inner_args)
882
+ if isinstance(parsed, dict):
883
+ args = parsed
884
+
885
+ call_id_value = payload.get("call_id") or payload.get("id")
886
+ if isinstance(call_id_value, str) and call_id_value.strip():
887
+ call_id = call_id_value.strip()
888
+
889
+ if not isinstance(args, dict):
890
+ continue
891
+
892
+ tool_calls.append(ToolCall(name=name, arguments=args, call_id=call_id))
893
+
894
+ return tool_calls
895
+
896
+
425
897
  def _parse_any_format(response: str) -> List[ToolCall]:
426
898
  """Try all parsing formats with comprehensive fallbacks."""
899
+ # SANITIZE FIRST: Fix malformed tags before trying any parser
900
+ response = _sanitize_tool_call_tags(response)
901
+
427
902
  tool_calls = []
428
903
 
429
904
  # Try each parser and accumulate results
@@ -432,6 +907,8 @@ def _parse_any_format(response: str) -> List[ToolCall]:
432
907
  _parse_function_call,
433
908
  _parse_xml_wrapped,
434
909
  _parse_tool_code,
910
+ _parse_harmony_tool_prefix,
911
+ _parse_bracket_tool_prefix,
435
912
  _parse_raw_json
436
913
  ]
437
914
 
@@ -450,7 +927,11 @@ def _parse_any_format(response: str) -> List[ToolCall]:
450
927
  unique_calls = []
451
928
  seen = set()
452
929
  for call in tool_calls:
453
- call_key = (call.name, str(call.arguments))
930
+ try:
931
+ args_key = json.dumps(call.arguments, sort_keys=True, ensure_ascii=False)
932
+ except Exception:
933
+ args_key = str(call.arguments)
934
+ call_key = (call.name, args_key)
454
935
  if call_key not in seen:
455
936
  seen.add(call_key)
456
937
  unique_calls.append(call)
@@ -500,181 +981,269 @@ def _parse_python_code_blocks(response: str) -> List[ToolCall]:
500
981
 
501
982
  # Formatting functions
502
983
 
503
- def _format_qwen_style(tools: List[ToolDefinition]) -> str:
984
+ def _format_parameters_compact(parameters: Dict[str, Any]) -> str:
985
+ """Render a compact, human/LLM-friendly parameter summary.
986
+
987
+ We intentionally avoid dumping full JSON schema here to keep the tool prompt small.
988
+ """
989
+ if not isinstance(parameters, dict) or not parameters:
990
+ return "(none)"
991
+
992
+ def _fmt_default(value: Any) -> str:
993
+ try:
994
+ return json.dumps(value, ensure_ascii=False)
995
+ except Exception:
996
+ return str(value)
997
+
998
+ parts: List[str] = []
999
+ for name in sorted([k for k in parameters.keys() if isinstance(k, str)]):
1000
+ meta = parameters.get(name)
1001
+ ptype = "any"
1002
+ required = True
1003
+ default_repr: Optional[str] = None
1004
+
1005
+ if isinstance(meta, dict):
1006
+ if isinstance(meta.get("type"), str) and meta.get("type"):
1007
+ ptype = str(meta.get("type"))
1008
+ required = "default" not in meta
1009
+ if not required:
1010
+ default_value = meta.get("default")
1011
+ # Avoid printing `default null` / `default None` in prompts; treat that as optional.
1012
+ if default_value is not None:
1013
+ default_repr = _fmt_default(default_value)
1014
+ else:
1015
+ required = True
1016
+
1017
+ if required:
1018
+ parts.append(f"{name}: {ptype} (required)")
1019
+ elif default_repr is not None:
1020
+ parts.append(f"{name}: {ptype} (default {default_repr})")
1021
+ else:
1022
+ parts.append(f"{name}: {ptype} (optional)")
1023
+
1024
+ return ", ".join(parts) if parts else "(none)"
1025
+
1026
+
1027
+ def _append_tool_examples(
1028
+ prompt: str,
1029
+ tools: List[ToolDefinition],
1030
+ *,
1031
+ tool_format: ToolFormat,
1032
+ max_examples_total: int = 6,
1033
+ ) -> str:
1034
+ """Append a small, globally-capped examples section.
1035
+
1036
+ Notes:
1037
+ - Examples are useful, but they are extremely token-expensive when included per-tool.
1038
+ - We cap examples globally and prioritize the "core editing loop" tools first.
1039
+ """
1040
+ if max_examples_total <= 0:
1041
+ return prompt
1042
+
1043
+ tools_with_examples = [t for t in tools if getattr(t, "examples", None)]
1044
+ if not tools_with_examples:
1045
+ return prompt
1046
+
1047
+ by_name = {t.name: t for t in tools_with_examples if isinstance(t.name, str) and t.name}
1048
+ preferred_order = [
1049
+ "list_files",
1050
+ "search_files",
1051
+ "read_file",
1052
+ "edit_file",
1053
+ "write_file",
1054
+ "execute_command",
1055
+ "fetch_url",
1056
+ "web_search",
1057
+ ]
1058
+
1059
+ ordered_names = []
1060
+ seen: set[str] = set()
1061
+ for name in preferred_order:
1062
+ if name in by_name and name not in seen:
1063
+ ordered_names.append(name)
1064
+ seen.add(name)
1065
+ for name in sorted(by_name.keys()):
1066
+ if name not in seen:
1067
+ ordered_names.append(name)
1068
+
1069
+ out = prompt + "**EXAMPLES:**\n\n"
1070
+ added = 0
1071
+ for name in ordered_names:
1072
+ tool = by_name.get(name)
1073
+ if tool is None:
1074
+ continue
1075
+ examples = getattr(tool, "examples", None)
1076
+ if not isinstance(examples, list) or not examples:
1077
+ continue
1078
+ example = examples[0] if isinstance(examples[0], dict) else {}
1079
+ desc = str(example.get("description") or "Example").strip()
1080
+ args = example.get("arguments")
1081
+ args_dict = dict(args) if isinstance(args, dict) else {}
1082
+
1083
+ out += f"- {tool.name}: {desc}\n"
1084
+ out += _format_tool_call_example(tool.name, args_dict, tool_format) + "\n\n"
1085
+ added += 1
1086
+ if added >= max_examples_total:
1087
+ break
1088
+
1089
+ return out
1090
+
1091
+
1092
+ def _format_qwen_style(tools: List[ToolDefinition], *, include_tool_list: bool = True, include_examples: bool = True) -> str:
504
1093
  """Format tools for Qwen models using <|tool_call|> format with enhanced metadata."""
505
1094
  if not tools:
506
1095
  return ""
507
1096
 
508
1097
  prompt = "You are a helpful AI assistant with access to the following tools:\n\n"
509
1098
 
510
- # Tool descriptions with enhanced metadata
511
- for tool in tools:
512
- prompt += f"**{tool.name}**: {tool.description}\n"
513
-
514
- # Add when_to_use guidance if available
515
- if tool.when_to_use:
516
- prompt += f" • **When to use**: {tool.when_to_use}\n"
517
-
518
- # Add tags if available
519
- if tool.tags:
520
- prompt += f" • **Tags**: {', '.join(tool.tags)}\n"
521
-
522
- if tool.parameters:
523
- prompt += f" • **Parameters**: {json.dumps(tool.parameters, indent=2)}\n"
524
- prompt += "\n"
1099
+ if include_tool_list:
1100
+ for tool in tools:
1101
+ prompt += f"**{tool.name}**: {tool.description}\n"
1102
+ if tool.parameters:
1103
+ prompt += f" • **Args**: {_format_parameters_compact(tool.parameters)}\n"
1104
+ prompt += "\n"
525
1105
 
526
- prompt += """To use a tool, respond with this EXACT format:
1106
+ prompt += """To use a tool, respond with one or more tool-call blocks (no other text):
527
1107
  <|tool_call|>
528
1108
  {"name": "tool_name", "arguments": {"param1": "value1", "param2": "value2"}}
529
1109
  </|tool_call|>
1110
+
1111
+ To call multiple tools, repeat the block once per call.
530
1112
  """ + _critical_rules()
531
1113
 
532
1114
 
533
- # Add examples from tool metadata
534
- if any(tool.examples for tool in tools):
535
- prompt += "**EXAMPLES:**\n\n"
536
- for tool in tools:
537
- if tool.examples:
538
- prompt += f"**{tool.name} Examples:**\n"
539
- for i, example in enumerate(tool.examples[:3], 1): # Limit to 3 examples
540
- desc = example.get("description", f"Example {i}")
541
- args = example.get("arguments", {})
542
- prompt += f"{i}. {desc}:\n"
543
- # Use Qwen3-specific tool call format
544
- tool_call_example = _format_tool_call_example(tool.name, args, ToolFormat.SPECIAL_TOKEN)
545
- prompt += f"{tool_call_example}\n\n"
1115
+ if include_examples:
1116
+ prompt = _append_tool_examples(prompt, tools, tool_format=ToolFormat.SPECIAL_TOKEN)
546
1117
 
547
1118
  return prompt
548
1119
 
549
1120
 
550
- def _format_llama_style(tools: List[ToolDefinition]) -> str:
1121
+ def _format_llama_style(tools: List[ToolDefinition], *, include_tool_list: bool = True, include_examples: bool = True) -> str:
551
1122
  """Format tools for LLaMA models using <function_call> format with enhanced metadata."""
552
1123
  if not tools:
553
1124
  return ""
554
1125
 
555
1126
  prompt = "You have access to the following functions. Use them when needed:\n\n"
556
1127
 
557
- # Tool descriptions with enhanced metadata
558
- for tool in tools:
559
- prompt += f"**{tool.name}**: {tool.description}\n"
560
-
561
- # Add when_to_use guidance if available
562
- if tool.when_to_use:
563
- prompt += f" • **When to use**: {tool.when_to_use}\n"
564
-
565
- # Add tags if available
566
- if tool.tags:
567
- prompt += f" • **Tags**: {', '.join(tool.tags)}\n"
568
-
569
- if tool.parameters:
570
- prompt += f" • **Parameters**: {json.dumps(tool.parameters, indent=2)}\n"
571
- prompt += "\n"
1128
+ if include_tool_list:
1129
+ for tool in tools:
1130
+ prompt += f"**{tool.name}**: {tool.description}\n"
1131
+ if tool.parameters:
1132
+ prompt += f" • **Args**: {_format_parameters_compact(tool.parameters)}\n"
1133
+ prompt += "\n"
572
1134
 
573
- prompt += """To call a function, use this format:
1135
+ prompt += """To call a function, output one or more <function_call> blocks (no other text):
574
1136
  <function_call>
575
1137
  {"name": "function_name", "arguments": {"param1": "value1", "param2": "value2"}}
576
1138
  </function_call>
1139
+
1140
+ To call multiple functions, repeat the block once per call.
577
1141
  """ + _critical_rules()
578
1142
 
579
- # Add examples from tool metadata
580
- if any(tool.examples for tool in tools):
581
- prompt += "**EXAMPLES:**\n\n"
582
- for tool in tools:
583
- if tool.examples:
584
- prompt += f"**{tool.name} Examples:**\n"
585
- for i, example in enumerate(tool.examples[:3], 1): # Limit to 3 examples
586
- desc = example.get("description", f"Example {i}")
587
- args = example.get("arguments", {})
588
- prompt += f"{i}. {desc}:\n"
589
- # Use architecture-specific tool call format
590
- tool_call_example = _format_tool_call_example(tool.name, args, ToolFormat.FUNCTION_CALL)
591
- prompt += f"{tool_call_example}\n\n"
1143
+ if include_examples:
1144
+ prompt = _append_tool_examples(prompt, tools, tool_format=ToolFormat.FUNCTION_CALL)
592
1145
 
593
1146
  return prompt
594
1147
 
595
1148
 
596
- def _format_xml_style(tools: List[ToolDefinition]) -> str:
1149
+ def _format_xml_style(tools: List[ToolDefinition], *, include_tool_list: bool = True, include_examples: bool = True) -> str:
597
1150
  """Format tools for XML-based models."""
598
1151
  if not tools:
599
1152
  return ""
600
1153
 
601
1154
  prompt = "You have access to these tools:\n\n"
602
1155
 
603
- for tool in tools:
604
- prompt += f'<tool name="{tool.name}">\n'
605
- prompt += f" <description>{tool.description}</description>\n"
606
- if tool.parameters:
607
- prompt += f" <parameters>{json.dumps(tool.parameters)}</parameters>\n"
608
- prompt += "</tool>\n\n"
1156
+ if include_tool_list:
1157
+ for tool in tools:
1158
+ prompt += f'<tool name="{tool.name}">\n'
1159
+ prompt += f" <description>{tool.description}</description>\n"
1160
+ if tool.parameters:
1161
+ prompt += f" <args>{_format_parameters_compact(tool.parameters)}</args>\n"
1162
+ prompt += "</tool>\n\n"
609
1163
 
610
- prompt += """To use a tool, format your call as:
1164
+ prompt += """To use a tool, output one or more <tool_call> blocks (no other text):
611
1165
  <tool_call>
612
1166
  {"name": "tool_name", "arguments": {"param1": "value1"}}
613
1167
  </tool_call>
1168
+
1169
+ To call multiple tools, repeat the block once per call.
614
1170
  """ + _critical_rules()
615
1171
 
1172
+ if include_examples:
1173
+ prompt = _append_tool_examples(prompt, tools, tool_format=ToolFormat.XML_WRAPPED)
1174
+
616
1175
  return prompt
617
1176
 
618
1177
 
619
- def _format_gemma_style(tools: List[ToolDefinition]) -> str:
1178
+ def _format_json_style(tools: List[ToolDefinition], *, include_tool_list: bool = True, include_examples: bool = True) -> str:
1179
+ """Format tools for models that prefer raw JSON tool calls in content."""
1180
+ if not tools:
1181
+ return ""
1182
+
1183
+ prompt = "You have access to the following tools:\n\n"
1184
+
1185
+ if include_tool_list:
1186
+ for tool in tools:
1187
+ prompt += f"- {tool.name}: {tool.description}\n"
1188
+ if tool.parameters:
1189
+ prompt += f" args: {_format_parameters_compact(tool.parameters)}\n"
1190
+
1191
+ prompt += """To use a tool, respond with one or more JSON objects (no extra text):
1192
+ {"name": "tool_name", "arguments": {"param1": "value1", "param2": "value2"}}
1193
+
1194
+ To call multiple tools, output multiple JSON objects (one per line/block).
1195
+ """ + _critical_rules()
1196
+
1197
+ if include_examples:
1198
+ prompt = _append_tool_examples(prompt, tools, tool_format=ToolFormat.RAW_JSON)
1199
+
1200
+ return prompt
1201
+
1202
+
1203
+ def _format_gemma_style(tools: List[ToolDefinition], *, include_tool_list: bool = True, include_examples: bool = True) -> str:
620
1204
  """Format tools for Gemma models using code blocks."""
621
1205
  if not tools:
622
1206
  return ""
623
1207
 
624
1208
  prompt = "You can use these tools by writing tool_code blocks:\n\n"
625
1209
 
626
- for tool in tools:
627
- prompt += f"**{tool.name}**: {tool.description}\n"
628
- if tool.parameters:
629
- param_list = ", ".join([f"{name}: {info.get('type', 'any')}" for name, info in tool.parameters.items()])
630
- prompt += f"Usage: {tool.name}({param_list})\n"
631
- prompt += "\n"
1210
+ if include_tool_list:
1211
+ for tool in tools:
1212
+ prompt += f"**{tool.name}**: {tool.description}\n"
1213
+ if tool.parameters:
1214
+ prompt += f"Args: {_format_parameters_compact(tool.parameters)}\n"
1215
+ prompt += "\n"
632
1216
 
633
- prompt += """To call a tool, use:
1217
+ prompt += """To call a tool, output one or more tool_code blocks (no other text):
634
1218
  ```tool_code
635
1219
  {"name": "tool_name", "arguments": {"param1": "value1", "param2": "value2"}}
636
- ```"""
1220
+ ```
1221
+
1222
+ To call multiple tools, repeat the block once per call."""
1223
+
1224
+ if include_examples:
1225
+ prompt = _append_tool_examples(prompt, tools, tool_format=ToolFormat.TOOL_CODE)
637
1226
 
638
1227
  return prompt
639
1228
 
640
1229
 
641
- def _format_generic_style(tools: List[ToolDefinition]) -> str:
1230
+ def _format_generic_style(tools: List[ToolDefinition], *, include_tool_list: bool = True, include_examples: bool = True) -> str:
642
1231
  """Generic tool formatting for unknown architectures with enhanced metadata."""
643
1232
  if not tools:
644
1233
  return ""
645
1234
 
646
1235
  prompt = "You have access to the following tools:\n\n"
647
1236
 
648
- for tool in tools:
649
- prompt += f"- **{tool.name}**: {tool.description}\n"
650
-
651
- # Add when_to_use guidance if available
652
- if tool.when_to_use:
653
- prompt += f" **When to use**: {tool.when_to_use}\n"
654
-
655
- # Add tags if available
656
- if tool.tags:
657
- prompt += f" **Tags**: {', '.join(tool.tags)}\n"
658
-
659
- if tool.parameters:
660
- prompt += f" **Parameters**: {json.dumps(tool.parameters, indent=2)}\n"
661
- prompt += "\n"
1237
+ if include_tool_list:
1238
+ for tool in tools:
1239
+ prompt += f"- {tool.name}: {tool.description}\n"
1240
+ if tool.parameters:
1241
+ prompt += f" args: {_format_parameters_compact(tool.parameters)}\n"
662
1242
 
663
1243
  prompt += _critical_rules()
664
1244
 
665
- # Add examples from tool metadata
666
- if any(tool.examples for tool in tools):
667
- prompt += "**EXAMPLES:**\n\n"
668
- for tool in tools:
669
- if tool.examples:
670
- prompt += f"**{tool.name} Examples:**\n"
671
- for i, example in enumerate(tool.examples[:3], 1): # Limit to 3 examples
672
- desc = example.get("description", f"Example {i}")
673
- args = example.get("arguments", {})
674
- prompt += f"{i}. {desc}:\n"
675
- # Use generic format for unknown architectures
676
- tool_call_example = _format_tool_call_example(tool.name, args, ToolFormat.RAW_JSON)
677
- prompt += f"{tool_call_example}\n\n"
1245
+ if include_examples:
1246
+ prompt = _append_tool_examples(prompt, tools, tool_format=ToolFormat.RAW_JSON)
678
1247
 
679
1248
  return prompt
680
1249
 
@@ -701,6 +1270,158 @@ def clean_tool_syntax(content: str, tool_calls: List[ToolCall] = None) -> str:
701
1270
 
702
1271
  import re
703
1272
 
1273
+ # Strip Harmony/ChatML tool-call segments first (balanced JSON after <|message|>).
1274
+ # Regex alone is brittle here because tool arguments can contain nested braces.
1275
+ if "<|channel|>" in content and "<|message|>" in content and "to=" in content:
1276
+ def _find_matching_brace(text: str, start: int) -> int:
1277
+ depth = 0
1278
+ in_string = False
1279
+ quote = ""
1280
+ escaped = False
1281
+ for i in range(start, len(text)):
1282
+ ch = text[i]
1283
+ if in_string:
1284
+ if escaped:
1285
+ escaped = False
1286
+ continue
1287
+ if ch == "\\":
1288
+ escaped = True
1289
+ continue
1290
+ if ch == quote:
1291
+ in_string = False
1292
+ quote = ""
1293
+ continue
1294
+ if ch in ("'", '"'):
1295
+ in_string = True
1296
+ quote = ch
1297
+ continue
1298
+ if ch == "{":
1299
+ depth += 1
1300
+ continue
1301
+ if ch == "}":
1302
+ depth -= 1
1303
+ if depth == 0:
1304
+ return i
1305
+ return -1
1306
+
1307
+ def _consume_trailing_kv_fragment(text: str, start_idx: int) -> int:
1308
+ """Consume malformed trailing JSON key/value fragments after a closed object.
1309
+
1310
+ Some models (notably some OSS models emitting Harmony tool transcripts) occasionally
1311
+ close the JSON object early and then continue emitting extra fields outside of it,
1312
+ e.g.:
1313
+ <|message|>{"name":"write_file","arguments":{...},"call_id":null},"mode":"w"}
1314
+
1315
+ Tool parsing can still succeed (the prefix is valid), but the tail fragment must
1316
+ not leak into cleaned assistant content (it otherwise shows up as "Thought" in UIs).
1317
+ """
1318
+ i = start_idx
1319
+ while i < len(text) and text[i].isspace():
1320
+ i += 1
1321
+ if i >= len(text) or text[i] != ",":
1322
+ return start_idx
1323
+
1324
+ # Quick heuristic: only treat as a JSON-ish continuation if we see `,"key":...`.
1325
+ j = i + 1
1326
+ while j < len(text) and text[j].isspace():
1327
+ j += 1
1328
+ if j >= len(text) or text[j] not in ("'", '"'):
1329
+ return start_idx
1330
+
1331
+ in_string = False
1332
+ quote = ""
1333
+ escaped = False
1334
+ brace_depth = 0
1335
+ saw_colon = False
1336
+ pos = i
1337
+ while pos < len(text):
1338
+ # Do not swallow the next Harmony segment (if any).
1339
+ if not in_string and text.startswith("<|channel|>", pos):
1340
+ return pos
1341
+
1342
+ ch = text[pos]
1343
+ if in_string:
1344
+ if escaped:
1345
+ escaped = False
1346
+ pos += 1
1347
+ continue
1348
+ if ch == "\\":
1349
+ escaped = True
1350
+ pos += 1
1351
+ continue
1352
+ if ch == quote:
1353
+ in_string = False
1354
+ quote = ""
1355
+ pos += 1
1356
+ continue
1357
+ pos += 1
1358
+ continue
1359
+
1360
+ if ch in ("'", '"'):
1361
+ in_string = True
1362
+ quote = ch
1363
+ pos += 1
1364
+ continue
1365
+
1366
+ if ch == ":":
1367
+ saw_colon = True
1368
+ elif ch == "{":
1369
+ brace_depth += 1
1370
+ elif ch == "}":
1371
+ if saw_colon and brace_depth == 0:
1372
+ return pos + 1
1373
+ if brace_depth > 0:
1374
+ brace_depth -= 1
1375
+ pos += 1
1376
+
1377
+ return len(text) if saw_colon else start_idx
1378
+
1379
+ msg_tag = "<|message|>"
1380
+ out_parts = []
1381
+ i = 0
1382
+ while i < len(content):
1383
+ start = content.find("<|channel|>", i)
1384
+ if start == -1:
1385
+ out_parts.append(content[i:])
1386
+ break
1387
+ out_parts.append(content[i:start])
1388
+
1389
+ msg_start = content.find(msg_tag, start)
1390
+ if msg_start == -1:
1391
+ out_parts.append(content[start:])
1392
+ break
1393
+ # Only treat as a tool call when there's a `to=` directive before the message tag.
1394
+ if "to=" not in content[start:msg_start]:
1395
+ out_parts.append(content[start:msg_start])
1396
+ i = msg_start
1397
+ continue
1398
+
1399
+ brace_start = content.find("{", msg_start + len(msg_tag))
1400
+ if brace_start == -1:
1401
+ out_parts.append(content[start:msg_start])
1402
+ i = msg_start
1403
+ continue
1404
+ between = content[msg_start + len(msg_tag) : brace_start]
1405
+ if between and any(not c.isspace() for c in between):
1406
+ out_parts.append(content[start:brace_start])
1407
+ i = brace_start
1408
+ continue
1409
+
1410
+ brace_end = _find_matching_brace(content, brace_start)
1411
+ if brace_end == -1:
1412
+ # Best-effort: drop the remainder of this segment up to the next Harmony marker
1413
+ # (or to end-of-content). Leaving partial tool payloads in `content` is more
1414
+ # harmful (it breaks agent scratchpads and UI "Thought" rendering).
1415
+ next_start = content.find("<|channel|>", brace_start + 1)
1416
+ if next_start == -1:
1417
+ break
1418
+ i = next_start
1419
+ continue
1420
+
1421
+ i = _consume_trailing_kv_fragment(content, brace_end + 1)
1422
+
1423
+ content = "".join(out_parts)
1424
+
704
1425
  # Use the same sophisticated patterns as the _parse_special_token function
705
1426
  patterns = [
706
1427
  # Strategy 1: Properly closed <|tool_call|> tags
@@ -717,6 +1438,19 @@ def clean_tool_syntax(content: str, tool_calls: List[ToolCall] = None) -> str:
717
1438
  r'<tool_call>.*?</tool_call>',
718
1439
  r'```tool_code.*?```',
719
1440
  r'```tool_call.*?```'
1441
+ ,
1442
+ # CLI-like prefix format: tool: [name]: {...}
1443
+ r'(?im)^\s*tool\s*:\s*\[[^\]]+\]\s*:\s*\{.*\}\s*$',
1444
+ # Harmony/ChatML tool-call transcript format:
1445
+ # <|channel|>commentary to=tool <|constrain|>json<|message|>{...}
1446
+ r'(?is)<\|channel\|>\s*[a-zA-Z0-9_\-]+\s+to=[a-zA-Z0-9_\-\.]+\b.*?<\|message\|>\s*\{.*?\}',
1447
+ # Orphan tags (some models emit a closing tag on its own line)
1448
+ r'(?im)^\s*<\|tool_call\|>\s*$',
1449
+ r'(?im)^\s*</\|tool_call\|>\s*$',
1450
+ r'(?im)^\s*<tool_call>\s*$',
1451
+ r'(?im)^\s*</tool_call>\s*$',
1452
+ r'(?im)^\s*<\|channel\|>\s*$',
1453
+ r'(?im)^\s*<\|message\|>\s*$',
720
1454
  ]
721
1455
 
722
1456
  # Apply all patterns
@@ -739,7 +1473,7 @@ def _format_tool_call_example(tool_name: str, arguments: Dict[str, Any], tool_fo
739
1473
  Returns:
740
1474
  Formatted tool call example string
741
1475
  """
742
- tool_call_json = json.dumps({"name": tool_name, "arguments": arguments})
1476
+ tool_call_json = json.dumps({"name": tool_name, "arguments": arguments}, separators=(",", ":"), ensure_ascii=False)
743
1477
 
744
1478
  if tool_format == ToolFormat.SPECIAL_TOKEN:
745
1479
  # Qwen3, GLM-4.5+ format
@@ -769,13 +1503,14 @@ def _critical_rules():
769
1503
  Returns:
770
1504
  str: The critical rules for tool usage.
771
1505
  """
772
- return """
773
-
774
- CRITICAL RULES FOR TOOL USAGE:
775
- 1. If you can answer the question directly, do not call a tool
776
- 2. If you can't answer the question directly, call a tool to extend your capabilities, gain further insights or perform an action
777
- 3. DO NOT call tools to show off capabilities - when requested, just describe the tools at your disposal
778
- 4. The "name" field must be at the TOP LEVEL, NOT inside "arguments"
779
- 5. Use the exact JSON structure shown above
780
-
781
- """
1506
+ return (
1507
+ "CRITICAL RULES FOR TOOL USAGE:\n"
1508
+ "1. If you can answer directly, do not call a tool.\n"
1509
+ "2. If you need info or an action, call the smallest relevant tool.\n"
1510
+ "3. Do not call tools to show off; if asked, describe capabilities.\n"
1511
+ "4. The \"name\" field must be top-level (not inside \"arguments\").\n"
1512
+ "5. Use the exact tool-call JSON structure.\n"
1513
+ "6. Never fabricate tool results; outputs are returned separately.\n"
1514
+ "7. Do not write your own `tool:` result lines.\n"
1515
+ "8. You MAY batch multiple tool calls by repeating the tool-call block once per call (prefer independent calls).\n"
1516
+ )