autobyteus 1.1.9__py3-none-any.whl → 1.2.1__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 (126) hide show
  1. autobyteus/agent/context/agent_runtime_state.py +4 -0
  2. autobyteus/agent/events/notifiers.py +5 -1
  3. autobyteus/agent/message/send_message_to.py +5 -4
  4. autobyteus/agent/streaming/agent_event_stream.py +5 -0
  5. autobyteus/agent/streaming/stream_event_payloads.py +25 -0
  6. autobyteus/agent/streaming/stream_events.py +13 -1
  7. autobyteus/agent_team/bootstrap_steps/task_notifier_initialization_step.py +4 -4
  8. autobyteus/agent_team/bootstrap_steps/team_context_initialization_step.py +12 -12
  9. autobyteus/agent_team/context/agent_team_runtime_state.py +2 -2
  10. autobyteus/agent_team/streaming/agent_team_event_notifier.py +4 -4
  11. autobyteus/agent_team/streaming/agent_team_stream_event_payloads.py +3 -3
  12. autobyteus/agent_team/streaming/agent_team_stream_events.py +8 -8
  13. autobyteus/agent_team/task_notification/activation_policy.py +1 -1
  14. autobyteus/agent_team/task_notification/system_event_driven_agent_task_notifier.py +22 -22
  15. autobyteus/agent_team/task_notification/task_notification_mode.py +1 -1
  16. autobyteus/cli/agent_team_tui/app.py +4 -4
  17. autobyteus/cli/agent_team_tui/state.py +8 -8
  18. autobyteus/cli/agent_team_tui/widgets/focus_pane.py +3 -3
  19. autobyteus/cli/agent_team_tui/widgets/shared.py +1 -1
  20. autobyteus/cli/agent_team_tui/widgets/{task_board_panel.py → task_plan_panel.py} +5 -5
  21. autobyteus/clients/__init__.py +10 -0
  22. autobyteus/clients/autobyteus_client.py +318 -0
  23. autobyteus/clients/cert_utils.py +105 -0
  24. autobyteus/clients/certificates/cert.pem +34 -0
  25. autobyteus/events/event_types.py +4 -3
  26. autobyteus/llm/api/autobyteus_llm.py +1 -1
  27. autobyteus/llm/api/zhipu_llm.py +26 -0
  28. autobyteus/llm/autobyteus_provider.py +1 -1
  29. autobyteus/llm/llm_factory.py +23 -0
  30. autobyteus/llm/ollama_provider_resolver.py +1 -0
  31. autobyteus/llm/providers.py +1 -0
  32. autobyteus/llm/token_counter/token_counter_factory.py +3 -0
  33. autobyteus/llm/token_counter/zhipu_token_counter.py +24 -0
  34. autobyteus/multimedia/audio/api/__init__.py +3 -2
  35. autobyteus/multimedia/audio/api/autobyteus_audio_client.py +1 -1
  36. autobyteus/multimedia/audio/api/openai_audio_client.py +112 -0
  37. autobyteus/multimedia/audio/audio_client_factory.py +37 -0
  38. autobyteus/multimedia/audio/autobyteus_audio_provider.py +1 -1
  39. autobyteus/multimedia/image/api/autobyteus_image_client.py +1 -1
  40. autobyteus/multimedia/image/autobyteus_image_provider.py +1 -1
  41. autobyteus/multimedia/image/image_client_factory.py +1 -1
  42. autobyteus/task_management/__init__.py +44 -20
  43. autobyteus/task_management/{base_task_board.py → base_task_plan.py} +16 -13
  44. autobyteus/task_management/converters/__init__.py +2 -2
  45. autobyteus/task_management/converters/{task_board_converter.py → task_plan_converter.py} +13 -13
  46. autobyteus/task_management/events.py +7 -7
  47. autobyteus/task_management/{in_memory_task_board.py → in_memory_task_plan.py} +34 -22
  48. autobyteus/task_management/schemas/__init__.py +3 -0
  49. autobyteus/task_management/schemas/task_definition.py +1 -1
  50. autobyteus/task_management/schemas/task_status_report.py +3 -3
  51. autobyteus/task_management/schemas/todo_definition.py +15 -0
  52. autobyteus/task_management/todo.py +29 -0
  53. autobyteus/task_management/todo_list.py +75 -0
  54. autobyteus/task_management/tools/__init__.py +25 -7
  55. autobyteus/task_management/tools/task_tools/__init__.py +19 -0
  56. autobyteus/task_management/tools/task_tools/assign_task_to.py +125 -0
  57. autobyteus/task_management/tools/{publish_task.py → task_tools/create_task.py} +16 -18
  58. autobyteus/task_management/tools/{publish_tasks.py → task_tools/create_tasks.py} +19 -19
  59. autobyteus/task_management/tools/{get_my_tasks.py → task_tools/get_my_tasks.py} +15 -15
  60. autobyteus/task_management/tools/{get_task_board_status.py → task_tools/get_task_plan_status.py} +16 -16
  61. autobyteus/task_management/tools/{update_task_status.py → task_tools/update_task_status.py} +16 -16
  62. autobyteus/task_management/tools/todo_tools/__init__.py +18 -0
  63. autobyteus/task_management/tools/todo_tools/add_todo.py +78 -0
  64. autobyteus/task_management/tools/todo_tools/create_todo_list.py +79 -0
  65. autobyteus/task_management/tools/todo_tools/get_todo_list.py +55 -0
  66. autobyteus/task_management/tools/todo_tools/update_todo_status.py +85 -0
  67. autobyteus/tools/__init__.py +61 -21
  68. autobyteus/tools/bash/bash_executor.py +3 -3
  69. autobyteus/tools/browser/session_aware/browser_session_aware_navigate_to.py +5 -5
  70. autobyteus/tools/browser/session_aware/browser_session_aware_web_element_trigger.py +4 -4
  71. autobyteus/tools/browser/session_aware/browser_session_aware_webpage_reader.py +3 -3
  72. autobyteus/tools/browser/session_aware/browser_session_aware_webpage_screenshot_taker.py +3 -3
  73. autobyteus/tools/browser/standalone/navigate_to.py +13 -9
  74. autobyteus/tools/browser/standalone/web_page_pdf_generator.py +9 -5
  75. autobyteus/tools/browser/standalone/webpage_image_downloader.py +10 -6
  76. autobyteus/tools/browser/standalone/webpage_reader.py +13 -9
  77. autobyteus/tools/browser/standalone/webpage_screenshot_taker.py +9 -5
  78. autobyteus/tools/file/__init__.py +13 -0
  79. autobyteus/tools/file/edit_file.py +200 -0
  80. autobyteus/tools/file/list_directory.py +168 -0
  81. autobyteus/tools/file/{file_reader.py → read_file.py} +3 -3
  82. autobyteus/tools/file/search_files.py +188 -0
  83. autobyteus/tools/file/{file_writer.py → write_file.py} +3 -3
  84. autobyteus/tools/functional_tool.py +10 -8
  85. autobyteus/tools/mcp/tool.py +3 -3
  86. autobyteus/tools/mcp/tool_registrar.py +5 -2
  87. autobyteus/tools/multimedia/__init__.py +2 -1
  88. autobyteus/tools/multimedia/audio_tools.py +2 -2
  89. autobyteus/tools/multimedia/download_media_tool.py +136 -0
  90. autobyteus/tools/multimedia/image_tools.py +4 -4
  91. autobyteus/tools/multimedia/media_reader_tool.py +1 -1
  92. autobyteus/tools/registry/tool_definition.py +66 -13
  93. autobyteus/tools/registry/tool_registry.py +29 -0
  94. autobyteus/tools/search/__init__.py +17 -0
  95. autobyteus/tools/search/base_strategy.py +35 -0
  96. autobyteus/tools/search/client.py +24 -0
  97. autobyteus/tools/search/factory.py +81 -0
  98. autobyteus/tools/search/google_cse_strategy.py +68 -0
  99. autobyteus/tools/search/providers.py +10 -0
  100. autobyteus/tools/search/serpapi_strategy.py +65 -0
  101. autobyteus/tools/search/serper_strategy.py +87 -0
  102. autobyteus/tools/search_tool.py +83 -0
  103. autobyteus/tools/timer.py +4 -0
  104. autobyteus/tools/tool_meta.py +4 -24
  105. autobyteus/tools/usage/parsers/_string_decoders.py +18 -0
  106. autobyteus/tools/usage/parsers/default_json_tool_usage_parser.py +9 -1
  107. autobyteus/tools/usage/parsers/default_xml_tool_usage_parser.py +15 -1
  108. autobyteus/tools/usage/parsers/gemini_json_tool_usage_parser.py +4 -1
  109. autobyteus/tools/usage/parsers/openai_json_tool_usage_parser.py +4 -1
  110. autobyteus/workflow/bootstrap_steps/coordinator_prompt_preparation_step.py +1 -2
  111. {autobyteus-1.1.9.dist-info → autobyteus-1.2.1.dist-info}/METADATA +7 -6
  112. {autobyteus-1.1.9.dist-info → autobyteus-1.2.1.dist-info}/RECORD +117 -94
  113. examples/run_agentic_software_engineer.py +239 -0
  114. examples/run_poem_writer.py +3 -3
  115. autobyteus/person/__init__.py +0 -0
  116. autobyteus/person/examples/__init__.py +0 -0
  117. autobyteus/person/examples/sample_persons.py +0 -14
  118. autobyteus/person/examples/sample_roles.py +0 -14
  119. autobyteus/person/person.py +0 -29
  120. autobyteus/person/role.py +0 -14
  121. autobyteus/tools/google_search.py +0 -149
  122. autobyteus/tools/image_downloader.py +0 -99
  123. autobyteus/tools/pdf_downloader.py +0 -89
  124. {autobyteus-1.1.9.dist-info → autobyteus-1.2.1.dist-info}/WHEEL +0 -0
  125. {autobyteus-1.1.9.dist-info → autobyteus-1.2.1.dist-info}/licenses/LICENSE +0 -0
  126. {autobyteus-1.1.9.dist-info → autobyteus-1.2.1.dist-info}/top_level.txt +0 -0
