autobyteus 1.1.6__py3-none-any.whl → 1.1.8__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 (43) hide show
  1. autobyteus/agent/context/agent_runtime_state.py +7 -1
  2. autobyteus/agent/handlers/tool_result_event_handler.py +121 -89
  3. autobyteus/agent/llm_response_processor/provider_aware_tool_usage_processor.py +7 -1
  4. autobyteus/agent/tool_invocation.py +25 -1
  5. autobyteus/agent_team/agent_team_builder.py +22 -1
  6. autobyteus/agent_team/context/agent_team_runtime_state.py +0 -2
  7. autobyteus/llm/llm_factory.py +25 -57
  8. autobyteus/llm/ollama_provider_resolver.py +1 -0
  9. autobyteus/llm/providers.py +1 -0
  10. autobyteus/llm/token_counter/token_counter_factory.py +2 -0
  11. autobyteus/multimedia/audio/audio_model.py +2 -1
  12. autobyteus/multimedia/image/image_model.py +2 -1
  13. autobyteus/task_management/tools/publish_task_plan.py +4 -16
  14. autobyteus/task_management/tools/update_task_status.py +4 -19
  15. autobyteus/tools/__init__.py +2 -4
  16. autobyteus/tools/base_tool.py +98 -29
  17. autobyteus/tools/browser/standalone/__init__.py +0 -1
  18. autobyteus/tools/google_search.py +149 -0
  19. autobyteus/tools/mcp/schema_mapper.py +29 -71
  20. autobyteus/tools/multimedia/audio_tools.py +3 -3
  21. autobyteus/tools/multimedia/image_tools.py +5 -5
  22. autobyteus/tools/parameter_schema.py +82 -89
  23. autobyteus/tools/pydantic_schema_converter.py +81 -0
  24. autobyteus/tools/usage/formatters/default_json_example_formatter.py +89 -20
  25. autobyteus/tools/usage/formatters/default_xml_example_formatter.py +115 -41
  26. autobyteus/tools/usage/formatters/default_xml_schema_formatter.py +50 -20
  27. autobyteus/tools/usage/formatters/gemini_json_example_formatter.py +55 -22
  28. autobyteus/tools/usage/formatters/google_json_example_formatter.py +54 -21
  29. autobyteus/tools/usage/formatters/openai_json_example_formatter.py +53 -23
  30. autobyteus/tools/usage/parsers/default_xml_tool_usage_parser.py +270 -94
  31. autobyteus/tools/usage/providers/tool_manifest_provider.py +39 -14
  32. autobyteus-1.1.8.dist-info/METADATA +204 -0
  33. {autobyteus-1.1.6.dist-info → autobyteus-1.1.8.dist-info}/RECORD +39 -40
  34. examples/run_google_slides_agent.py +2 -2
  35. examples/run_mcp_google_slides_client.py +1 -1
  36. examples/run_sqlite_agent.py +1 -1
  37. autobyteus/tools/ask_user_input.py +0 -40
  38. autobyteus/tools/browser/standalone/factory/google_search_factory.py +0 -25
  39. autobyteus/tools/browser/standalone/google_search_ui.py +0 -126
  40. autobyteus-1.1.6.dist-info/METADATA +0 -161
  41. {autobyteus-1.1.6.dist-info → autobyteus-1.1.8.dist-info}/WHEEL +0 -0
  42. {autobyteus-1.1.6.dist-info → autobyteus-1.1.8.dist-info}/licenses/LICENSE +0 -0
  43. {autobyteus-1.1.6.dist-info → autobyteus-1.1.8.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,8 @@
1
1
  # file: autobyteus/autobyteus/tools/usage/formatters/google_json_example_formatter.py
2
- from typing import Dict, Any, TYPE_CHECKING
2
+ import json
3
+ from typing import Dict, Any, TYPE_CHECKING, Optional
3
4
 
4
- from autobyteus.tools.parameter_schema import ParameterType, ParameterDefinition
5
+ from autobyteus.tools.parameter_schema import ParameterSchema, ParameterDefinition
5
6
  from .base_formatter import BaseExampleFormatter
6
7
  # Import for reuse of the intelligent example generation logic
7
8
  from .default_json_example_formatter import DefaultJsonExampleFormatter
@@ -10,32 +11,64 @@ if TYPE_CHECKING:
10
11
  from autobyteus.tools.registry import ToolDefinition
11
12
 
12
13
  class GoogleJsonExampleFormatter(BaseExampleFormatter):
13
- """Formats a tool usage example into the Google JSON tool_calls format."""
14
+ """
15
+ Formats a tool usage example into the Google JSON tool_calls format.
16
+ Provides both basic (required only) and advanced (all) examples if optional
17
+ parameters exist for the tool.
18
+ """
14
19
 
15
- def provide(self, tool_definition: 'ToolDefinition') -> Dict:
20
+ def provide(self, tool_definition: 'ToolDefinition') -> str:
21
+ """
22
+ Generates a formatted string containing basic and optionally an advanced usage example for the tool.
23
+ """
24
+ basic_example_dict = self._create_example_structure(tool_definition, mode='basic')
25
+ basic_example_str = "### Example 1: Basic Call (Required Arguments)\n"
26
+ basic_example_str += "```json\n"
27
+ basic_example_str += json.dumps(basic_example_dict, indent=2)
28
+ basic_example_str += "\n```"
29
+
30
+ if not self._schema_has_advanced_params(tool_definition.argument_schema):
31
+ return basic_example_str
32
+
33
+ advanced_example_dict = self._create_example_structure(tool_definition, mode='advanced')
34
+ advanced_example_str = "### Example 2: Advanced Call (With Optional Arguments)\n"
35
+ advanced_example_str += "```json\n"
36
+ advanced_example_str += json.dumps(advanced_example_dict, indent=2)
37
+ advanced_example_str += "\n```"
38
+
39
+ return f"{basic_example_str}\n\n{advanced_example_str}"
40
+
41
+ def _create_example_structure(self, tool_definition: 'ToolDefinition', mode: str) -> Dict:
42
+ """Helper to create a single Google tool call example for a given mode."""
16
43
  tool_name = tool_definition.name
17
44
  arg_schema = tool_definition.argument_schema
18
45
  arguments = {}
19
46
 
20
47
  if arg_schema and arg_schema.parameters:
21
- for param_def in arg_schema.parameters:
22
- if param_def.required or param_def.default_value is not None:
23
- arguments[param_def.name] = self._generate_placeholder_value(param_def)
24
-
48
+ params_to_render = arg_schema.parameters
49
+ if mode == 'basic':
50
+ params_to_render = [p for p in arg_schema.parameters if p.required]
51
+
52
+ for param_def in params_to_render:
53
+ # Use the intelligent placeholder generator from the default formatter
54
+ arguments[param_def.name] = DefaultJsonExampleFormatter._generate_example_from_schema(
55
+ param_def.object_schema or param_def.array_item_schema or param_def.param_type,
56
+ param_def.object_schema or arg_schema,
57
+ mode=mode
58
+ ) if param_def.object_schema or param_def.array_item_schema else self._generate_simple_placeholder(param_def)
59
+
25
60
  return {"name": tool_name, "args": arguments}
26
61
 
27
- def _generate_placeholder_value(self, param_def: ParameterDefinition) -> Any:
28
- # REUSE the intelligent generator for complex objects
29
- if param_def.param_type == ParameterType.OBJECT and param_def.object_schema:
30
- return DefaultJsonExampleFormatter._generate_example_from_schema(param_def.object_schema, param_def.object_schema)
62
+ def _schema_has_advanced_params(self, schema: Optional[ParameterSchema]) -> bool:
63
+ """Recursively checks if a schema or any of its sub-schemas have non-required parameters."""
64
+ if not schema: return False
65
+ for param in schema.parameters:
66
+ if not param.required: return True
67
+ if param.object_schema and self._schema_has_advanced_params(param.object_schema): return True
68
+ if isinstance(param.array_item_schema, ParameterSchema) and self._schema_has_advanced_params(param.array_item_schema): return True
69
+ return False
31
70
 
32
- # Fallback for primitives
71
+ def _generate_simple_placeholder(self, param_def: ParameterDefinition) -> Any:
72
+ """Generates a simple placeholder for primitive types."""
33
73
  if param_def.default_value is not None: return param_def.default_value
34
- if param_def.param_type == ParameterType.STRING: return f"example_{param_def.name}"
35
- if param_def.param_type == ParameterType.INTEGER: return 123
36
- if param_def.param_type == ParameterType.FLOAT: return 123.45
37
- if param_def.param_type == ParameterType.BOOLEAN: return True
38
- if param_def.param_type == ParameterType.ENUM: return param_def.enum_values[0] if param_def.enum_values else "enum_val"
39
- if param_def.param_type == ParameterType.OBJECT: return {"key": "value"}
40
- if param_def.param_type == ParameterType.ARRAY: return ["item1", "item2"]
41
- return "placeholder"
74
+ return DefaultJsonExampleFormatter._generate_example_from_schema(param_def.param_type, param_def.param_type, mode='basic')
@@ -1,8 +1,8 @@
1
1
  # file: autobyteus/autobyteus/tools/usage/formatters/openai_json_example_formatter.py
2
2
  import json
3
- from typing import Dict, Any, TYPE_CHECKING
3
+ from typing import Dict, Any, TYPE_CHECKING, Optional
4
4
 
5
- from autobyteus.tools.parameter_schema import ParameterType, ParameterDefinition
5
+ from autobyteus.tools.parameter_schema import ParameterSchema, ParameterDefinition
6
6
  from .base_formatter import BaseExampleFormatter
7
7
  from .default_json_example_formatter import DefaultJsonExampleFormatter # Import for reuse
8
8
 
@@ -11,41 +11,71 @@ if TYPE_CHECKING:
11
11
 
12
12
  class OpenAiJsonExampleFormatter(BaseExampleFormatter):
13
13
  """
14
- Formats a tool usage example into a format resembling an entry in the
15
- OpenAI JSON 'tool_calls' array, intended for prompting a model.
14
+ Formats a tool usage example into the OpenAI JSON 'tool_calls' format.
15
+ Provides both basic (required only) and advanced (all) examples if optional
16
+ parameters exist for the tool.
16
17
  """
18
+
19
+ def provide(self, tool_definition: 'ToolDefinition') -> str:
20
+ """
21
+ Generates a formatted string containing basic and optionally an advanced usage example for the tool.
22
+ """
23
+ basic_example_dict = self._create_example_structure(tool_definition, mode='basic')
24
+ basic_example_str = "### Example 1: Basic Call (Required Arguments)\n"
25
+ basic_example_str += "```json\n"
26
+ basic_example_str += json.dumps(basic_example_dict, indent=2)
27
+ basic_example_str += "\n```"
28
+
29
+ if not self._schema_has_advanced_params(tool_definition.argument_schema):
30
+ return basic_example_str
17
31
 
18
- def provide(self, tool_definition: 'ToolDefinition') -> Dict:
32
+ advanced_example_dict = self._create_example_structure(tool_definition, mode='advanced')
33
+ advanced_example_str = "### Example 2: Advanced Call (With Optional Arguments)\n"
34
+ advanced_example_str += "```json\n"
35
+ advanced_example_str += json.dumps(advanced_example_dict, indent=2)
36
+ advanced_example_str += "\n```"
37
+
38
+ return f"{basic_example_str}\n\n{advanced_example_str}"
39
+
40
+ def _create_example_structure(self, tool_definition: 'ToolDefinition', mode: str) -> Dict:
41
+ """Helper to create a single OpenAI tool call example for a given mode."""
19
42
  tool_name = tool_definition.name
20
43
  arg_schema = tool_definition.argument_schema
21
44
  arguments = {}
22
45
 
23
46
  if arg_schema and arg_schema.parameters:
24
- for param_def in arg_schema.parameters:
25
- if param_def.required or param_def.default_value is not None:
26
- arguments[param_def.name] = self._generate_placeholder_value(param_def)
47
+ params_to_render = arg_schema.parameters
48
+ if mode == 'basic':
49
+ params_to_render = [p for p in arg_schema.parameters if p.required]
50
+
51
+ for param_def in params_to_render:
52
+ # Use the intelligent placeholder generator from the default formatter
53
+ arguments[param_def.name] = DefaultJsonExampleFormatter._generate_example_from_schema(
54
+ param_def.object_schema or param_def.array_item_schema or param_def.param_type,
55
+ param_def.object_schema or arg_schema,
56
+ mode=mode
57
+ ) if param_def.object_schema or param_def.array_item_schema else self._generate_simple_placeholder(param_def)
27
58
 
28
59
  function_call = {
29
60
  "function": {
30
61
  "name": tool_name,
31
- "arguments": json.dumps(arguments),
62
+ # FIX: Keep arguments as a dictionary for clear examples in the prompt.
63
+ # Do NOT stringify it here.
64
+ "arguments": arguments,
32
65
  },
33
66
  }
34
-
35
67
  return {"tool": function_call}
36
68
 
37
- def _generate_placeholder_value(self, param_def: ParameterDefinition) -> Any:
38
- # REUSE a more intelligent generator for complex objects
39
- if param_def.param_type == ParameterType.OBJECT and param_def.object_schema:
40
- return DefaultJsonExampleFormatter._generate_example_from_schema(param_def.object_schema, param_def.object_schema)
69
+ def _schema_has_advanced_params(self, schema: Optional[ParameterSchema]) -> bool:
70
+ """Recursively checks if a schema or any of its sub-schemas have non-required parameters."""
71
+ if not schema: return False
72
+ for param in schema.parameters:
73
+ if not param.required: return True
74
+ if param.object_schema and self._schema_has_advanced_params(param.object_schema): return True
75
+ if isinstance(param.array_item_schema, ParameterSchema) and self._schema_has_advanced_params(param.array_item_schema): return True
76
+ return False
41
77
 
42
- # Fallback for primitives
78
+ def _generate_simple_placeholder(self, param_def: ParameterDefinition) -> Any:
79
+ """Generates a simple placeholder for primitive types."""
43
80
  if param_def.default_value is not None: return param_def.default_value
44
- if param_def.param_type == ParameterType.STRING: return f"example_{param_def.name}"
45
- if param_def.param_type == ParameterType.INTEGER: return 123
46
- if param_def.param_type == ParameterType.FLOAT: return 123.45
47
- if param_def.param_type == ParameterType.BOOLEAN: return True
48
- if param_def.param_type == ParameterType.ENUM: return param_def.enum_values[0] if param_def.enum_values else "enum_val"
49
- if param_def.param_type == ParameterType.OBJECT: return {"key": "value"}
50
- if param_def.param_type == ParameterType.ARRAY: return ["item1", "item2"]
51
- return "placeholder"
81
+ return DefaultJsonExampleFormatter._generate_example_from_schema(param_def.param_type, param_def.param_type, mode='basic')
@@ -1,126 +1,302 @@
1
- import xml.etree.ElementTree as ET
2
- import re
3
- import uuid
4
- import html
5
- from xml.sax.saxutils import escape
6
- import xml.parsers.expat
7
1
  import logging
2
+ import re
8
3
  from typing import TYPE_CHECKING, Dict, Any, List
4
+ from dataclasses import dataclass, field
9
5
 
10
6
  from autobyteus.agent.tool_invocation import ToolInvocation
11
7
  from .base_parser import BaseToolUsageParser
12
- from .exceptions import ToolUsageParseException
13
8
 
14
9
  if TYPE_CHECKING:
15
10
  from autobyteus.llm.utils.response_types import CompleteResponse
16
11
 
17
12
  logger = logging.getLogger(__name__)
18
13
 
14
+ # A unique UUID to use as an internal key for storing text content.
15
+ # This prevents any potential collision with user-provided argument names.
16
+ _INTERNAL_TEXT_KEY_UUID = "4e1a3b1e-3b2a-4d3c-9a8b-2a1c2b3d4e5f"
17
+
18
+ # --- Internal Arguments Parser with State Machine ---
19
+ # This entire section is now encapsulated in its own class for clarity.
20
+
21
+ class _XmlArgumentsParser:
22
+ """
23
+ A dedicated parser for the XML content within an <arguments> tag.
24
+ It encapsulates the state machine and all related logic, separating it
25
+ from the higher-level tool-finding logic.
26
+ """
27
+
28
+ # --- Nested State Machine Components ---
29
+
30
+ @dataclass
31
+ class _ParsingContext:
32
+ """Holds the shared state for the parsing process."""
33
+ parser: '_XmlArgumentsParser'
34
+ input_string: str
35
+ cursor: int = 0
36
+ stack: List[Any] = field(default_factory=list)
37
+ content_buffer: str = ""
38
+
39
+ def __post_init__(self):
40
+ self.stack.append({}) # Root of arguments is a dictionary
41
+
42
+ def is_eof(self) -> bool:
43
+ return self.cursor >= len(self.input_string)
44
+
45
+ def append_to_buffer(self, text: str):
46
+ self.content_buffer += text
47
+
48
+ def commit_content_buffer(self):
49
+ if self.content_buffer:
50
+ self.parser._commit_content(self.stack, self.content_buffer)
51
+ self.content_buffer = ""
52
+
53
+ class _ParserState:
54
+ """Abstract base class for a state in our parser's state machine."""
55
+ def handle(self, context: '_XmlArgumentsParser._ParsingContext') -> '_XmlArgumentsParser._ParserState':
56
+ raise NotImplementedError
57
+
58
+ class _ParsingContentState(_ParserState):
59
+ """Handles accumulation of character data between tags."""
60
+ def handle(self, context: '_XmlArgumentsParser._ParsingContext') -> '_XmlArgumentsParser._ParserState':
61
+ if context.is_eof():
62
+ return None
63
+
64
+ next_tag_start = context.input_string.find('<', context.cursor)
65
+
66
+ if next_tag_start == -1:
67
+ context.append_to_buffer(context.input_string[context.cursor:])
68
+ context.cursor = len(context.input_string)
69
+ return self
70
+
71
+ is_valid_tag = False
72
+ if next_tag_start + 1 < len(context.input_string):
73
+ next_char = context.input_string[next_tag_start + 1]
74
+ if next_char.isalpha() or next_char == '/':
75
+ is_valid_tag = True
76
+
77
+ if is_valid_tag:
78
+ content_before_tag = context.input_string[context.cursor:next_tag_start]
79
+ context.append_to_buffer(content_before_tag)
80
+ context.commit_content_buffer()
81
+ context.cursor = next_tag_start
82
+ return self.parser._ParsingTagState(self.parser)
83
+ else:
84
+ content_with_char = context.input_string[context.cursor : next_tag_start + 1]
85
+ context.append_to_buffer(content_with_char)
86
+ context.cursor = next_tag_start + 1
87
+ return self
88
+
89
+ def __init__(self, parser: '_XmlArgumentsParser'):
90
+ self.parser = parser
91
+
92
+ class _ParsingTagState(_ParserState):
93
+ """Handles parsing of a tag, from '<' to '>'."""
94
+ def handle(self, context: '_XmlArgumentsParser._ParsingContext') -> '_XmlArgumentsParser._ParserState':
95
+ tag_content_end = context.input_string.find('>', context.cursor)
96
+ if tag_content_end == -1:
97
+ context.append_to_buffer(context.input_string[context.cursor:])
98
+ context.cursor = len(context.input_string)
99
+ return self.parser._ParsingContentState(self.parser)
100
+
101
+ tag_content = context.input_string[context.cursor + 1 : tag_content_end]
102
+ context.parser.process_tag(tag_content, context)
103
+
104
+ context.cursor = tag_content_end + 1
105
+ return self.parser._ParsingContentState(self.parser)
106
+
107
+ def __init__(self, parser: '_XmlArgumentsParser'):
108
+ self.parser = parser
109
+
110
+ # --- Parser Implementation ---
111
+
112
+ def __init__(self, xml_string: str):
113
+ self.xml_string = xml_string
114
+
115
+ def parse(self) -> Dict[str, Any]:
116
+ """Drives the state machine to parse the XML string."""
117
+ context = self._ParsingContext(parser=self, input_string=self.xml_string)
118
+ state = self._ParsingContentState(self)
119
+
120
+ while state and not context.is_eof():
121
+ state = state.handle(context)
122
+
123
+ context.commit_content_buffer()
124
+
125
+ final_args = context.stack[0]
126
+ self._cleanup_internal_keys(final_args)
127
+ return final_args
128
+
129
+ def process_tag(self, tag_content: str, context: '_ParsingContext'):
130
+ STRUCTURAL_TAGS = {'arg', 'item'}
131
+ stripped_content = tag_content.strip()
132
+ if not stripped_content:
133
+ context.append_to_buffer(f"<{tag_content}>")
134
+ return
135
+
136
+ is_closing = stripped_content.startswith('/')
137
+ tag_name = (stripped_content[1:] if is_closing else stripped_content).split(' ')[0]
138
+
139
+ if tag_name in STRUCTURAL_TAGS:
140
+ if is_closing:
141
+ self._handle_closing_tag(context.stack)
142
+ else:
143
+ self._handle_opening_tag(context.stack, tag_content)
144
+ else:
145
+ context.append_to_buffer(f"<{tag_content}>")
146
+
147
+ def _commit_content(self, stack: List[Any], content: str):
148
+ trimmed_content = content.strip()
149
+ if not trimmed_content and '<' not in content and '>' not in content:
150
+ return
151
+
152
+ top = stack[-1]
153
+ if isinstance(top, dict):
154
+ top[_INTERNAL_TEXT_KEY_UUID] = top.get(_INTERNAL_TEXT_KEY_UUID, '') + content
155
+
156
+ def _handle_opening_tag(self, stack: List[Any], tag_content: str):
157
+ parent = stack[-1]
158
+
159
+ if tag_content.strip().startswith('arg'):
160
+ name_match = re.search(r'name="([^"]+)"', tag_content)
161
+ if name_match and isinstance(parent, dict):
162
+ arg_name = name_match.group(1)
163
+ new_container = {}
164
+ parent[arg_name] = new_container
165
+ stack.append(new_container)
166
+
167
+ elif tag_content.strip().startswith('item'):
168
+ if isinstance(parent, dict):
169
+ grandparent = stack[-2]
170
+ parent_key = next((k for k, v in grandparent.items() if v is parent), None)
171
+ if parent_key:
172
+ new_list = []
173
+ grandparent[parent_key] = new_list
174
+ stack[-1] = new_list
175
+ parent = new_list
176
+
177
+ if isinstance(parent, list):
178
+ new_item_container = {}
179
+ parent.append(new_item_container)
180
+ stack.append(new_item_container)
181
+
182
+ def _handle_closing_tag(self, stack: List[Any]):
183
+ if len(stack) > 1:
184
+ top = stack.pop()
185
+ parent = stack[-1]
186
+
187
+ is_primitive = False
188
+ if isinstance(top, dict):
189
+ keys = top.keys()
190
+ if not keys or (len(keys) == 1 and _INTERNAL_TEXT_KEY_UUID in keys):
191
+ is_primitive = True
192
+
193
+ if is_primitive:
194
+ value = top.get(_INTERNAL_TEXT_KEY_UUID, '')
195
+ if isinstance(parent, list):
196
+ try:
197
+ idx = parent.index(top)
198
+ parent[idx] = value
199
+ except ValueError:
200
+ logger.warning("Could not find item to collapse in parent list.")
201
+ elif isinstance(parent, dict):
202
+ parent_key = next((k for k, v in parent.items() if v is top), None)
203
+ if parent_key:
204
+ parent[parent_key] = value
205
+
206
+ def _cleanup_internal_keys(self, data: Any):
207
+ if isinstance(data, dict):
208
+ if _INTERNAL_TEXT_KEY_UUID in data and len(data) > 1:
209
+ del data[_INTERNAL_TEXT_KEY_UUID]
210
+ for value in data.values():
211
+ self._cleanup_internal_keys(value)
212
+ elif isinstance(data, list):
213
+ for item in data:
214
+ self._cleanup_internal_keys(item)
215
+
216
+
217
+ # --- Main Parser Class ---
218
+
19
219
  class DefaultXmlToolUsageParser(BaseToolUsageParser):
20
220
  """
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.
221
+ Parses LLM responses for tool usage commands formatted as XML.
222
+ This class is responsible for finding <tool> blocks and delegating the
223
+ parsing of their arguments to the specialized _XmlArgumentsParser.
25
224
  """
225
+
26
226
  def get_name(self) -> str:
27
227
  return "default_xml_tool_usage_parser"
28
228
 
29
229
  def parse(self, response: 'CompleteResponse') -> List[ToolInvocation]:
30
230
  text = response.content
31
231
  invocations: List[ToolInvocation] = []
32
- cursor = 0
33
-
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
232
+ i = 0
233
+
234
+ while i < len(text):
235
+ try:
236
+ i = text.index('<tool', i)
237
+ except ValueError:
44
238
  break
45
239
 
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
240
+ open_tag_end = text.find('>', i)
241
+ if open_tag_end == -1: break
54
242
 
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
243
+ open_tag_content = text[i:open_tag_end+1]
244
+ name_match = re.search(r'name="([^"]+)"', open_tag_content)
245
+ if not name_match:
246
+ i = open_tag_end + 1
60
247
  continue
248
+
249
+ tool_name = name_match.group(1)
250
+ logger.debug(f"--- Found tool '{tool_name}' at index {i} ---")
61
251
 
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]
252
+ cursor = open_tag_end + 1
253
+ nesting_level = 1
254
+ content_end = -1
65
255
 
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
256
+ while cursor < len(text):
257
+ next_open = text.find('<tool', cursor)
258
+ next_close = text.find('</tool>', cursor)
75
259
 
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]}")
260
+ if next_close == -1: break
261
+
262
+ if next_open != -1 and next_open < next_close:
263
+ nesting_level += 1
264
+ end_of_nested_open = text.find('>', next_open)
265
+ if end_of_nested_open == -1: break
266
+ cursor = end_of_nested_open + 1
85
267
  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}")
