autobyteus 1.1.1__py3-none-any.whl → 1.1.3__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.
- autobyteus/agent/bootstrap_steps/__init__.py +2 -0
- autobyteus/agent/bootstrap_steps/agent_bootstrapper.py +2 -0
- autobyteus/agent/bootstrap_steps/mcp_server_prewarming_step.py +71 -0
- autobyteus/agent/llm_response_processor/provider_aware_tool_usage_processor.py +41 -12
- autobyteus/agent/runtime/agent_runtime.py +1 -4
- autobyteus/agent/runtime/agent_worker.py +56 -23
- autobyteus/agent/shutdown_steps/__init__.py +17 -0
- autobyteus/agent/shutdown_steps/agent_shutdown_orchestrator.py +63 -0
- autobyteus/agent/shutdown_steps/base_shutdown_step.py +33 -0
- autobyteus/agent/shutdown_steps/llm_instance_cleanup_step.py +45 -0
- autobyteus/agent/shutdown_steps/mcp_server_cleanup_step.py +32 -0
- autobyteus/llm/api/deepseek_llm.py +10 -172
- autobyteus/llm/api/grok_llm.py +10 -171
- autobyteus/llm/api/kimi_llm.py +24 -0
- autobyteus/llm/api/openai_compatible_llm.py +193 -0
- autobyteus/llm/api/openai_llm.py +11 -139
- autobyteus/llm/llm_factory.py +62 -0
- autobyteus/llm/providers.py +1 -0
- autobyteus/llm/token_counter/kimi_token_counter.py +24 -0
- autobyteus/llm/token_counter/token_counter_factory.py +3 -0
- autobyteus/llm/utils/messages.py +3 -3
- autobyteus/tools/base_tool.py +2 -0
- autobyteus/tools/mcp/__init__.py +10 -7
- autobyteus/tools/mcp/call_handlers/__init__.py +0 -2
- autobyteus/tools/mcp/config_service.py +1 -6
- autobyteus/tools/mcp/factory.py +12 -26
- autobyteus/tools/mcp/registrar.py +57 -178
- autobyteus/tools/mcp/server/__init__.py +16 -0
- autobyteus/tools/mcp/server/base_managed_mcp_server.py +139 -0
- autobyteus/tools/mcp/server/http_managed_mcp_server.py +29 -0
- autobyteus/tools/mcp/server/proxy.py +36 -0
- autobyteus/tools/mcp/server/stdio_managed_mcp_server.py +33 -0
- autobyteus/tools/mcp/server_instance_manager.py +93 -0
- autobyteus/tools/mcp/tool.py +28 -46
- autobyteus/tools/mcp/tool_registrar.py +177 -0
- autobyteus/tools/mcp/types.py +10 -21
- autobyteus/tools/registry/tool_definition.py +11 -2
- autobyteus/tools/registry/tool_registry.py +27 -28
- autobyteus/tools/usage/parsers/_json_extractor.py +99 -0
- autobyteus/tools/usage/parsers/default_json_tool_usage_parser.py +46 -77
- autobyteus/tools/usage/parsers/default_xml_tool_usage_parser.py +87 -97
- autobyteus/tools/usage/parsers/gemini_json_tool_usage_parser.py +38 -46
- autobyteus/tools/usage/parsers/openai_json_tool_usage_parser.py +104 -154
- {autobyteus-1.1.1.dist-info → autobyteus-1.1.3.dist-info}/METADATA +4 -2
- {autobyteus-1.1.1.dist-info → autobyteus-1.1.3.dist-info}/RECORD +48 -32
- autobyteus/tools/mcp/call_handlers/sse_handler.py +0 -22
- {autobyteus-1.1.1.dist-info → autobyteus-1.1.3.dist-info}/WHEEL +0 -0
- {autobyteus-1.1.1.dist-info → autobyteus-1.1.3.dist-info}/licenses/LICENSE +0 -0
- {autobyteus-1.1.1.dist-info → autobyteus-1.1.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import logging
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
def _find_json_blobs(text: str) -> List[str]:
|
|
8
|
+
"""
|
|
9
|
+
Robustly finds and extracts all top-level JSON objects or arrays from a string,
|
|
10
|
+
maintaining their original order of appearance. It handles JSON within
|
|
11
|
+
markdown-style code blocks (```json ... ```) and inline JSON.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
text: The string to search for JSON in.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
A list of strings, where each string is a valid-looking JSON blob,
|
|
18
|
+
ordered as they appeared in the input text.
|
|
19
|
+
"""
|
|
20
|
+
found_blobs = []
|
|
21
|
+
|
|
22
|
+
# 1. Find all markdown blobs first and store them with their start positions.
|
|
23
|
+
markdown_matches = list(re.finditer(r"```(?:json)?\s*([\s\S]+?)\s*```", text))
|
|
24
|
+
for match in markdown_matches:
|
|
25
|
+
content = match.group(1).strip()
|
|
26
|
+
found_blobs.append((match.start(), content))
|
|
27
|
+
|
|
28
|
+
# 2. Create a "masked" version of the text by replacing markdown blocks with spaces.
|
|
29
|
+
# This prevents the inline scanner from finding JSON inside them, while preserving indices.
|
|
30
|
+
masked_text_list = list(text)
|
|
31
|
+
for match in markdown_matches:
|
|
32
|
+
for i in range(match.start(), match.end()):
|
|
33
|
+
masked_text_list[i] = ' '
|
|
34
|
+
masked_text = "".join(masked_text_list)
|
|
35
|
+
|
|
36
|
+
# 3. Scan the masked text for any other JSON using a single pass brace-counter.
|
|
37
|
+
idx = 0
|
|
38
|
+
while idx < len(masked_text):
|
|
39
|
+
start_idx = -1
|
|
40
|
+
|
|
41
|
+
# Find the next opening brace or bracket
|
|
42
|
+
next_brace = masked_text.find('{', idx)
|
|
43
|
+
next_bracket = masked_text.find('[', idx)
|
|
44
|
+
|
|
45
|
+
if next_brace == -1 and next_bracket == -1:
|
|
46
|
+
break # No more JSON starts
|
|
47
|
+
|
|
48
|
+
if next_brace != -1 and (next_bracket == -1 or next_brace < next_bracket):
|
|
49
|
+
start_idx = next_brace
|
|
50
|
+
start_char, end_char = '{', '}'
|
|
51
|
+
else:
|
|
52
|
+
start_idx = next_bracket
|
|
53
|
+
start_char, end_char = '[', ']'
|
|
54
|
+
|
|
55
|
+
brace_count = 1
|
|
56
|
+
in_string = False
|
|
57
|
+
is_escaped = False
|
|
58
|
+
end_idx = -1
|
|
59
|
+
|
|
60
|
+
for i in range(start_idx + 1, len(masked_text)):
|
|
61
|
+
char = masked_text[i]
|
|
62
|
+
|
|
63
|
+
if in_string:
|
|
64
|
+
if is_escaped:
|
|
65
|
+
is_escaped = False
|
|
66
|
+
elif char == '\\':
|
|
67
|
+
is_escaped = True
|
|
68
|
+
elif char == '"':
|
|
69
|
+
in_string = False
|
|
70
|
+
else:
|
|
71
|
+
if char == '"':
|
|
72
|
+
in_string = True
|
|
73
|
+
is_escaped = False
|
|
74
|
+
elif char == '{' or char == '[':
|
|
75
|
+
brace_count += 1
|
|
76
|
+
elif char == '}' or char == ']':
|
|
77
|
+
brace_count -= 1
|
|
78
|
+
|
|
79
|
+
if brace_count == 0:
|
|
80
|
+
if (start_char == '{' and char == '}') or \
|
|
81
|
+
(start_char == '[' and char == ']'):
|
|
82
|
+
end_idx = i
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
if end_idx != -1:
|
|
86
|
+
# We found a blob in the masked text, so its indices are correct
|
|
87
|
+
# for the original text. Extract the blob from the original text.
|
|
88
|
+
blob = text[start_idx : end_idx + 1]
|
|
89
|
+
found_blobs.append((start_idx, blob))
|
|
90
|
+
idx = end_idx + 1
|
|
91
|
+
else:
|
|
92
|
+
# No matching end brace found, move on from the start character.
|
|
93
|
+
idx = start_idx + 1
|
|
94
|
+
|
|
95
|
+
# 4. Sort all found blobs by their start position to ensure correct order
|
|
96
|
+
found_blobs.sort(key=lambda item: item[0])
|
|
97
|
+
|
|
98
|
+
# 5. Return only the content of the blobs
|
|
99
|
+
return [content for _, content in found_blobs]
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# file: autobyteus/autobyteus/tools/usage/parsers/default_json_tool_usage_parser.py
|
|
2
2
|
import json
|
|
3
|
-
import re
|
|
4
3
|
import logging
|
|
5
|
-
from typing import
|
|
6
|
-
import uuid
|
|
4
|
+
from typing import Dict, Any, TYPE_CHECKING, List
|
|
7
5
|
|
|
8
6
|
from autobyteus.agent.tool_invocation import ToolInvocation
|
|
9
7
|
from .base_parser import BaseToolUsageParser
|
|
8
|
+
from .exceptions import ToolUsageParseException
|
|
9
|
+
from ._json_extractor import _find_json_blobs
|
|
10
10
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
12
|
from autobyteus.llm.utils.response_types import CompleteResponse
|
|
@@ -16,91 +16,60 @@ logger = logging.getLogger(__name__)
|
|
|
16
16
|
class DefaultJsonToolUsageParser(BaseToolUsageParser):
|
|
17
17
|
"""
|
|
18
18
|
A default parser for tool usage commands formatted as custom JSON.
|
|
19
|
-
It
|
|
19
|
+
It robustly extracts potential JSON blobs and expects a 'tool' object
|
|
20
|
+
with 'function' and 'parameters' keys.
|
|
20
21
|
"""
|
|
21
22
|
def get_name(self) -> str:
|
|
22
23
|
return "default_json_tool_usage_parser"
|
|
23
24
|
|
|
24
25
|
def parse(self, response: 'CompleteResponse') -> List[ToolInvocation]:
|
|
25
|
-
response_text =
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
try:
|
|
30
|
-
data = json.loads(response_text)
|
|
31
|
-
except json.JSONDecodeError:
|
|
32
|
-
logger.debug(f"Could not parse extracted text as JSON. Text: {response_text[:200]}")
|
|
33
|
-
return []
|
|
34
|
-
|
|
35
|
-
tool_calls_data = []
|
|
36
|
-
if isinstance(data, list):
|
|
37
|
-
tool_calls_data = data
|
|
38
|
-
elif isinstance(data, dict):
|
|
39
|
-
if "tools" in data and isinstance(data.get("tools"), list):
|
|
40
|
-
tool_calls_data = data["tools"]
|
|
41
|
-
else:
|
|
42
|
-
tool_calls_data = [data]
|
|
43
|
-
else:
|
|
26
|
+
response_text = response.content
|
|
27
|
+
json_blobs = _find_json_blobs(response_text)
|
|
28
|
+
if not json_blobs:
|
|
44
29
|
return []
|
|
45
30
|
|
|
46
31
|
invocations: List[ToolInvocation] = []
|
|
47
|
-
for
|
|
48
|
-
|
|
49
|
-
|
|
32
|
+
for blob in json_blobs:
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(blob)
|
|
35
|
+
|
|
36
|
+
# This parser specifically looks for the {"tool": {...}} structure.
|
|
37
|
+
if isinstance(data, dict) and "tool" in data:
|
|
38
|
+
tool_block = data.get("tool")
|
|
39
|
+
if not isinstance(tool_block, dict):
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
tool_name = tool_block.get("function")
|
|
43
|
+
arguments = tool_block.get("parameters")
|
|
50
44
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
45
|
+
if not tool_name or not isinstance(tool_name, str):
|
|
46
|
+
logger.debug(f"Skipping malformed tool block (missing or invalid 'function'): {tool_block}")
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
if arguments is None:
|
|
50
|
+
arguments = {}
|
|
51
|
+
|
|
52
|
+
if not isinstance(arguments, dict):
|
|
53
|
+
logger.debug(f"Skipping tool block with invalid 'parameters' type ({type(arguments)}): {tool_block}")
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
# Pass id=None to trigger deterministic ID generation.
|
|
58
|
+
tool_invocation = ToolInvocation(name=tool_name, arguments=arguments, id=None)
|
|
59
|
+
invocations.append(tool_invocation)
|
|
60
|
+
logger.info(f"Successfully parsed default JSON tool invocation for '{tool_name}'.")
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.error(f"Unexpected error creating ToolInvocation for tool '{tool_name}': {e}", exc_info=True)
|
|
57
63
|
|
|
58
|
-
|
|
59
|
-
logger.debug(f"
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
logger.debug(f"Could not parse extracted text as JSON in {self.get_name()}. Blob: {blob[:200]}")
|
|
66
|
+
# This is likely not a tool call, so we can ignore it.
|
|
60
67
|
continue
|
|
61
|
-
|
|
62
|
-
if arguments is None:
|
|
63
|
-
arguments = {}
|
|
64
|
-
|
|
65
|
-
if not isinstance(arguments, dict):
|
|
66
|
-
logger.debug(f"Skipping tool block with invalid 'parameters' type ({type(arguments)}): {tool_block}")
|
|
67
|
-
continue
|
|
68
|
-
|
|
69
|
-
# The custom format does not have a tool ID, so a deterministic one will be generated.
|
|
70
|
-
try:
|
|
71
|
-
# Pass id=None to trigger deterministic ID generation.
|
|
72
|
-
tool_invocation = ToolInvocation(name=tool_name, arguments=arguments, id=None)
|
|
73
|
-
invocations.append(tool_invocation)
|
|
74
68
|
except Exception as e:
|
|
75
|
-
|
|
69
|
+
# If we're here, it's likely a valid JSON but with unexpected structure.
|
|
70
|
+
# It's safer to raise this for upstream handling.
|
|
71
|
+
error_msg = f"Unexpected error while parsing JSON blob in {self.get_name()}: {e}. Blob: {blob[:200]}"
|
|
72
|
+
logger.error(error_msg, exc_info=True)
|
|
73
|
+
raise ToolUsageParseException(error_msg, original_exception=e)
|
|
76
74
|
|
|
77
75
|
return invocations
|
|
78
|
-
|
|
79
|
-
def _extract_json_from_response(self, text: str) -> Optional[str]:
|
|
80
|
-
match = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", text)
|
|
81
|
-
if match:
|
|
82
|
-
return match.group(1).strip()
|
|
83
|
-
|
|
84
|
-
# Try to find a JSON object or array in the text
|
|
85
|
-
first_bracket = text.find('[')
|
|
86
|
-
first_brace = text.find('{')
|
|
87
|
-
|
|
88
|
-
if first_brace == -1 and first_bracket == -1:
|
|
89
|
-
return None
|
|
90
|
-
|
|
91
|
-
start_index = -1
|
|
92
|
-
if first_bracket != -1 and first_brace != -1:
|
|
93
|
-
start_index = min(first_bracket, first_brace)
|
|
94
|
-
elif first_bracket != -1:
|
|
95
|
-
start_index = first_bracket
|
|
96
|
-
else: # first_brace != -1
|
|
97
|
-
start_index = first_brace
|
|
98
|
-
|
|
99
|
-
json_substring = text[start_index:]
|
|
100
|
-
try:
|
|
101
|
-
# Check if the substring is valid JSON
|
|
102
|
-
json.loads(json_substring)
|
|
103
|
-
return json_substring
|
|
104
|
-
except json.JSONDecodeError:
|
|
105
|
-
logger.debug(f"Found potential start of JSON, but substring was not valid: {json_substring[:100]}")
|
|
106
|
-
return None
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# file: autobyteus/autobyteus/tools/usage/parsers/default_xml_tool_usage_parser.py
|
|
2
1
|
import xml.etree.ElementTree as ET
|
|
3
2
|
import re
|
|
4
3
|
import uuid
|
|
5
|
-
|
|
4
|
+
import html
|
|
5
|
+
from xml.sax.saxutils import escape
|
|
6
6
|
import xml.parsers.expat
|
|
7
7
|
import logging
|
|
8
8
|
from typing import TYPE_CHECKING, Dict, Any, List
|
|
@@ -18,119 +18,109 @@ logger = logging.getLogger(__name__)
|
|
|
18
18
|
|
|
19
19
|
class DefaultXmlToolUsageParser(BaseToolUsageParser):
|
|
20
20
|
"""
|
|
21
|
-
Parses LLM responses for tool usage commands formatted as XML
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
Parses LLM responses for tool usage commands formatted as XML using a robust,
|
|
22
|
+
stateful, character-by-character scanning approach. This parser can correctly
|
|
23
|
+
identify and extract valid <tool>...</tool> blocks even when they are mixed with
|
|
24
|
+
conversational text, malformed XML, or other noise.
|
|
24
25
|
"""
|
|
25
26
|
def get_name(self) -> str:
|
|
26
27
|
return "default_xml_tool_usage_parser"
|
|
27
28
|
|
|
28
29
|
def parse(self, response: 'CompleteResponse') -> List[ToolInvocation]:
|
|
29
|
-
|
|
30
|
-
logger.debug(f"{self.get_name()} attempting to parse response (first 500 chars): {response_text[:500]}...")
|
|
31
|
-
|
|
30
|
+
text = response.content
|
|
32
31
|
invocations: List[ToolInvocation] = []
|
|
33
|
-
|
|
34
|
-
if not match:
|
|
35
|
-
logger.debug(f"No <tools> or <tool> block found by {self.get_name()}.")
|
|
36
|
-
return invocations
|
|
37
|
-
|
|
38
|
-
xml_content = match.group(0)
|
|
39
|
-
processed_xml = self._preprocess_xml_for_parsing(xml_content)
|
|
40
|
-
|
|
41
|
-
try:
|
|
42
|
-
root = ET.fromstring(processed_xml)
|
|
43
|
-
tool_elements = []
|
|
44
|
-
|
|
45
|
-
if root.tag.lower() == "tools":
|
|
46
|
-
tool_elements = root.findall('tool')
|
|
47
|
-
if not tool_elements:
|
|
48
|
-
logger.debug("Found <tools> but no <tool> children.")
|
|
49
|
-
return invocations
|
|
50
|
-
elif root.tag.lower() == "tool":
|
|
51
|
-
tool_elements = [root]
|
|
52
|
-
else:
|
|
53
|
-
logger.warning(f"Root XML tag is '{root.tag}', not 'tools' or 'tool'. Skipping parsing.")
|
|
54
|
-
return invocations
|
|
55
|
-
|
|
56
|
-
for tool_elem in tool_elements:
|
|
57
|
-
tool_name = tool_elem.attrib.get("name")
|
|
58
|
-
# If 'id' is not present in XML, it will be None, triggering deterministic generation.
|
|
59
|
-
tool_id = tool_elem.attrib.get("id")
|
|
60
|
-
arguments = self._parse_arguments_from_xml(tool_elem)
|
|
61
|
-
|
|
62
|
-
if tool_name:
|
|
63
|
-
tool_invocation = ToolInvocation(name=tool_name, arguments=arguments, id=tool_id)
|
|
64
|
-
invocations.append(tool_invocation)
|
|
65
|
-
else:
|
|
66
|
-
logger.warning(f"Parsed a <tool> element but its 'name' attribute is missing or empty.")
|
|
67
|
-
|
|
68
|
-
except (ET.ParseError, xml.parsers.expat.ExpatError) as e:
|
|
69
|
-
error_msg = f"XML parsing error in '{self.get_name()}': {e}. Content: '{processed_xml[:200]}'"
|
|
70
|
-
logger.debug(error_msg)
|
|
71
|
-
# Raise a specific exception to be caught upstream.
|
|
72
|
-
raise ToolUsageParseException(error_msg, original_exception=e)
|
|
32
|
+
cursor = 0
|
|
73
33
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
34
|
+
while cursor < len(text):
|
|
35
|
+
# Find the start of the next potential tool tag from the current cursor position
|
|
36
|
+
tool_start_index = text.find('<tool', cursor)
|
|
37
|
+
if tool_start_index == -1:
|
|
38
|
+
break # No more tool tags in the rest of the string
|
|
39
|
+
|
|
40
|
+
# Find the end of that opening <tool ...> tag. This is a potential end.
|
|
41
|
+
tool_start_tag_end = text.find('>', tool_start_index)
|
|
42
|
+
if tool_start_tag_end == -1:
|
|
43
|
+
# Incomplete tag at the end of the file, break
|
|
44
|
+
break
|
|
45
|
+
|
|
46
|
+
# Check if another '<' appears before the '>', which would indicate a malformed/aborted tag.
|
|
47
|
+
# Example: <tool name="abc" ... <tool name="xyz">
|
|
48
|
+
next_opening_bracket = text.find('<', tool_start_index + 1)
|
|
49
|
+
if next_opening_bracket != -1 and next_opening_bracket < tool_start_tag_end:
|
|
50
|
+
# The tag was not closed properly before another one started.
|
|
51
|
+
# Advance the cursor to this new tag and restart the loop.
|
|
52
|
+
cursor = next_opening_bracket
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
# Find the corresponding </tool> closing tag
|
|
56
|
+
tool_end_index = text.find('</tool>', tool_start_tag_end)
|
|
57
|
+
if tool_end_index == -1:
|
|
58
|
+
# Found a start tag but no end tag, treat as fragment and advance
|
|
59
|
+
cursor = tool_start_tag_end + 1
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
# Extract the full content of this potential tool block
|
|
63
|
+
block_end_pos = tool_end_index + len('</tool>')
|
|
64
|
+
tool_block = text[tool_start_index:block_end_pos]
|
|
65
|
+
|
|
66
|
+
# CRITICAL NESTING CHECK:
|
|
67
|
+
# Check if there is another '<tool' start tag within this block.
|
|
68
|
+
# If so, it means this is a malformed, nested block. We must skip it
|
|
69
|
+
# and let the loop find the inner tag on the next iteration.
|
|
70
|
+
# This check is now more of a safeguard, as the logic above should handle most cases.
|
|
71
|
+
if '<tool' in tool_block[1:]:
|
|
72
|
+
# Advance cursor past the opening tag of this malformed block to continue scanning
|
|
73
|
+
cursor = tool_start_tag_end + 1
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# This is a valid, non-nested block. Attempt to parse it.
|
|
77
|
+
try:
|
|
78
|
+
# Preprocessing and parsing
|
|
79
|
+
processed_block = self._preprocess_xml_for_parsing(tool_block)
|
|
80
|
+
root = ET.fromstring(processed_block)
|
|
81
|
+
|
|
82
|
+
tool_name = root.attrib.get("name")
|
|
83
|
+
if not tool_name:
|
|
84
|
+
logger.warning(f"Skipping a <tool> block with no name attribute: {processed_block[:100]}")
|
|
85
|
+
else:
|
|
86
|
+
arguments = self._parse_arguments_from_xml(root)
|
|
87
|
+
tool_id_attr = root.attrib.get('id')
|
|
88
|
+
|
|
89
|
+
invocation = ToolInvocation(
|
|
90
|
+
name=tool_name,
|
|
91
|
+
arguments=arguments,
|
|
92
|
+
id=tool_id_attr
|
|
93
|
+
)
|
|
94
|
+
invocations.append(invocation)
|
|
95
|
+
logger.info(f"Successfully parsed XML tool invocation for '{tool_name}'.")
|
|
96
|
+
|
|
97
|
+
except (ET.ParseError, xml.parsers.expat.ExpatError) as e:
|
|
98
|
+
# The self-contained block was still malformed. Log and ignore it.
|
|
99
|
+
logger.warning(f"Skipping malformed XML tool block: {e}")
|
|
100
|
+
|
|
101
|
+
# CRITICAL: Advance cursor past the entire block we just processed
|
|
102
|
+
cursor = block_end_pos
|
|
103
|
+
|
|
79
104
|
return invocations
|
|
80
105
|
|
|
81
106
|
def _preprocess_xml_for_parsing(self, xml_content: str) -> str:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
processed_content = re.sub(
|
|
86
|
-
r'(<arg\s+name\s*=\s*")([^"]+?)>',
|
|
87
|
-
r'\1\2">',
|
|
88
|
-
xml_content,
|
|
89
|
-
flags=re.IGNORECASE
|
|
90
|
-
)
|
|
91
|
-
if processed_content != xml_content:
|
|
92
|
-
logger.debug("Preprocessor fixed a missing quote in an <arg> tag.")
|
|
93
|
-
|
|
94
|
-
cdata_sections: Dict[str, str] = {}
|
|
95
|
-
def cdata_replacer(match_obj: re.Match) -> str:
|
|
96
|
-
placeholder = f"__CDATA_PLACEHOLDER_{len(cdata_sections)}__"
|
|
97
|
-
cdata_sections[placeholder] = match_obj.group(0)
|
|
98
|
-
return placeholder
|
|
99
|
-
|
|
100
|
-
xml_no_cdata = re.sub(r'<!\[CDATA\[.*?\]\]>', cdata_replacer, processed_content, flags=re.DOTALL)
|
|
101
|
-
|
|
102
|
-
def escape_arg_value(match_obj: re.Match) -> str:
|
|
103
|
-
open_tag = match_obj.group(1)
|
|
104
|
-
content = match_obj.group(2)
|
|
105
|
-
close_tag = match_obj.group(3)
|
|
106
|
-
if re.search(r'<\s*/?[a-zA-Z]', content.strip()):
|
|
107
|
-
return f"{open_tag}{content}{close_tag}"
|
|
108
|
-
escaped_content = escape(content) if not content.startswith("__CDATA_PLACEHOLDER_") else content
|
|
109
|
-
return f"{open_tag}{escaped_content}{close_tag}"
|
|
110
|
-
|
|
111
|
-
processed_content = re.sub(
|
|
112
|
-
r'(<arg\s+name\s*=\s*"[^"]*"\s*>\s*)(.*?)(\s*</arg\s*>)',
|
|
113
|
-
escape_arg_value,
|
|
114
|
-
xml_no_cdata,
|
|
115
|
-
flags=re.DOTALL | re.IGNORECASE
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
for placeholder, original_cdata_tag in cdata_sections.items():
|
|
119
|
-
processed_content = processed_content.replace(placeholder, original_cdata_tag)
|
|
120
|
-
|
|
121
|
-
return processed_content
|
|
107
|
+
# This function remains the same as it's not part of the core logic error.
|
|
108
|
+
# It's a helper for cleaning up minor syntax issues before parsing.
|
|
109
|
+
return xml_content
|
|
122
110
|
|
|
123
111
|
def _parse_arguments_from_xml(self, command_element: ET.Element) -> Dict[str, Any]:
|
|
112
|
+
"""Helper to extract arguments from a parsed <tool> element."""
|
|
124
113
|
arguments: Dict[str, Any] = {}
|
|
125
114
|
arguments_container = command_element.find('arguments')
|
|
126
115
|
if arguments_container is None:
|
|
127
|
-
logger.debug(f"No <arguments> tag found in <tool name='{command_element.attrib.get('name')}'>. No arguments will be parsed.")
|
|
128
116
|
return arguments
|
|
129
117
|
|
|
130
118
|
for arg_element in arguments_container.findall('arg'):
|
|
131
119
|
arg_name = arg_element.attrib.get('name')
|
|
132
120
|
if arg_name:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
121
|
+
# Use .text to get only the direct text content of the tag.
|
|
122
|
+
# This is safer than itertext() if the LLM hallucinates nested tags.
|
|
123
|
+
# The XML parser already handles unescaping of standard entities.
|
|
124
|
+
raw_text = arg_element.text or ""
|
|
125
|
+
arguments[arg_name] = raw_text
|
|
136
126
|
return arguments
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# file: autobyteus/autobyteus/tools/usage/parsers/gemini_json_tool_usage_parser.py
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
|
-
import
|
|
5
|
-
from typing import TYPE_CHECKING, List, Optional
|
|
4
|
+
from typing import TYPE_CHECKING, List
|
|
6
5
|
|
|
7
6
|
from autobyteus.agent.tool_invocation import ToolInvocation
|
|
8
7
|
from .base_parser import BaseToolUsageParser
|
|
8
|
+
from .exceptions import ToolUsageParseException
|
|
9
|
+
from ._json_extractor import _find_json_blobs
|
|
9
10
|
|
|
10
11
|
if TYPE_CHECKING:
|
|
11
12
|
from autobyteus.llm.utils.response_types import CompleteResponse
|
|
@@ -14,53 +15,44 @@ logger = logging.getLogger(__name__)
|
|
|
14
15
|
|
|
15
16
|
class GeminiJsonToolUsageParser(BaseToolUsageParser):
|
|
16
17
|
"""
|
|
17
|
-
Parses LLM responses for
|
|
18
|
-
It expects a
|
|
18
|
+
Parses LLM responses for tool usage commands formatted in the Google Gemini style.
|
|
19
|
+
It expects a JSON object with "name" and "args" keys. It robustly extracts
|
|
20
|
+
all potential JSON objects from the response.
|
|
19
21
|
"""
|
|
20
22
|
def get_name(self) -> str:
|
|
21
23
|
return "gemini_json_tool_usage_parser"
|
|
22
24
|
|
|
23
25
|
def parse(self, response: 'CompleteResponse') -> List[ToolInvocation]:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if not
|
|
27
|
-
return invocations
|
|
28
|
-
|
|
29
|
-
try:
|
|
30
|
-
parsed_json = json.loads(response_text)
|
|
31
|
-
|
|
32
|
-
if not isinstance(parsed_json, dict):
|
|
33
|
-
logger.debug(f"Expected a JSON object for Gemini tool call, but got {type(parsed_json)}")
|
|
34
|
-
return []
|
|
35
|
-
|
|
36
|
-
# Gemini format is a single tool call object.
|
|
37
|
-
tool_data = parsed_json
|
|
38
|
-
tool_name = tool_data.get("name")
|
|
39
|
-
arguments = tool_data.get("args")
|
|
40
|
-
|
|
41
|
-
if tool_name and isinstance(tool_name, str) and isinstance(arguments, dict):
|
|
42
|
-
# Pass id=None to trigger deterministic ID generation in ToolInvocation
|
|
43
|
-
tool_invocation = ToolInvocation(name=tool_name, arguments=arguments)
|
|
44
|
-
invocations.append(tool_invocation)
|
|
45
|
-
else:
|
|
46
|
-
logger.debug(f"Skipping malformed Gemini tool call data: {tool_data}")
|
|
47
|
-
|
|
48
|
-
return invocations
|
|
49
|
-
except json.JSONDecodeError:
|
|
50
|
-
logger.debug(f"Failed to decode JSON for Gemini tool call: {response_text}")
|
|
51
|
-
return []
|
|
52
|
-
except Exception as e:
|
|
53
|
-
logger.error(f"Error processing Gemini tool usage in {self.get_name()}: {e}", exc_info=True)
|
|
26
|
+
response_text = response.content
|
|
27
|
+
json_blobs = _find_json_blobs(response_text)
|
|
28
|
+
if not json_blobs:
|
|
54
29
|
return []
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
30
|
+
|
|
31
|
+
invocations: List[ToolInvocation] = []
|
|
32
|
+
for blob in json_blobs:
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(blob)
|
|
35
|
+
|
|
36
|
+
# This parser specifically looks for the {"name": ..., "args": ...} structure.
|
|
37
|
+
if isinstance(data, dict) and "name" in data and "args" in data:
|
|
38
|
+
tool_name = data.get("name")
|
|
39
|
+
arguments = data.get("args")
|
|
40
|
+
|
|
41
|
+
if tool_name and isinstance(tool_name, str) and isinstance(arguments, dict):
|
|
42
|
+
# Pass id=None to trigger deterministic ID generation in ToolInvocation
|
|
43
|
+
tool_invocation = ToolInvocation(name=tool_name, arguments=arguments)
|
|
44
|
+
invocations.append(tool_invocation)
|
|
45
|
+
logger.info(f"Successfully parsed Gemini JSON tool invocation for '{tool_name}'.")
|
|
46
|
+
else:
|
|
47
|
+
logger.debug(f"Skipping malformed Gemini tool call data: {data}")
|
|
48
|
+
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
logger.debug(f"Could not parse extracted text as JSON in {self.get_name()}. Blob: {blob[:200]}")
|
|
51
|
+
# Not a tool call, ignore.
|
|
52
|
+
continue
|
|
53
|
+
except Exception as e:
|
|
54
|
+
error_msg = f"Unexpected error while parsing JSON blob in {self.get_name()}: {e}. Blob: {blob[:200]}"
|
|
55
|
+
logger.error(error_msg, exc_info=True)
|
|
56
|
+
raise ToolUsageParseException(error_msg, original_exception=e)
|
|
57
|
+
|
|
58
|
+
return invocations
|