@@ -10,8 +10,8 @@ if TYPE_CHECKING:
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
13
- @tool(name="FileReader", category=ToolCategory.FILE_SYSTEM)
14
- async def file_reader(context: 'AgentContext', path: str) -> str:
13
+ @tool(name="read_file", category=ToolCategory.FILE_SYSTEM)
14
+ async def read_file(context: 'AgentContext', path: str) -> str:
15
15
  """
16
16
  Reads content from a specified file.
17
17
  'path' is the path to the file. If relative, it must be resolved against a configured agent workspace.
@@ -19,7 +19,7 @@ async def file_reader(context: 'AgentContext', path: str) -> str:
19
19
  Raises FileNotFoundError if the file does not exist.
20
20
  Raises IOError if file reading fails for other reasons.
21
21
  """
22
- logger.debug(f"Functional FileReader tool for agent {context.agent_id}, initial path: {path}")
22
+ logger.debug(f"Functional read_file tool for agent {context.agent_id}, initial path: {path}")
23
23
 
24
24
  final_path: str
25
25
  if os.path.isabs(path):
@@ -0,0 +1,188 @@
1
+ # file: autobyteus/autobyteus/tools/file/search_files.py
2
+ """
3
+ This module provides a high-performance fuzzy file search tool.
4
+ It uses 'git ls-files' for speed in Git repositories and falls back
5
+ to a filesystem walk for other directories, respecting .gitignore.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import os
12
+ import subprocess
13
+ from pathlib import Path
14
+ from typing import List, Dict, Optional, Tuple, TYPE_CHECKING
15
+
16
+ from rapidfuzz import process, fuzz
17
+ from pathspec import PathSpec
18
+ from pathspec.patterns import GitWildMatchPattern
19
+
20
+ from autobyteus.tools.functional_tool import tool
21
+ from autobyteus.tools.tool_category import ToolCategory
22
+
23
+ if TYPE_CHECKING:
24
+ from autobyteus.agent.context import AgentContext
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @tool(name="search_files", category=ToolCategory.FILE_SYSTEM)
30
+ async def search_files(
31
+ context: 'AgentContext',
32
+ query: Optional[str] = None,
33
+ path: str = '.',
34
+ limit: int = 64,
35
+ exclude_patterns: Optional[List[str]] = None
36
+ ) -> str:
37
+ """
38
+ Performs a high-performance fuzzy search for files in a directory.
39
+
40
+ This tool intelligently discovers files. If the search directory is a Git repository,
41
+ it uses the highly efficient 'git ls-files' command. Otherwise, it performs a
42
+ standard filesystem walk. In both cases, it respects .gitignore rules and any
43
+ additional exclusion patterns provided. The search results are returned as a
44
+ JSON string, with each result including the file path and a relevance score.
45
+
46
+ Args:
47
+ query: The fuzzy search pattern. If omitted, the tool lists all discoverable files up to the limit.
48
+ path: The directory to search in. Relative paths are resolved against the agent's workspace. Defaults to the workspace root.
49
+ limit: The maximum number of results to return.
50
+ exclude_patterns: A list of glob patterns to exclude from the search, in addition to .gitignore rules.
51
+ """
52
+ final_path = _resolve_search_path(context, path)
53
+ if not final_path.is_dir():
54
+ raise FileNotFoundError(f"The specified search path does not exist or is not a directory: {final_path}")
55
+
56
+ exclude = exclude_patterns or []
57
+ files, discovery_method = await _discover_files(final_path, exclude)
58
+
59
+ if not query:
60
+ # If no query, just return the first 'limit' files found
61
+ matches = [{"path": f, "score": 100} for f in files[:limit]]
62
+ result_summary = {
63
+ "discovery_method": discovery_method,
64
+ "total_files_scanned": len(files),
65
+ "matches_found": len(matches),
66
+ "results": matches
67
+ }
68
+ return json.dumps(result_summary, indent=2)
69
+
70
+ # Use rapidfuzz to find the best matches
71
+ results = process.extract(
72
+ query,
73
+ files,
74
+ scorer=fuzz.WRatio,
75
+ limit=limit,
76
+ score_cutoff=50
77
+ )
78
+
79
+ file_matches = [{"path": path, "score": round(score)} for path, score, _ in results]
80
+
81
+ result_summary = {
82
+ "discovery_method": discovery_method,
83
+ "total_files_scanned": len(files),
84
+ "matches_found": len(file_matches),
85
+ "results": file_matches
86
+ }
87
+ return json.dumps(result_summary, indent=2)
88
+
89
+
90
+ def _resolve_search_path(context: 'AgentContext', path: str) -> Path:
91
+ """Resolves the search path against the agent's workspace if relative."""
92
+ if os.path.isabs(path):
93
+ return Path(path)
94
+
95
+ if not context.workspace:
96
+ raise ValueError(f"Relative path '{path}' provided, but no workspace is configured for agent '{context.agent_id}'.")
97
+
98
+ base_path = context.workspace.get_base_path()
99
+ if not base_path:
100
+ raise ValueError(f"Agent '{context.agent_id}' has a workspace, but it provided an invalid base path.")
101
+
102
+ return Path(os.path.normpath(os.path.join(base_path, path)))
103
+
104
+
105
+ async def _is_git_repository_async(path: Path) -> bool:
106
+ """Asynchronously checks if a given path is within a Git repository."""
107
+ process = await asyncio.create_subprocess_exec(
108
+ "git", "rev-parse", "--is-inside-work-tree",
109
+ cwd=str(path),
110
+ stdout=asyncio.subprocess.PIPE,
111
+ stderr=asyncio.subprocess.PIPE,
112
+ )
113
+ stdout, _ = await process.communicate()
114
+ return stdout.decode().strip() == "true"
115
+
116
+
117
+ async def _get_files_from_git_async(path: Path) -> List[str]:
118
+ """Uses 'git ls-files' to get a list of all tracked and untracked files."""
119
+ try:
120
+ process = await asyncio.create_subprocess_exec(
121
+ "git", "ls-files", "-co", "--exclude-standard",
122
+ cwd=str(path),
123
+ stdout=asyncio.subprocess.PIPE,
124
+ stderr=asyncio.subprocess.PIPE,
125
+ )
126
+ stdout_bytes, stderr_bytes = await process.communicate()
127
+ if process.returncode != 0:
128
+ stderr = stderr_bytes.decode().strip()
129
+ logger.error(f"Failed to run 'git ls-files' in '{path}': {stderr}")
130
+ return []
131
+
132
+ stdout = stdout_bytes.decode().strip()
133
+ return stdout.strip().split("\n") if stdout.strip() else []
134
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
135
+ logger.error(f"Failed to run 'git ls-files': {e}")
136
+ return []
137
+
138
+
139
+ def _get_files_with_walk_sync(path: Path, exclude_patterns: List[str]) -> List[str]:
140
+ """Synchronously walks the filesystem to find files, respecting ignore patterns."""
141
+ files: List[str] = []
142
+
143
+ all_exclude_patterns = exclude_patterns[:]
144
+ gitignore_path = path / ".gitignore"
145
+ if gitignore_path.is_file():
146
+ try:
147
+ with open(gitignore_path, "r", encoding='utf-8') as f:
148
+ all_exclude_patterns.extend(f.read().splitlines())
149
+ except Exception as e:
150
+ logger.warning(f"Could not read .gitignore file at '{gitignore_path}': {e}")
151
+
152
+ spec = PathSpec.from_lines(GitWildMatchPattern, all_exclude_patterns)
153
+
154
+ for root, _, filenames in os.walk(path, topdown=True):
155
+ root_path = Path(root)
156
+ for filename in filenames:
157
+ full_path = root_path / filename
158
+ try:
159
+ relative_path = full_path.relative_to(path)
160
+ if not spec.match_file(str(relative_path)):
161
+ files.append(str(relative_path))
162
+ except (ValueError, IsADirectoryError):
163
+ # Handles cases like broken symlinks
164
+ continue
165
+ return files
166
+
167
+
168
+ async def _get_files_with_walk_async(path: Path, exclude_patterns: List[str]) -> List[str]:
169
+ """Runs the synchronous walk in a thread pool."""
170
+ loop = asyncio.get_running_loop()
171
+ return await loop.run_in_executor(
172
+ None, _get_files_with_walk_sync, path, exclude_patterns
173
+ )
174
+
175
+
176
+ async def _discover_files(cwd: Path, exclude: List[str]) -> Tuple[List[str], str]:
177
+ """Orchestrates the file discovery, choosing between Git and os.walk."""
178
+ if await _is_git_repository_async(cwd):
179
+ logger.info(f"Using 'git ls-files' for fast file discovery in '{cwd}'.")
180
+ files = await _get_files_from_git_async(cwd)
181
+ # Git ls-files already handles gitignore, but we may have extra excludes
182
+ if exclude:
183
+ spec = PathSpec.from_lines(GitWildMatchPattern, exclude)
184
+ files = [f for f in files if not spec.match_file(f)]
185
+ return files, "git"
186
+ else:
187
+ logger.info(f"Using 'os.walk' to scan directory '{cwd}'.")
188
+ return await _get_files_with_walk_async(cwd, exclude), "os_walk"
@@ -10,8 +10,8 @@ if TYPE_CHECKING:
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
13
- @tool(name="FileWriter", category=ToolCategory.FILE_SYSTEM)
14
- async def file_writer(context: 'AgentContext', path: str, content: str) -> str:
13
+ @tool(name="write_file", category=ToolCategory.FILE_SYSTEM)
14
+ async def write_file(context: 'AgentContext', path: str, content: str) -> str:
15
15
  """
16
16
  Creates or overwrites a file with specified content.
17
17
  'path' is the path where the file will be written. If relative, it must be resolved against a configured agent workspace.
@@ -20,7 +20,7 @@ async def file_writer(context: 'AgentContext', path: str, content: str) -> str:
20
20
  Raises ValueError if a relative path is given without a valid workspace.
21
21
  Raises IOError if file writing fails.
22
22
  """