268
+ nesting_level -= 1
269
+ if nesting_level == 0:
270
+ content_end = next_close
271
+ break
272
+ cursor = next_close + len('</tool>')
273
+
274
+ if content_end == -1:
275
+ logger.warning(f"Malformed XML for tool '{tool_name}': could not find matching </tool> tag.")
276
+ i = open_tag_end + 1
277
+ continue
278
+
279
+ tool_content = text[open_tag_end+1:content_end]
280
+ args_match = re.search(r'<arguments>(.*)</arguments>', tool_content, re.DOTALL)
100
281
 
101
- # CRITICAL: Advance cursor past the entire block we just processed
102
- cursor = block_end_pos
282
+ arguments = {}
283
+ if args_match:
284
+ arguments_xml = args_match.group(1)
285
+ try:
286
+ # Delegate parsing to the specialized class
287
+ arguments = self._parse_arguments(arguments_xml)
288
+ except Exception as e:
289
+ logger.error(f"Arguments parser failed for tool '{tool_name}': {e}", exc_info=True)
103
290
 
291
+ invocations.append(ToolInvocation(name=tool_name, arguments=arguments))
292
+ i = content_end + len('</tool>')
293
+
104
294
  return invocations
105
295
 
106
- def _preprocess_xml_for_parsing(self, xml_content: str) -> str:
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
110
-
111
- def _parse_arguments_from_xml(self, command_element: ET.Element) -> Dict[str, Any]:
112
- """Helper to extract arguments from a parsed <tool> element."""
113
- arguments: Dict[str, Any] = {}
114
- arguments_container = command_element.find('arguments')
115
- if arguments_container is None:
116
- return arguments
117
-
118
- for arg_element in arguments_container.findall('arg'):
119
- arg_name = arg_element.attrib.get('name')
120
- if arg_name:
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
126
- return arguments
296
+ def _parse_arguments(self, xml_string: str) -> Dict[str, Any]:
297
+ """
298
+ Delegates parsing of an arguments XML string to the dedicated parser class.
299
+ """
300
+ parser = _XmlArgumentsParser(xml_string)
301
+ return parser.parse()
302
+