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.
- autobyteus/agent/context/agent_runtime_state.py +4 -0
- autobyteus/agent/events/notifiers.py +5 -1
- autobyteus/agent/message/send_message_to.py +5 -4
- autobyteus/agent/streaming/agent_event_stream.py +5 -0
- autobyteus/agent/streaming/stream_event_payloads.py +25 -0
- autobyteus/agent/streaming/stream_events.py +13 -1
- autobyteus/agent_team/bootstrap_steps/task_notifier_initialization_step.py +4 -4
- autobyteus/agent_team/bootstrap_steps/team_context_initialization_step.py +12 -12
- autobyteus/agent_team/context/agent_team_runtime_state.py +2 -2
- autobyteus/agent_team/streaming/agent_team_event_notifier.py +4 -4
- autobyteus/agent_team/streaming/agent_team_stream_event_payloads.py +3 -3
- autobyteus/agent_team/streaming/agent_team_stream_events.py +8 -8
- autobyteus/agent_team/task_notification/activation_policy.py +1 -1
- autobyteus/agent_team/task_notification/system_event_driven_agent_task_notifier.py +22 -22
- autobyteus/agent_team/task_notification/task_notification_mode.py +1 -1
- autobyteus/cli/agent_team_tui/app.py +4 -4
- autobyteus/cli/agent_team_tui/state.py +8 -8
- autobyteus/cli/agent_team_tui/widgets/focus_pane.py +3 -3
- autobyteus/cli/agent_team_tui/widgets/shared.py +1 -1
- autobyteus/cli/agent_team_tui/widgets/{task_board_panel.py → task_plan_panel.py} +5 -5
- autobyteus/clients/__init__.py +10 -0
- autobyteus/clients/autobyteus_client.py +318 -0
- autobyteus/clients/cert_utils.py +105 -0
- autobyteus/clients/certificates/cert.pem +34 -0
- autobyteus/events/event_types.py +4 -3
- autobyteus/llm/api/autobyteus_llm.py +1 -1
- autobyteus/llm/api/zhipu_llm.py +26 -0
- autobyteus/llm/autobyteus_provider.py +1 -1
- autobyteus/llm/llm_factory.py +23 -0
- autobyteus/llm/ollama_provider_resolver.py +1 -0
- autobyteus/llm/providers.py +1 -0
- autobyteus/llm/token_counter/token_counter_factory.py +3 -0
- autobyteus/llm/token_counter/zhipu_token_counter.py +24 -0
- autobyteus/multimedia/audio/api/__init__.py +3 -2
- autobyteus/multimedia/audio/api/autobyteus_audio_client.py +1 -1
- autobyteus/multimedia/audio/api/openai_audio_client.py +112 -0
- autobyteus/multimedia/audio/audio_client_factory.py +37 -0
- autobyteus/multimedia/audio/autobyteus_audio_provider.py +1 -1
- autobyteus/multimedia/image/api/autobyteus_image_client.py +1 -1
- autobyteus/multimedia/image/autobyteus_image_provider.py +1 -1
- autobyteus/multimedia/image/image_client_factory.py +1 -1
- autobyteus/task_management/__init__.py +44 -20
- autobyteus/task_management/{base_task_board.py → base_task_plan.py} +16 -13
- autobyteus/task_management/converters/__init__.py +2 -2
- autobyteus/task_management/converters/{task_board_converter.py → task_plan_converter.py} +13 -13
- autobyteus/task_management/events.py +7 -7
- autobyteus/task_management/{in_memory_task_board.py → in_memory_task_plan.py} +34 -22
- autobyteus/task_management/schemas/__init__.py +3 -0
- autobyteus/task_management/schemas/task_definition.py +1 -1
- autobyteus/task_management/schemas/task_status_report.py +3 -3
- autobyteus/task_management/schemas/todo_definition.py +15 -0
- autobyteus/task_management/todo.py +29 -0
- autobyteus/task_management/todo_list.py +75 -0
- autobyteus/task_management/tools/__init__.py +25 -7
- autobyteus/task_management/tools/task_tools/__init__.py +19 -0
- autobyteus/task_management/tools/task_tools/assign_task_to.py +125 -0
- autobyteus/task_management/tools/{publish_task.py → task_tools/create_task.py} +16 -18
- autobyteus/task_management/tools/{publish_tasks.py → task_tools/create_tasks.py} +19 -19
- autobyteus/task_management/tools/{get_my_tasks.py → task_tools/get_my_tasks.py} +15 -15
- autobyteus/task_management/tools/{get_task_board_status.py → task_tools/get_task_plan_status.py} +16 -16
- autobyteus/task_management/tools/{update_task_status.py → task_tools/update_task_status.py} +16 -16
- autobyteus/task_management/tools/todo_tools/__init__.py +18 -0
- autobyteus/task_management/tools/todo_tools/add_todo.py +78 -0
- autobyteus/task_management/tools/todo_tools/create_todo_list.py +79 -0
- autobyteus/task_management/tools/todo_tools/get_todo_list.py +55 -0
- autobyteus/task_management/tools/todo_tools/update_todo_status.py +85 -0
- autobyteus/tools/__init__.py +61 -21
- autobyteus/tools/bash/bash_executor.py +3 -3
- autobyteus/tools/browser/session_aware/browser_session_aware_navigate_to.py +5 -5
- autobyteus/tools/browser/session_aware/browser_session_aware_web_element_trigger.py +4 -4
- autobyteus/tools/browser/session_aware/browser_session_aware_webpage_reader.py +3 -3
- autobyteus/tools/browser/session_aware/browser_session_aware_webpage_screenshot_taker.py +3 -3
- autobyteus/tools/browser/standalone/navigate_to.py +13 -9
- autobyteus/tools/browser/standalone/web_page_pdf_generator.py +9 -5
- autobyteus/tools/browser/standalone/webpage_image_downloader.py +10 -6
- autobyteus/tools/browser/standalone/webpage_reader.py +13 -9
- autobyteus/tools/browser/standalone/webpage_screenshot_taker.py +9 -5
- autobyteus/tools/file/__init__.py +13 -0
- autobyteus/tools/file/edit_file.py +200 -0
- autobyteus/tools/file/list_directory.py +168 -0
- autobyteus/tools/file/{file_reader.py → read_file.py} +3 -3
- autobyteus/tools/file/search_files.py +188 -0
- autobyteus/tools/file/{file_writer.py → write_file.py} +3 -3
- autobyteus/tools/functional_tool.py +10 -8
- autobyteus/tools/mcp/tool.py +3 -3
- autobyteus/tools/mcp/tool_registrar.py +5 -2
- autobyteus/tools/multimedia/__init__.py +2 -1
- autobyteus/tools/multimedia/audio_tools.py +2 -2
- autobyteus/tools/multimedia/download_media_tool.py +136 -0
- autobyteus/tools/multimedia/image_tools.py +4 -4
- autobyteus/tools/multimedia/media_reader_tool.py +1 -1
- autobyteus/tools/registry/tool_definition.py +66 -13
- autobyteus/tools/registry/tool_registry.py +29 -0
- autobyteus/tools/search/__init__.py +17 -0
- autobyteus/tools/search/base_strategy.py +35 -0
- autobyteus/tools/search/client.py +24 -0
- autobyteus/tools/search/factory.py +81 -0
- autobyteus/tools/search/google_cse_strategy.py +68 -0
- autobyteus/tools/search/providers.py +10 -0
- autobyteus/tools/search/serpapi_strategy.py +65 -0
- autobyteus/tools/search/serper_strategy.py +87 -0
- autobyteus/tools/search_tool.py +83 -0
- autobyteus/tools/timer.py +4 -0
- autobyteus/tools/tool_meta.py +4 -24
- autobyteus/tools/usage/parsers/_string_decoders.py +18 -0
- autobyteus/tools/usage/parsers/default_json_tool_usage_parser.py +9 -1
- autobyteus/tools/usage/parsers/default_xml_tool_usage_parser.py +15 -1
- autobyteus/tools/usage/parsers/gemini_json_tool_usage_parser.py +4 -1
- autobyteus/tools/usage/parsers/openai_json_tool_usage_parser.py +4 -1
- autobyteus/workflow/bootstrap_steps/coordinator_prompt_preparation_step.py +1 -2
- {autobyteus-1.1.9.dist-info → autobyteus-1.2.1.dist-info}/METADATA +7 -6
- {autobyteus-1.1.9.dist-info → autobyteus-1.2.1.dist-info}/RECORD +117 -94
- examples/run_agentic_software_engineer.py +239 -0
- examples/run_poem_writer.py +3 -3
- autobyteus/person/__init__.py +0 -0
- autobyteus/person/examples/__init__.py +0 -0
- autobyteus/person/examples/sample_persons.py +0 -14
- autobyteus/person/examples/sample_roles.py +0 -14
- autobyteus/person/person.py +0 -29
- autobyteus/person/role.py +0 -14
- autobyteus/tools/google_search.py +0 -149
- autobyteus/tools/image_downloader.py +0 -99
- autobyteus/tools/pdf_downloader.py +0 -89
- {autobyteus-1.1.9.dist-info → autobyteus-1.2.1.dist-info}/WHEEL +0 -0
- {autobyteus-1.1.9.dist-info → autobyteus-1.2.1.dist-info}/licenses/LICENSE +0 -0
- {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="
|
|
14
|
-
async def
|
|
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
|
|
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="
|
|
14
|
-
async def
|
|
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
|
|
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":
|
|
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
|
|
115
|
-
return {"type": "array", "items":
|
|
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
|
-
|
|
145
|
-
|
|
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 =
|
|
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
|
-
|
|
236
|
-
|
|
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,
|
autobyteus/tools/mcp/tool.py
CHANGED
|
@@ -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"
|
|
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 "
|
|
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"
|
|
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
|
-
|
|
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 "
|
|
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"
|
|
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 "
|
|
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"
|
|
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 "
|
|
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"
|
|
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 = "
|
|
24
|
+
TOOL_NAME = "read_media_file"
|
|
25
25
|
CATEGORY = ToolCategory.MULTIMEDIA
|
|
26
26
|
|
|
27
27
|
@classmethod
|