23
- logger.debug(f"Functional FileWriter tool for agent {context.agent_id}, initial path: {path}")
23
+ logger.debug(f"Functional write_file tool for agent {context.agent_id}, initial path: {path}")
24
24
 
25
25
  final_path: str
26
26
  if os.path.isabs(path):
@@ -99,7 +99,7 @@ def _python_type_to_json_schema(py_type: Any) -> Optional[Dict[str, Any]]:
99
99
  if py_type is float: return {"type": "number"}
100
100
  if py_type is bool: return {"type": "boolean"}
101
101
  if py_type is dict: return {"type": "object"}
102
- if py_type is list: return {"type": "array", "items": True}
102
+ if py_type is list: return {"type": "array", "items": {}} # Use empty dict for 'any'
103
103
 
104
104
  origin_type = get_origin(py_type)
105
105
  if origin_type is Union:
@@ -111,8 +111,8 @@ def _python_type_to_json_schema(py_type: Any) -> Optional[Dict[str, Any]]:
111
111
  list_args = get_args(py_type)
112
112
  if list_args and len(list_args) == 1:
113
113
  item_schema = _python_type_to_json_schema(list_args[0])
114
- return {"type": "array", "items": item_schema if item_schema else True}
115
- return {"type": "array", "items": True}
114
+ return {"type": "array", "items": item_schema if item_schema else {}}
115
+ return {"type": "array", "items": {}} # Use empty dict for 'any'
116
116
  if origin_type is Dict or origin_type is dict: return {"type": "object"}
