agent-tool-parser 0.1.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.
- agent_tool_parser/__init__.py +70 -0
- agent_tool_parser/cleaners.py +283 -0
- agent_tool_parser/models.py +36 -0
- agent_tool_parser/parser.py +1219 -0
- agent_tool_parser/py.typed +1 -0
- agent_tool_parser-0.1.0.dist-info/METADATA +432 -0
- agent_tool_parser-0.1.0.dist-info/RECORD +9 -0
- agent_tool_parser-0.1.0.dist-info/WHEEL +4 -0
- agent_tool_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""agent-tool-parser: Fast, multi-format parser for LLM agent tool calls.
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 InBoost Team.
|
|
4
|
+
Licensed under the MIT License. Provided "AS IS" without warranty of any kind.
|
|
5
|
+
See LICENSE and README.md for full terms and execution safety disclaimers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
# Allow forcing pure-Python mode via environment variable for benchmarking or debugging
|
|
13
|
+
_DISABLE_ACCEL = os.getenv("AGENT_TOOL_PARSER_NO_EXT", "0") in ("1", "true", "True")
|
|
14
|
+
|
|
15
|
+
ACCELERATED: bool = False
|
|
16
|
+
|
|
17
|
+
if not _DISABLE_ACCEL:
|
|
18
|
+
try:
|
|
19
|
+
from agent_tool_parser._accelerated import ( # type: ignore[import-untyped,import-not-found]
|
|
20
|
+
ToolCall,
|
|
21
|
+
ToolError,
|
|
22
|
+
ToolParser,
|
|
23
|
+
clean_json_str,
|
|
24
|
+
parse_tool_call,
|
|
25
|
+
parse_tool_calls,
|
|
26
|
+
safe_json_loads,
|
|
27
|
+
strip_thinking,
|
|
28
|
+
try_parse_tool_call,
|
|
29
|
+
try_parse_tool_calls,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
ACCELERATED = True
|
|
33
|
+
except ImportError:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
if not ACCELERATED:
|
|
37
|
+
from agent_tool_parser.cleaners import (
|
|
38
|
+
clean_json_str,
|
|
39
|
+
clean_param_val,
|
|
40
|
+
extract_json_objects,
|
|
41
|
+
safe_json_loads,
|
|
42
|
+
strip_thinking,
|
|
43
|
+
)
|
|
44
|
+
from agent_tool_parser.models import ToolCall, ToolError
|
|
45
|
+
from agent_tool_parser.parser import (
|
|
46
|
+
ToolParser,
|
|
47
|
+
parse_tool_call,
|
|
48
|
+
parse_tool_calls,
|
|
49
|
+
try_parse_tool_call,
|
|
50
|
+
try_parse_tool_calls,
|
|
51
|
+
)
|
|
52
|
+
else:
|
|
53
|
+
from agent_tool_parser.cleaners import clean_param_val, extract_json_objects
|
|
54
|
+
|
|
55
|
+
__version__ = "0.1.0"
|
|
56
|
+
__all__ = [
|
|
57
|
+
"ACCELERATED",
|
|
58
|
+
"ToolCall",
|
|
59
|
+
"ToolError",
|
|
60
|
+
"ToolParser",
|
|
61
|
+
"clean_json_str",
|
|
62
|
+
"clean_param_val",
|
|
63
|
+
"extract_json_objects",
|
|
64
|
+
"parse_tool_call",
|
|
65
|
+
"parse_tool_calls",
|
|
66
|
+
"safe_json_loads",
|
|
67
|
+
"strip_thinking",
|
|
68
|
+
"try_parse_tool_call",
|
|
69
|
+
"try_parse_tool_calls",
|
|
70
|
+
]
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# Copyright (c) 2026 InBoost Team
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import html
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
_CODE_PARAM_KEYS_DEFAULT: set[str] = {
|
|
12
|
+
"old_str",
|
|
13
|
+
"new_str",
|
|
14
|
+
"content",
|
|
15
|
+
"old",
|
|
16
|
+
"new",
|
|
17
|
+
"code",
|
|
18
|
+
"text",
|
|
19
|
+
"replacement",
|
|
20
|
+
"patch",
|
|
21
|
+
"diff",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_THINK_OPEN_RE = re.compile(r"<[||]?(?:think|thought|reasoning)[||]?>", re.IGNORECASE)
|
|
25
|
+
_THINK_CLOSE_RE = re.compile(r"</[||]?(?:think|thought|reasoning)[||]?>", re.IGNORECASE)
|
|
26
|
+
|
|
27
|
+
_TOOL_START_CANDIDATES = (
|
|
28
|
+
"<tool_call",
|
|
29
|
+
"<tool_calls",
|
|
30
|
+
"<tools",
|
|
31
|
+
"<tool",
|
|
32
|
+
"<invoke",
|
|
33
|
+
"<dsml",
|
|
34
|
+
"<call",
|
|
35
|
+
"<tool_use",
|
|
36
|
+
"<ant_tool_use",
|
|
37
|
+
"<action_start",
|
|
38
|
+
"<action_name",
|
|
39
|
+
"<python_tag",
|
|
40
|
+
"```",
|
|
41
|
+
"action:",
|
|
42
|
+
"✿function✿",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_CDATA_RE = re.compile(r"<!\[CDATA\[(.*?)(?:\]\]>|$)", re.DOTALL)
|
|
46
|
+
_SINGLE_QUOTE_KEYS_RE = re.compile(r"([{,]\s*)'([^']+)'(\s*:)")
|
|
47
|
+
_SINGLE_QUOTE_VALS_RE = re.compile(r"(:\s*)'([^']*)'(\s*[,}])")
|
|
48
|
+
_TRAILING_COMMA_RE = re.compile(r",\s*([}\]])")
|
|
49
|
+
_PY_TRUE_RE = re.compile(r"\bTrue\b")
|
|
50
|
+
_PY_FALSE_RE = re.compile(r"\bFalse\b")
|
|
51
|
+
_PY_NONE_RE = re.compile(r"\bNone\b")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def strip_thinking(text: str) -> str:
|
|
55
|
+
"""Strips internal reasoning / thinking tags (<think>...</think>)."""
|
|
56
|
+
if "<" not in text:
|
|
57
|
+
return text
|
|
58
|
+
|
|
59
|
+
open_m = _THINK_OPEN_RE.search(text)
|
|
60
|
+
if open_m:
|
|
61
|
+
after_open = text[open_m.end() :]
|
|
62
|
+
# 1. Look for explicit closing tag
|
|
63
|
+
close_m = _THINK_CLOSE_RE.search(after_open)
|
|
64
|
+
if close_m:
|
|
65
|
+
before = text[: open_m.start()]
|
|
66
|
+
after = after_open[close_m.end() :]
|
|
67
|
+
result = before + after
|
|
68
|
+
if _THINK_OPEN_RE.search(result):
|
|
69
|
+
return strip_thinking(result)
|
|
70
|
+
return result.strip()
|
|
71
|
+
|
|
72
|
+
# 2. No closing tag: find first tool candidate
|
|
73
|
+
lower = after_open.lower()
|
|
74
|
+
min_pos = None
|
|
75
|
+
for cand in _TOOL_START_CANDIDATES:
|
|
76
|
+
pos = lower.find(cand)
|
|
77
|
+
if pos != -1:
|
|
78
|
+
if min_pos is None or pos < min_pos:
|
|
79
|
+
min_pos = pos
|
|
80
|
+
if min_pos is not None:
|
|
81
|
+
before = text[: open_m.start()]
|
|
82
|
+
after = after_open[min_pos:]
|
|
83
|
+
return (before + after).strip()
|
|
84
|
+
|
|
85
|
+
before = text[: open_m.start()].strip()
|
|
86
|
+
if before:
|
|
87
|
+
return before
|
|
88
|
+
|
|
89
|
+
return text
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def clean_param_val(val: str, is_code_param: bool = False) -> str:
|
|
93
|
+
"""Cleans an extracted XML/DSML parameter value.
|
|
94
|
+
|
|
95
|
+
1. Unwraps <![CDATA[...]]> blocks if present (preserving raw code).
|
|
96
|
+
2. Unescapes HTML/XML entities (&, <, >, ") only if not in CDATA.
|
|
97
|
+
3. Trims whitespace appropriately: preserves indentation for code/diffs,
|
|
98
|
+
stripping only leading/trailing bounding newlines.
|
|
99
|
+
"""
|
|
100
|
+
has_cdata = False
|
|
101
|
+
if "<![CDATA[" in val:
|
|
102
|
+
m_cdata = _CDATA_RE.search(val)
|
|
103
|
+
if m_cdata:
|
|
104
|
+
val = m_cdata.group(1)
|
|
105
|
+
has_cdata = True
|
|
106
|
+
|
|
107
|
+
if not has_cdata and "&" in val:
|
|
108
|
+
val = html.unescape(val)
|
|
109
|
+
|
|
110
|
+
if is_code_param:
|
|
111
|
+
val = val.removeprefix("\r\n").removeprefix("\n").removesuffix("\r\n").removesuffix("\n")
|
|
112
|
+
else:
|
|
113
|
+
val = val.strip()
|
|
114
|
+
|
|
115
|
+
return val
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def clean_json_str(s: str) -> str:
|
|
119
|
+
"""Applies heuristic repairs to malformed JSON strings.
|
|
120
|
+
|
|
121
|
+
- Replaces single-quoted keys and string values with double quotes.
|
|
122
|
+
- Strips trailing commas before closing braces/brackets.
|
|
123
|
+
- Normalizes Python literals (True, False, None) to JSON (true, false, null).
|
|
124
|
+
"""
|
|
125
|
+
if "'" in s:
|
|
126
|
+
if '"' not in s:
|
|
127
|
+
s = s.replace("'", '"')
|
|
128
|
+
else:
|
|
129
|
+
# Single-quoted keys: {'key': ...} -> {"key": ...}
|
|
130
|
+
s = _SINGLE_QUOTE_KEYS_RE.sub(r'\g<1>"\g<2>"\g<3>', s)
|
|
131
|
+
|
|
132
|
+
# Single-quoted values: : 'val' -> : "val" (escaping any inner double quotes)
|
|
133
|
+
def _replace_sq_val(m: re.Match[str]) -> str:
|
|
134
|
+
prefix, val, suffix = m.group(1), m.group(2), m.group(3)
|
|
135
|
+
val_escaped = val.replace('\\"', '"').replace('"', '\\"')
|
|
136
|
+
return f'{prefix}"{val_escaped}"{suffix}'
|
|
137
|
+
|
|
138
|
+
s = _SINGLE_QUOTE_VALS_RE.sub(_replace_sq_val, s)
|
|
139
|
+
|
|
140
|
+
# Fix trailing commas: {"a": 1,} -> {"a": 1}
|
|
141
|
+
if "," in s:
|
|
142
|
+
s = _TRAILING_COMMA_RE.sub(r"\g<1>", s)
|
|
143
|
+
|
|
144
|
+
# Replace Python literals
|
|
145
|
+
if "True" in s:
|
|
146
|
+
s = _PY_TRUE_RE.sub("true", s)
|
|
147
|
+
if "False" in s:
|
|
148
|
+
s = _PY_FALSE_RE.sub("false", s)
|
|
149
|
+
if "None" in s:
|
|
150
|
+
s = _PY_NONE_RE.sub("null", s)
|
|
151
|
+
return s
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
import orjson # type: ignore[import-not-found,import-untyped]
|
|
156
|
+
|
|
157
|
+
def _fast_json_loads(s: str) -> Any:
|
|
158
|
+
return orjson.loads(s)
|
|
159
|
+
except ImportError:
|
|
160
|
+
import json
|
|
161
|
+
|
|
162
|
+
def _fast_json_loads(s: str) -> Any:
|
|
163
|
+
return json.loads(s, strict=False)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
import json_repair # type: ignore[import-not-found,import-untyped]
|
|
168
|
+
|
|
169
|
+
def _repair_json_ext(s: str) -> Any:
|
|
170
|
+
return json_repair.repair_json(s, return_objects=True)
|
|
171
|
+
except ImportError:
|
|
172
|
+
_repair_json_ext = None # type: ignore[assignment]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def safe_json_loads(s: str, default: Any = None) -> Any:
|
|
176
|
+
"""Safely parses JSON with fallback to heuristic repair, returning default on failure.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
s: Raw JSON string or malformed candidate.
|
|
180
|
+
default: Fallback value if parsing fails completely.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Parsed JSON object or default value.
|
|
184
|
+
"""
|
|
185
|
+
if not s or not isinstance(s, str):
|
|
186
|
+
return default
|
|
187
|
+
try:
|
|
188
|
+
return _fast_json_loads(s)
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
return _fast_json_loads(clean_json_str(s))
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
if _repair_json_ext is not None:
|
|
198
|
+
try:
|
|
199
|
+
res = _repair_json_ext(s)
|
|
200
|
+
if isinstance(res, (dict, list)):
|
|
201
|
+
return res
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
return default
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def extract_json_objects(text: str) -> list[str]:
|
|
209
|
+
"""Extracts all balanced JSON object candidates from arbitrary text using bracket-depth tracking.
|
|
210
|
+
|
|
211
|
+
Handles double and single quoted strings, escape characters, and auto-completes
|
|
212
|
+
truncated objects (including unclosed arrays and nested objects) if the LLM stream
|
|
213
|
+
was cut off mid-generation.
|
|
214
|
+
"""
|
|
215
|
+
if "{" not in text:
|
|
216
|
+
return []
|
|
217
|
+
|
|
218
|
+
results: list[str] = []
|
|
219
|
+
stack: list[str] = []
|
|
220
|
+
start = -1
|
|
221
|
+
in_string = False
|
|
222
|
+
quote_char: str | None = None
|
|
223
|
+
escape = False
|
|
224
|
+
|
|
225
|
+
for i, char in enumerate(text):
|
|
226
|
+
if char in ('"', "'") and not escape:
|
|
227
|
+
if not in_string:
|
|
228
|
+
in_string = True
|
|
229
|
+
quote_char = char
|
|
230
|
+
elif quote_char == char:
|
|
231
|
+
in_string = False
|
|
232
|
+
quote_char = None
|
|
233
|
+
elif char == "\\" and in_string:
|
|
234
|
+
escape = not escape
|
|
235
|
+
continue
|
|
236
|
+
elif not in_string:
|
|
237
|
+
if char == "{":
|
|
238
|
+
if not stack:
|
|
239
|
+
start = i
|
|
240
|
+
stack.append("{")
|
|
241
|
+
elif char == "[":
|
|
242
|
+
if stack:
|
|
243
|
+
stack.append("[")
|
|
244
|
+
elif char == "}":
|
|
245
|
+
if stack and stack[-1] == "{":
|
|
246
|
+
stack.pop()
|
|
247
|
+
if not stack and start != -1:
|
|
248
|
+
results.append(text[start : i + 1])
|
|
249
|
+
start = -1
|
|
250
|
+
elif stack and "{" in stack:
|
|
251
|
+
while stack and stack[-1] != "{":
|
|
252
|
+
stack.pop()
|
|
253
|
+
if stack:
|
|
254
|
+
stack.pop()
|
|
255
|
+
if not stack and start != -1:
|
|
256
|
+
results.append(text[start : i + 1])
|
|
257
|
+
start = -1
|
|
258
|
+
elif char == "]":
|
|
259
|
+
if stack and stack[-1] == "[":
|
|
260
|
+
stack.pop()
|
|
261
|
+
if escape:
|
|
262
|
+
escape = False
|
|
263
|
+
|
|
264
|
+
# Auto-repair truncated tail if JSON generation was cut off mid-stream
|
|
265
|
+
if stack and start != -1:
|
|
266
|
+
tail = text[start:]
|
|
267
|
+
if in_string and quote_char:
|
|
268
|
+
if escape or tail.endswith("\\"):
|
|
269
|
+
tail = tail.rstrip("\\")
|
|
270
|
+
tail += quote_char
|
|
271
|
+
trimmed_tail = tail.rstrip()
|
|
272
|
+
if trimmed_tail.endswith(":"):
|
|
273
|
+
tail = trimmed_tail + '""'
|
|
274
|
+
elif trimmed_tail.endswith(","):
|
|
275
|
+
tail = trimmed_tail[:-1]
|
|
276
|
+
for b in reversed(stack):
|
|
277
|
+
if b == "[":
|
|
278
|
+
tail += "]"
|
|
279
|
+
elif b == "{":
|
|
280
|
+
tail += "}"
|
|
281
|
+
results.append(tail)
|
|
282
|
+
|
|
283
|
+
return results
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Copyright (c) 2026 InBoost Team
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ToolCall:
|
|
12
|
+
"""Represents a structured tool invocation extracted from LLM output.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
name: Normalized name of the tool to execute.
|
|
16
|
+
args: Dictionary of parsed arguments/parameters for the tool.
|
|
17
|
+
raw_source: Snippet of the original response that matched.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
raw_source: str = ""
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict[str, Any]:
|
|
25
|
+
return {
|
|
26
|
+
"name": self.name,
|
|
27
|
+
"args": self.args,
|
|
28
|
+
"raw_source": self.raw_source,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def __repr__(self) -> str:
|
|
32
|
+
return f"ToolCall(name={self.name!r}, args={self.args!r})"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolError(ValueError):
|
|
36
|
+
"""Raised when no valid tool call could be extracted or validation failed."""
|