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.
Files changed (49) hide show
  1. autobyteus/agent/bootstrap_steps/__init__.py +2 -0
  2. autobyteus/agent/bootstrap_steps/agent_bootstrapper.py +2 -0
  3. autobyteus/agent/bootstrap_steps/mcp_server_prewarming_step.py +71 -0
  4. autobyteus/agent/llm_response_processor/provider_aware_tool_usage_processor.py +41 -12
  5. autobyteus/agent/runtime/agent_runtime.py +1 -4
  6. autobyteus/agent/runtime/agent_worker.py +56 -23
  7. autobyteus/agent/shutdown_steps/__init__.py +17 -0
  8. autobyteus/agent/shutdown_steps/agent_shutdown_orchestrator.py +63 -0
  9. autobyteus/agent/shutdown_steps/base_shutdown_step.py +33 -0
  10. autobyteus/agent/shutdown_steps/llm_instance_cleanup_step.py +45 -0
  11. autobyteus/agent/shutdown_steps/mcp_server_cleanup_step.py +32 -0
  12. autobyteus/llm/api/deepseek_llm.py +10 -172
  13. autobyteus/llm/api/grok_llm.py +10 -171
  14. autobyteus/llm/api/kimi_llm.py +24 -0
  15. autobyteus/llm/api/openai_compatible_llm.py +193 -0
  16. autobyteus/llm/api/openai_llm.py +11 -139
  17. autobyteus/llm/llm_factory.py +62 -0
  18. autobyteus/llm/providers.py +1 -0
  19. autobyteus/llm/token_counter/kimi_token_counter.py +24 -0
  20. autobyteus/llm/token_counter/token_counter_factory.py +3 -0
  21. autobyteus/llm/utils/messages.py +3 -3
  22. autobyteus/tools/base_tool.py +2 -0
  23. autobyteus/tools/mcp/__init__.py +10 -7
  24. autobyteus/tools/mcp/call_handlers/__init__.py +0 -2
  25. autobyteus/tools/mcp/config_service.py +1 -6
  26. autobyteus/tools/mcp/factory.py +12 -26
  27. autobyteus/tools/mcp/registrar.py +57 -178
  28. autobyteus/tools/mcp/server/__init__.py +16 -0
  29. autobyteus/tools/mcp/server/base_managed_mcp_server.py +139 -0
  30. autobyteus/tools/mcp/server/http_managed_mcp_server.py +29 -0
  31. autobyteus/tools/mcp/server/proxy.py +36 -0
  32. autobyteus/tools/mcp/server/stdio_managed_mcp_server.py +33 -0
  33. autobyteus/tools/mcp/server_instance_manager.py +93 -0
  34. autobyteus/tools/mcp/tool.py +28 -46
  35. autobyteus/tools/mcp/tool_registrar.py +177 -0
  36. autobyteus/tools/mcp/types.py +10 -21
  37. autobyteus/tools/registry/tool_definition.py +11 -2
  38. autobyteus/tools/registry/tool_registry.py +27 -28
  39. autobyteus/tools/usage/parsers/_json_extractor.py +99 -0
  40. autobyteus/tools/usage/parsers/default_json_tool_usage_parser.py +46 -77
  41. autobyteus/tools/usage/parsers/default_xml_tool_usage_parser.py +87 -97
  42. autobyteus/tools/usage/parsers/gemini_json_tool_usage_parser.py +38 -46
  43. autobyteus/tools/usage/parsers/openai_json_tool_usage_parser.py +104 -154
  44. {autobyteus-1.1.1.dist-info → autobyteus-1.1.3.dist-info}/METADATA +4 -2
  45. {autobyteus-1.1.1.dist-info → autobyteus-1.1.3.dist-info}/RECORD +48 -32
  46. autobyteus/tools/mcp/call_handlers/sse_handler.py +0 -22
  47. {autobyteus-1.1.1.dist-info → autobyteus-1.1.3.dist-info}/WHEEL +0 -0
  48. {autobyteus-1.1.1.dist-info → autobyteus-1.1.3.dist-info}/licenses/LICENSE +0 -0
  49. {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 Optional, Dict, Any, TYPE_CHECKING, List
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 expects a 'tool' object with 'function' and 'parameters' keys.
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 = self._extract_json_from_response(response.content)
26
- if not response_text:
27
- return []
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 call_data in tool_calls_data:
48
- if not isinstance(call_data, dict):
49
- continue
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
- tool_block = call_data.get("tool")
52
- if not isinstance(tool_block, dict):
53
- continue
54
-
55
- tool_name = tool_block.get("function")
56
- arguments = tool_block.get("parameters")
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
- if not tool_name or not isinstance(tool_name, str):
59
- logger.debug(f"Skipping malformed tool block (missing or invalid 'function'): {tool_block}")
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
- logger.error(f"Unexpected error creating ToolInvocation for tool '{tool_name}': {e}", exc_info=True)
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
- from xml.sax.saxutils import escape, unescape
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
- It looks for either a <tools> block (for multiple calls) or a
23
- single <tool> block.
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
- response_text = response.content
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
- match = re.search(r"<tools\b[^>]*>.*?</tools\s*>|<tool\b[^>]*>.*?</tool\s*>", response_text, re.DOTALL | re.IGNORECASE)
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
- except Exception as e:
75
- logger.error(f"Unexpected error in {self.get_name()} processing XML: {e}. XML Content: {xml_content[:200]}", exc_info=True)
76
- # Also wrap unexpected errors for consistent handling.
77
- raise ToolUsageParseException(f"Unexpected error during XML parsing: {e}", original_exception=e)
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
- Preprocesses raw XML string from an LLM to fix common errors before parsing.
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
- raw_text = "".join(arg_element.itertext())
134
- unescaped_value = unescape(raw_text)
135
- arguments[arg_name] = unescaped_value
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 uuid
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 a single tool usage command formatted in the Google Gemini style.
18
- It expects a single JSON object with "name" and "args" keys.
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
- invocations: List[ToolInvocation] = []
25
- response_text = self.extract_json_from_response(response.content)
26
- if not response_text:
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
- def extract_json_from_response(self, text: str) -> Optional[str]:
57
- import re
58
- match = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", text)
59
- if match:
60
- return match.group(1).strip()
61
-
62
- stripped_text = text.strip()
63
- if stripped_text.startswith('{') and stripped_text.endswith('}'):
64
- return stripped_text
65
-
66
- return None
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