117
117
  logger.debug(f"Could not map Python type {py_type} to a simple JSON schema for array items.")
118
118
  return None
@@ -141,13 +141,15 @@ def _get_parameter_type_from_hint(py_type: Any, param_name: str) -> Tuple[Parame
141
141
  list_args = get_args(actual_type)
142
142
  if list_args and len(list_args) == 1:
143
143
  array_item_js_schema = _python_type_to_json_schema(list_args[0])
144
- if not array_item_js_schema:
145
- array_item_js_schema = True
144
+ # FIX: For an untyped list, the item schema should be None, not True.
145
+ # An empty dict `{}` is a valid JSON schema for 'any'.
146
+ if array_item_js_schema is None:
147
+ array_item_js_schema = {}
146
148
  return param_type_enum, array_item_js_schema
147
149
 
148
150
  mapped_type = _TYPE_MAPPING.get(actual_type)
149
151
  if mapped_type:
150
- item_schema_for_array = True if mapped_type == ParameterType.ARRAY else None
152
+ item_schema_for_array = {} if mapped_type == ParameterType.ARRAY else None
151
153
  return mapped_type, item_schema_for_array
152
154
 
153
155
  logger.warning(f"Unmapped type hint {py_type} (actual_type: {actual_type}) for param '{param_name}'. Defaulting to ParameterType.STRING.")
@@ -232,8 +234,8 @@ def tool(
232
234
  tool_def = ToolDefinition(
233
235
  name=tool_name,
234
236
  description=tool_desc,
235
- argument_schema=final_arg_schema,
236
- config_schema=config_schema,
237
+ argument_schema_provider=lambda: final_arg_schema,
238
+ config_schema_provider=lambda: config_schema,
237
239
  custom_factory=factory,
238
240
  tool_class=None,
239
241
  origin=ToolOrigin.LOCAL,
@@ -41,7 +41,7 @@ class GenericMcpTool(BaseTool):
41
41
  self.get_description = self.get_instance_description
42
42
  self.get_argument_schema = self.get_instance_argument_schema
43
43
 
44
- logger.info(f"GenericMcpTool instance created for remote tool '{remote_tool_name}' on server '{self._server_id}'. "
44
+ logger.info(f"call_remote_mcp_tool instance created for remote tool '{remote_tool_name}' on server '{self._server_id}'. "
45
45
  f"Registered in AutoByteUs as '{self._instance_name}'.")
46
46
 
47
47
  # --- Getters for instance-specific data ---
@@ -51,7 +51,7 @@ class GenericMcpTool(BaseTool):
51
51
 
52
52
  # --- Base class methods (class-level, not instance-level) ---
53
53
  @classmethod
54
- def get_name(cls) -> str: return "GenericMcpTool"
54
+ def get_name(cls) -> str: return "call_remote_mcp_tool"
55
55
  @classmethod
56
56
  def get_description(cls) -> str: return "A generic wrapper for executing remote MCP tools."
57
57
  @classmethod
@@ -65,7 +65,7 @@ class GenericMcpTool(BaseTool):
65
65
  agent_id = context.agent_id
66
66
  tool_name_for_log = self.get_instance_name()
67
67
 
68
- logger.info(f"GenericMcpTool '{tool_name_for_log}': Creating proxy for agent '{agent_id}' and server '{self._server_id}'.")
68
+ logger.info(f"call_remote_mcp_tool '{tool_name_for_log}': Creating proxy for agent '{agent_id}' and server '{self._server_id}'.")
69
69
 
70
70
  try:
71
71
  # The proxy is created on-demand for each execution.
@@ -59,6 +59,8 @@ class McpToolRegistrar(metaclass=SingletonMeta):
59
59
  if server_config.tool_name_prefix:
60
60
  registered_name = f"{server_config.tool_name_prefix.rstrip('_')}_{remote_tool.name}"
61
61
 
62
+ # Note: McpToolFactory is now somewhat redundant as it holds static info,
63
+ # but we keep it for consistency. It creates a GenericMcpTool which needs this static info.
62
64
  tool_factory = McpToolFactory(
63
65
  server_id=server_config.server_id,
64
66
  remote_tool_name=remote_tool.name,
@@ -70,12 +72,13 @@ class McpToolRegistrar(metaclass=SingletonMeta):
70
72
  return ToolDefinition(
71
73
  name=registered_name,
72
74
  description=actual_desc,
73
- argument_schema=actual_arg_schema,
75
+ # Pass schema providers as lambdas to conform to the new constructor
76
+ argument_schema_provider=lambda: actual_arg_schema,
77
+ config_schema_provider=lambda: None,
74
78
  origin=ToolOrigin.MCP,
75
79
  category=server_config.server_id, # Use server_id as the category
76
80
  metadata={"mcp_server_id": server_config.server_id}, # Store origin in generic metadata
77
81
  custom_factory=tool_factory.create_tool,
78
- config_schema=None,
79
82
  tool_class=None
80
83
  )
81
84
 
@@ -1,10 +1,11 @@
1
1
  from .image_tools import GenerateImageTool, EditImageTool
2
2
  from .audio_tools import GenerateSpeechTool
3
3
  from .media_reader_tool import ReadMediaFile
4
-
4
+ from .download_media_tool import DownloadMediaTool
5
5
  __all__ = [
6
6
  "GenerateImageTool",
7
7
  "EditImageTool",
8
8
  "GenerateSpeechTool",
9
9
  "ReadMediaFile",
10
+ "DownloadMediaTool",
10
11
  ]
@@ -63,7 +63,7 @@ class GenerateSpeechTool(BaseTool):
63
63
 
64
64
  @classmethod
65
65
  def get_name(cls) -> str:
66
- return "GenerateSpeech"
66
+ return "generate_speech"
67
67
 
68
68
  @classmethod
69
69
  def get_description(cls) -> str:
@@ -91,7 +91,7 @@ class GenerateSpeechTool(BaseTool):
91
91
 
92
92
  async def _execute(self, context, prompt: str, generation_config: Optional[dict] = None) -> List[str]:
93
93
  model_identifier = _get_configured_model_identifier(self.MODEL_ENV_VAR, self.DEFAULT_MODEL)
94
- logger.info(f"GenerateSpeechTool executing with configured model '{model_identifier}'.")
94
+ logger.info(f"generate_speech executing with configured model '{model_identifier}'.")
95
95
  client = None
96
96
  try:
97
97
  client = audio_client_factory.create_audio_client(model_identifier=model_identifier)
@@ -0,0 +1,136 @@
1
+ import os
2
+ import logging
3
+ import mimetypes
4
+ import aiohttp
5
+ from typing import Optional, TYPE_CHECKING
6
+ from urllib.parse import urlparse
7
+
8
+ from autobyteus.tools.base_tool import BaseTool
9
+ from autobyteus.tools.tool_category import ToolCategory
10
+ from autobyteus.utils.file_utils import get_default_download_folder
11
+ from autobyteus.utils.parameter_schema import ParameterSchema, ParameterDefinition, ParameterType
12
+
13
+ if TYPE_CHECKING:
14
+ from autobyteus.agent.context import AgentContext
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ class DownloadMediaTool(BaseTool):
19
+ """
20
+ A unified tool to download any media file (e.g., image, PDF, audio) from a URL.
21
+ """
22
+ CATEGORY = ToolCategory.MULTIMEDIA
23
+
24
+ @classmethod
25
+ def get_name(cls) -> str:
26
+ return "download_media"
27
+
28
+ @classmethod
29
+ def get_description(cls) -> str:
30
+ return (
31
+ "Downloads various media files (e.g., images like PNG/JPG, documents like PDF, audio like MP3/WAV) "
32
+ "from a direct URL and saves them locally. It intelligently determines the correct file extension "
33
+ "based on the content type. Returns the absolute path to the downloaded file."
34
+ )
35
+
36
+ @classmethod
37
+ def get_argument_schema(cls) -> ParameterSchema:
38
+ schema = ParameterSchema()
39
+ schema.add_parameter(ParameterDefinition(
40
+ name="url",
41
+ param_type=ParameterType.STRING,
42
+ description="The direct URL of the media file to download.",
43
+ required=True
44
+ ))
45
+ schema.add_parameter(ParameterDefinition(
46
+ name="filename",
47
+ param_type=ParameterType.STRING,
48
+ description="The desired base name for the downloaded file (e.g., 'vacation_photo', 'annual_report'). The tool will automatically add the correct file extension.",
49
+ required=True
50
+ ))
51
+ schema.add_parameter(ParameterDefinition(
52
+ name="folder",
53
+ param_type=ParameterType.STRING,
54
+ description="Optional. A custom directory path to save the file. If not provided, the system's default download folder will be used.",
55
+ required=False
56
+ ))
57
+ return schema
58
+
59
+ async def _execute(self, context: 'AgentContext', url: str, filename: str, folder: Optional[str] = None) -> str:
60
+ # 1. Determine download directory
61
+ try:
62
+ if folder:
63
+ # Security: prevent path traversal attacks.
64
+ if ".." in folder:
65
+ raise ValueError("Security error: 'folder' path cannot contain '..'.")
66
+ destination_dir = os.path.abspath(folder)
67
+ else:
68
+ destination_dir = get_default_download_folder()
69
+
70
+ os.makedirs(destination_dir, exist_ok=True)
71
+ except Exception as e:
72
+ logger.error(f"Error preparing download directory '{folder or 'default'}': {e}", exc_info=True)
73
+ raise IOError(f"Failed to create or access download directory: {e}")
74
+
75
+ # 2. Sanitize filename provided by the LLM
76
+ if not filename or ".." in filename or os.path.isabs(filename) or "/" in filename or "\\" in filename:
77
+ raise ValueError("Invalid filename. It must be a simple name without any path characters ('..', '/', '\\').")
78
+
79
+ logger.info(f"Attempting to download from {url} to save as '{filename}' in '{destination_dir}'.")
80
+
81
+ # 3. Download and process file asynchronously
82
+ try:
83
+ async with aiohttp.ClientSession() as session:
84
+ async with session.get(url, timeout=60) as response:
85
+ response.raise_for_status()
86
+
87
+ # 4. Intelligently determine file extension from Content-Type header
88
+ content_type = response.headers.get('Content-Type')
89
+ correct_ext = ''
90
+ if content_type:
91
+ mime_type = content_type.split(';')[0].strip()
92
+ guess = mimetypes.guess_extension(mime_type)
93
+ if guess:
94
+ correct_ext = guess
95
+ logger.debug(f"Determined extension '{correct_ext}' from Content-Type: '{mime_type}'")
96
+
97
+ # Fallback to URL extension if Content-Type is generic or missing
98
+ if not correct_ext or correct_ext == '.bin':
99
+ url_path = urlparse(url).path
100
+ _, ext_from_url = os.path.splitext(os.path.basename(url_path))
101
+ if ext_from_url and len(ext_from_url) > 1: # Ensure it's not just a dot
102
+ logger.debug(f"Using fallback extension '{ext_from_url}' from URL.")
103
+ correct_ext = ext_from_url
104
+
105
+ if not correct_ext:
106
+ logger.warning("Could not determine a file extension. The file will be saved without one.")
107
+
108
+ # 5. Construct final filename and path
109
+ base_filename, _ = os.path.splitext(filename)
110
+ final_filename = f"{base_filename}{correct_ext}"
111
+ save_path = os.path.join(destination_dir, final_filename)
112
+
113
+ # Ensure filename is unique to avoid overwriting
114
+ counter = 1
115
+ while os.path.exists(save_path):
116
+ final_filename = f"{base_filename}_{counter}{correct_ext}"
117
+ save_path = os.path.join(destination_dir, final_filename)
118
+ counter += 1
119
+
120
+ # 6. Stream file content to disk
121
+ with open(save_path, 'wb') as f:
122
+ async for chunk in response.content.iter_chunked(8192):
123
+ f.write(chunk)
124
+
125
+ logger.info(f"Successfully downloaded and saved file to: {save_path}")
126
+ return f"Successfully downloaded file to: {save_path}"
127
+
128
+ except aiohttp.ClientError as e:
129
+ logger.error(f"Network error while downloading from {url}: {e}", exc_info=True)
130
+ raise ConnectionError(f"Failed to download from {url}: {e}")
131
+ except IOError as e:
132
+ logger.error(f"Failed to write downloaded file to {destination_dir}: {e}", exc_info=True)
133
+ raise
134
+ except Exception as e:
135
+ logger.error(f"An unexpected error occurred during download from {url}: {e}", exc_info=True)
136
+ raise RuntimeError(f"An unexpected error occurred: {e}")
@@ -62,7 +62,7 @@ class GenerateImageTool(BaseTool):
62
62
 
63
63
  @classmethod
64
64
  def get_name(cls) -> str:
65
- return "GenerateImage"
65
+ return "generate_image"
66
66
 
67
67
  @classmethod
68
68
  def get_description(cls) -> str:
@@ -92,7 +92,7 @@ class GenerateImageTool(BaseTool):
92
92
 
93
93
  async def _execute(self, context, prompt: str, input_image_urls: Optional[str] = None, generation_config: Optional[dict] = None) -> List[str]:
94
94
  model_identifier = _get_configured_model_identifier(self.MODEL_ENV_VAR, self.DEFAULT_MODEL)
95
- logger.info(f"GenerateImageTool executing with configured model '{model_identifier}'.")
95
+ logger.info(f"generate_image executing with configured model '{model_identifier}'.")
96
96
  client = None
97
97
  try:
98
98
  urls_list = None
@@ -125,7 +125,7 @@ class EditImageTool(BaseTool):
125
125
 
126
126
  @classmethod
127
127
  def get_name(cls) -> str:
128
- return "EditImage"
128
+ return "edit_image"
129
129
 
130
130
  @classmethod
131
131
  def get_description(cls) -> str:
@@ -161,7 +161,7 @@ class EditImageTool(BaseTool):
161
161
 
162
162
  async def _execute(self, context, prompt: str, input_image_urls: str, generation_config: Optional[dict] = None, mask_image_url: Optional[str] = None) -> List[str]:
163
163
  model_identifier = _get_configured_model_identifier(self.MODEL_ENV_VAR, self.DEFAULT_MODEL)
164
- logger.info(f"EditImageTool executing with configured model '{model_identifier}'.")
164
+ logger.info(f"edit_image executing with configured model '{model_identifier}'.")
165
165
  client = None
166
166
  try:
167
167
  urls_list = [url.strip() for url in input_image_urls.split(',') if url.strip()]
@@ -21,7 +21,7 @@ class ReadMediaFile(BaseTool):
21
21
  the file's content. The tool's result is a structured object that the system
22
22
  uses to construct a multimodal prompt, not plain text.
23
23
  """
24
- TOOL_NAME = "ReadMediaFile"
24
+ TOOL_NAME = "read_media_file"
25
25
  CATEGORY = ToolCategory.MULTIMEDIA
26
26
 
27
27
  @classmethod