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.
@@ -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 (&amp;, &lt;, &gt;, &quot;) 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."""