diff --git a/autogpt/command_decorator.py b/autogpt/command_decorator.py index 3f8279e4d6..98f114e463 100644 --- a/autogpt/command_decorator.py +++ b/autogpt/command_decorator.py @@ -1,5 +1,5 @@ import functools -from typing import Any, Callable, Optional +from typing import Any, Callable, Dict, Optional from autogpt.config import Config from autogpt.logs import logger @@ -12,7 +12,7 @@ AUTO_GPT_COMMAND_IDENTIFIER = "auto_gpt_command" def command( name: str, description: str, - signature: str, + arguments: Dict[str, Dict[str, Any]], enabled: bool | Callable[[Config], bool] = True, disabled_reason: Optional[str] = None, ) -> Callable[..., Any]: @@ -33,7 +33,7 @@ def command( name=name, description=description, method=func, - signature=signature, + signature=arguments, enabled=enabled, disabled_reason=disabled_reason, ) diff --git a/autogpt/commands/execute_code.py b/autogpt/commands/execute_code.py index c422d652f9..beaae64ca7 100644 --- a/autogpt/commands/execute_code.py +++ b/autogpt/commands/execute_code.py @@ -20,7 +20,18 @@ DENYLIST_CONTROL = "denylist" @command( "execute_python_code", "Create a Python file and execute it", - '"code": "", "basename": ""', + { + "code": { + "type": "string", + "description": "The Python code to run", + "required": True, + }, + "name": { + "type": "string", + "description": "A name to be given to the python file", + "required": True, + }, + }, ) def execute_python_code(code: str, basename: str, agent: Agent) -> str: """Create and execute a Python file in a Docker container and return the STDOUT of the @@ -51,7 +62,17 @@ def execute_python_code(code: str, basename: str, agent: Agent) -> str: return f"Error: {str(e)}" -@command("execute_python_file", "Execute Python File", '"filename": ""') +@command( + "execute_python_file", + "Execute an existing Python file", + { + "filename": { + "type": "string", + "description": "The name of te file to execute", + "required": True, + }, + }, +) def execute_python_file(filename: str, agent: Agent) -> str: """Execute a Python file in a Docker container and return the output @@ -171,9 +192,15 @@ def validate_command(command: str, config: Config) -> bool: @command( "execute_shell", "Execute Shell Command, non-interactive commands only", - '"command_line": ""', - lambda cfg: cfg.execute_local_commands, - "You are not allowed to run local shell commands. To execute" + { + "command_line": { + "type": "string", + "description": "The command line to execute", + "required": True, + } + }, + enabled=lambda cfg: cfg.execute_local_commands, + disabled_reason="You are not allowed to run local shell commands. To execute" " shell commands, EXECUTE_LOCAL_COMMANDS must be set to 'True' " "in your config file: .env - do not attempt to bypass the restriction.", ) @@ -211,7 +238,13 @@ def execute_shell(command_line: str, agent: Agent) -> str: @command( "execute_shell_popen", "Execute Shell Command, non-interactive commands only", - '"command_line": ""', + { + "query": { + "type": "string", + "description": "The search query", + "required": True, + } + }, lambda config: config.execute_local_commands, "You are not allowed to run local shell commands. To execute" " shell commands, EXECUTE_LOCAL_COMMANDS must be set to 'True' " diff --git a/autogpt/commands/file_operations.py b/autogpt/commands/file_operations.py index d74fee9610..2a932d38de 100644 --- a/autogpt/commands/file_operations.py +++ b/autogpt/commands/file_operations.py @@ -4,21 +4,15 @@ from __future__ import annotations import hashlib import os import os.path -import re from typing import Generator, Literal -import requests -from colorama import Back, Fore from confection import Config -from requests.adapters import HTTPAdapter, Retry from autogpt.agent.agent import Agent from autogpt.command_decorator import command from autogpt.commands.file_operations_utils import read_textual_file from autogpt.logs import logger from autogpt.memory.vector import MemoryItem, VectorMemory -from autogpt.spinner import Spinner -from autogpt.utils import readable_file_size Operation = Literal["write", "append", "delete"] @@ -119,7 +113,17 @@ def log_operation( ) -@command("read_file", "Read a file", '"filename": ""') +@command( + "read_file", + "Read an existing file", + { + "filename": { + "type": "string", + "description": "The path of the file to read", + "required": True, + } + }, +) def read_file(filename: str, agent: Agent) -> str: """Read a file and return the contents @@ -168,7 +172,22 @@ def ingest_file( logger.warn(f"Error while ingesting file '{filename}': {err}") -@command("write_to_file", "Write to file", '"filename": "", "text": ""') +@command( + "write_to_file", + "Write to file", + { + "filename": { + "type": "string", + "description": "The name of the file to write to", + "required": True, + }, + "text": { + "type": "string", + "description": "The text to write to the file", + "required": True, + }, + }, +) def write_to_file(filename: str, text: str, agent: Agent) -> str: """Write text to a file @@ -194,69 +213,20 @@ def write_to_file(filename: str, text: str, agent: Agent) -> str: @command( - "replace_in_file", - "Replace text or code in a file", - '"filename": "", ' - '"old_text": "", "new_text": "", ' - '"occurrence_index": ""', -) -def replace_in_file( - filename: str, old_text: str, new_text: str, agent: Agent, occurrence_index=None -): - """Update a file by replacing one or all occurrences of old_text with new_text using Python's built-in string - manipulation and regular expression modules for cross-platform file editing similar to sed and awk. - - Args: - filename (str): The name of the file - old_text (str): String to be replaced. \n will be stripped from the end. - new_text (str): New string. \n will be stripped from the end. - occurrence_index (int): Optional index of the occurrence to replace. If None, all occurrences will be replaced. - - Returns: - str: A message indicating whether the file was updated successfully or if there were no matches found for old_text - in the file. - - Raises: - Exception: If there was an error updating the file. - """ - try: - with open(filename, "r", encoding="utf-8") as f: - content = f.read() - - old_text = old_text.rstrip("\n") - new_text = new_text.rstrip("\n") - - if occurrence_index is None: - new_content = content.replace(old_text, new_text) - else: - matches = list(re.finditer(re.escape(old_text), content)) - if not matches: - return f"No matches found for {old_text} in {filename}" - - if int(occurrence_index) >= len(matches): - return f"Occurrence index {occurrence_index} is out of range for {old_text} in {filename}" - - match = matches[int(occurrence_index)] - start, end = match.start(), match.end() - new_content = content[:start] + new_text + content[end:] - - if content == new_content: - return f"No matches found for {old_text} in {filename}" - - with open(filename, "w", encoding="utf-8") as f: - f.write(new_content) - - with open(filename, "r", encoding="utf-8") as f: - checksum = text_checksum(f.read()) - log_operation("update", filename, agent, checksum=checksum) - - return f"File {filename} updated successfully." - except Exception as e: - return "Error: " + str(e) - - -@command( - "append_to_file", "Append to file", '"filename": "", "text": ""' + "append_to_file", + "Append to file", + { + "filename": { + "type": "string", + "description": "The name of the file to write to", + "required": True, + }, + "text": { + "type": "string", + "description": "The text to write to the file", + "required": True, + }, + }, ) def append_to_file( filename: str, text: str, agent: Agent, should_log: bool = True @@ -287,7 +257,17 @@ def append_to_file( return f"Error: {err}" -@command("delete_file", "Delete file", '"filename": ""') +@command( + "delete_file", + "Delete file", + { + "filename": { + "type": "string", + "description": "The name of the file to delete", + "required": True, + } + }, +) def delete_file(filename: str, agent: Agent) -> str: """Delete a file @@ -307,7 +287,17 @@ def delete_file(filename: str, agent: Agent) -> str: return f"Error: {err}" -@command("list_files", "List Files in Directory", '"directory": ""') +@command( + "list_files", + "List Files in Directory", + { + "directory": { + "type": "string", + "description": "The directory to list files in", + "required": True, + } + }, +) def list_files(directory: str, agent: Agent) -> list[str]: """lists files in a directory recursively @@ -329,51 +319,3 @@ def list_files(directory: str, agent: Agent) -> list[str]: found_files.append(relative_path) return found_files - - -@command( - "download_file", - "Download File", - '"url": "", "filename": ""', - lambda config: config.allow_downloads, - "Error: You do not have user authorization to download files locally.", -) -def download_file(url, filename, agent: Agent): - """Downloads a file - Args: - url (str): URL of the file to download - filename (str): Filename to save the file as - """ - try: - directory = os.path.dirname(filename) - os.makedirs(directory, exist_ok=True) - message = f"{Fore.YELLOW}Downloading file from {Back.LIGHTBLUE_EX}{url}{Back.RESET}{Fore.RESET}" - with Spinner(message, plain_output=agent.config.plain_output) as spinner: - session = requests.Session() - retry = Retry(total=3, backoff_factor=1, status_forcelist=[502, 503, 504]) - adapter = HTTPAdapter(max_retries=retry) - session.mount("http://", adapter) - session.mount("https://", adapter) - - total_size = 0 - downloaded_size = 0 - - with session.get(url, allow_redirects=True, stream=True) as r: - r.raise_for_status() - total_size = int(r.headers.get("Content-Length", 0)) - downloaded_size = 0 - - with open(filename, "wb") as f: - for chunk in r.iter_content(chunk_size=8192): - f.write(chunk) - downloaded_size += len(chunk) - - # Update the progress message - progress = f"{readable_file_size(downloaded_size)} / {readable_file_size(total_size)}" - spinner.update_message(f"{message} {progress}") - - return f'Successfully downloaded and locally stored file: "{filename}"! (Size: {readable_file_size(downloaded_size)})' - except requests.HTTPError as err: - return f"Got an HTTP Error whilst trying to download file: {err}" - except Exception as err: - return f"Error: {err}" diff --git a/autogpt/commands/git_operations.py b/autogpt/commands/git_operations.py index 8dfe213cdf..3832ca885f 100644 --- a/autogpt/commands/git_operations.py +++ b/autogpt/commands/git_operations.py @@ -10,7 +10,18 @@ from autogpt.url_utils.validators import validate_url @command( "clone_repository", "Clone Repository", - '"url": "", "clone_path": ""', + { + "url": { + "type": "string", + "description": "The URL of the repository to clone", + "required": True, + }, + "clone_path": { + "type": "string", + "description": "The path to clone the repository to", + "required": True, + }, + }, lambda config: config.github_username and config.github_api_key, "Configure github_username and github_api_key.", ) diff --git a/autogpt/commands/image_gen.py b/autogpt/commands/image_gen.py index 5bed8e00bb..043e91d7c2 100644 --- a/autogpt/commands/image_gen.py +++ b/autogpt/commands/image_gen.py @@ -17,7 +17,13 @@ from autogpt.logs import logger @command( "generate_image", "Generate Image", - '"prompt": ""', + { + "prompt": { + "type": "string", + "description": "The prompt used to generate the image", + "required": True, + }, + }, lambda config: config.image_provider, "Requires a image provider to be set.", ) diff --git a/autogpt/commands/task_statuses.py b/autogpt/commands/task_statuses.py index d5718fd359..062ebe3a49 100644 --- a/autogpt/commands/task_statuses.py +++ b/autogpt/commands/task_statuses.py @@ -9,16 +9,22 @@ from autogpt.logs import logger @command( - "task_complete", - "Task Complete (Shutdown)", - '"reason": ""', + "goals_accomplished", + "Goals are accomplished and there is nothing left to do", + { + "reason": { + "type": "string", + "description": "A summary to the user of how the goals were accomplished", + "required": True, + } + }, ) def task_complete(reason: str, agent: Agent) -> NoReturn: """ A function that takes in a string and exits the program Parameters: - reason (str): The reason for shutting down. + reason (str): A summary to the user of how the goals were accomplished. Returns: A result string from create chat completion. A list of suggestions to improve the code. diff --git a/autogpt/commands/google_search.py b/autogpt/commands/web_search.py similarity index 87% rename from autogpt/commands/google_search.py rename to autogpt/commands/web_search.py index e6a1fc0588..50b06e4805 100644 --- a/autogpt/commands/google_search.py +++ b/autogpt/commands/web_search.py @@ -14,12 +14,17 @@ DUCKDUCKGO_MAX_ATTEMPTS = 3 @command( - "google", - "Google Search", - '"query": ""', - lambda config: not config.google_api_key, + "web_search", + "Search the web", + { + "query": { + "type": "string", + "description": "The search query", + "required": True, + } + }, ) -def google_search(query: str, agent: Agent, num_results: int = 8) -> str: +def web_search(query: str, agent: Agent, num_results: int = 8) -> str: """Return the results of a Google search Args: @@ -52,14 +57,18 @@ def google_search(query: str, agent: Agent, num_results: int = 8) -> str: @command( "google", "Google Search", - '"query": ""', + { + "query": { + "type": "string", + "description": "The search query", + "required": True, + } + }, lambda config: bool(config.google_api_key) and bool(config.google_custom_search_engine_id), "Configure google_api_key and custom_search_engine_id.", ) -def google_official_search( - query: str, agent: Agent, num_results: int = 8 -) -> str | list[str]: +def google(query: str, agent: Agent, num_results: int = 8) -> str | list[str]: """Return the results of a Google search using the official Google API Args: diff --git a/autogpt/commands/web_selenium.py b/autogpt/commands/web_selenium.py index bdc5e61375..718cde716e 100644 --- a/autogpt/commands/web_selenium.py +++ b/autogpt/commands/web_selenium.py @@ -42,7 +42,14 @@ FILE_DIR = Path(__file__).parent.parent @command( "browse_website", "Browse Website", - '"url": "", "question": ""', + { + "url": {"type": "string", "description": "The URL to visit", "required": True}, + "question": { + "type": "string", + "description": "What you want to find on the website", + "required": True, + }, + }, ) @validate_url def browse_website(url: str, question: str, agent: Agent) -> str: diff --git a/autogpt/main.py b/autogpt/main.py index 3b980ab220..f0af9b5346 100644 --- a/autogpt/main.py +++ b/autogpt/main.py @@ -25,9 +25,7 @@ from scripts.install_plugin_deps import install_plugin_dependencies COMMAND_CATEGORIES = [ "autogpt.commands.execute_code", "autogpt.commands.file_operations", - "autogpt.commands.git_operations", - "autogpt.commands.google_search", - "autogpt.commands.image_gen", + "autogpt.commands.web_search", "autogpt.commands.web_selenium", "autogpt.app", "autogpt.commands.task_statuses", diff --git a/autogpt/models/command.py b/autogpt/models/command.py index a925ca0436..f88bbcae60 100644 --- a/autogpt/models/command.py +++ b/autogpt/models/command.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, Optional +from typing import Any, Callable, Dict, Optional from autogpt.config import Config @@ -17,7 +17,7 @@ class Command: name: str, description: str, method: Callable[..., Any], - signature: str = "", + signature: Dict[str, Dict[str, Any]], enabled: bool | Callable[[Config], bool] = True, disabled_reason: Optional[str] = None, ): diff --git a/autogpt/plugins/__init__.py b/autogpt/plugins/__init__.py index 6002235237..4d84c9b5e8 100644 --- a/autogpt/plugins/__init__.py +++ b/autogpt/plugins/__init__.py @@ -254,11 +254,6 @@ def scan_plugins(cfg: Config, debug: bool = False) -> List[AutoGPTPluginTemplate logger.debug(f"Plugin: {plugin} Module: {module}") zipped_package = zipimporter(str(plugin)) zipped_module = zipped_package.load_module(str(module.parent)) - plugin_module_name = zipped_module.__name__.split(os.path.sep)[-1] - - if not plugins_config.is_enabled(plugin_module_name): - logger.warn(f"Plugin {plugin_module_name} found but not configured") - continue for key in dir(zipped_module): if key.startswith("__"): @@ -269,7 +264,26 @@ def scan_plugins(cfg: Config, debug: bool = False) -> List[AutoGPTPluginTemplate "_abc_impl" in a_keys and a_module.__name__ != "AutoGPTPluginTemplate" ): - loaded_plugins.append(a_module()) + plugin_name = a_module.__name__ + plugin_configured = plugins_config.get(plugin_name) is not None + plugin_enabled = plugins_config.is_enabled(plugin_name) + + if plugin_configured and plugin_enabled: + logger.debug( + f"Loading plugin {plugin_name} as it was enabled in config." + ) + loaded_plugins.append(a_module()) + elif plugin_configured and not plugin_enabled: + logger.debug( + f"Not loading plugin {plugin_name} as it was disabled in config." + ) + elif not plugin_configured: + logger.warn( + f"Not loading plugin {plugin_name} as it was not found in config. " + f"Please check your config. Starting with 0.4.1, plugins will not be loaded unless " + f"they are enabled in plugins_config.yaml. Zipped plugins should use the class " + f"name ({plugin_name}) as the key." + ) # OpenAI plugins if cfg.plugins_openai: diff --git a/docs/usage.md b/docs/usage.md index 93dfd25f49..2e88298c8f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -104,5 +104,5 @@ If you want to selectively disable some command groups, you can use the `DISABLE For example, to disable coding related features, set it to the value below: ```ini -DISABLED_COMMAND_CATEGORIES=autogpt.commands.execute_code,autogpt.commands.git_operations,autogpt.commands.improve_code,autogpt.commands.write_tests +DISABLED_COMMAND_CATEGORIES=autogpt.commands.execute_code ``` diff --git a/tests/Auto-GPT-test-cassettes b/tests/Auto-GPT-test-cassettes index 427de6721c..e6033baadc 160000 --- a/tests/Auto-GPT-test-cassettes +++ b/tests/Auto-GPT-test-cassettes @@ -1 +1 @@ -Subproject commit 427de6721cb5209a7a34359a81b71d60e80a110a +Subproject commit e6033baadcdd6b6fcf5b029670e70113422c0c30 diff --git a/tests/mocks/mock_commands.py b/tests/mocks/mock_commands.py index 7b16f1d150..278894c4d0 100644 --- a/tests/mocks/mock_commands.py +++ b/tests/mocks/mock_commands.py @@ -2,7 +2,12 @@ from autogpt.command_decorator import command @command( - "function_based", "Function-based test command", "(arg1: int, arg2: str) -> str" + "function_based", + "Function-based test command", + { + "arg1": {"type": "int", "description": "arg 1", "required": True}, + "arg2": {"type": "str", "description": "arg 2", "required": True}, + }, ) def function_based(arg1: int, arg2: str) -> str: """A function-based test command that returns a string with the two arguments separated by a dash.""" diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index f02cb620e4..cb3f539ace 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -41,6 +41,13 @@ class TestCommand: name="example", description="Example command", method=self.example_command_method, + signature={ + "prompt": { + "type": "string", + "description": "The prompt used to generate the image", + "required": True, + }, + }, ) result = cmd(arg1=1, arg2="test") assert result == "1 - test" diff --git a/tests/unit/test_file_operations.py b/tests/unit/test_file_operations.py index 27af937374..5761e01a41 100644 --- a/tests/unit/test_file_operations.py +++ b/tests/unit/test_file_operations.py @@ -15,7 +15,6 @@ import autogpt.commands.file_operations as file_ops from autogpt.agent.agent import Agent from autogpt.memory.vector.memory_item import MemoryItem from autogpt.memory.vector.utils import Embedding -from autogpt.utils import readable_file_size from autogpt.workspace import Workspace @@ -243,53 +242,6 @@ def test_write_file_succeeds_if_content_different( assert result == "File written to successfully." -# Update file testing -def test_replace_in_file_all_occurrences(test_file, test_file_path, agent: Agent): - old_content = "This is a test file.\n we test file here\na test is needed" - expected_content = ( - "This is a update file.\n we update file here\na update is needed" - ) - test_file.write(old_content) - test_file.close() - file_ops.replace_in_file(test_file_path, "test", "update", agent=agent) - with open(test_file_path) as f: - new_content = f.read() - print(new_content) - print(expected_content) - assert new_content == expected_content - - -def test_replace_in_file_one_occurrence(test_file, test_file_path, agent: Agent): - old_content = "This is a test file.\n we test file here\na test is needed" - expected_content = "This is a test file.\n we update file here\na test is needed" - test_file.write(old_content) - test_file.close() - file_ops.replace_in_file( - test_file_path, "test", "update", agent=agent, occurrence_index=1 - ) - with open(test_file_path) as f: - new_content = f.read() - - assert new_content == expected_content - - -def test_replace_in_file_multiline_old_text(test_file, test_file_path, agent: Agent): - old_content = "This is a multi_line\ntest for testing\nhow well this function\nworks when the input\nis multi-lined" - expected_content = "This is a multi_line\nfile. succeeded test\nis multi-lined" - test_file.write(old_content) - test_file.close() - file_ops.replace_in_file( - test_file_path, - "\ntest for testing\nhow well this function\nworks when the input\n", - "\nfile. succeeded test\n", - agent=agent, - ) - with open(test_file_path) as f: - new_content = f.read() - - assert new_content == expected_content - - def test_append_to_file(test_nested_file: Path, agent: Agent): append_text = "This is appended text.\n" file_ops.write_to_file(test_nested_file, append_text, agent=agent) @@ -373,26 +325,3 @@ def test_list_files(workspace: Workspace, test_directory: Path, agent: Agent): non_existent_file = "non_existent_file.txt" files = file_ops.list_files("", agent=agent) assert non_existent_file not in files - - -def test_download_file(workspace: Workspace, agent: Agent): - url = "https://github.com/Significant-Gravitas/Auto-GPT/archive/refs/tags/v0.2.2.tar.gz" - local_name = workspace.get_path("auto-gpt.tar.gz") - size = 365023 - readable_size = readable_file_size(size) - assert ( - file_ops.download_file(url, local_name, agent=agent) - == f'Successfully downloaded and locally stored file: "{local_name}"! (Size: {readable_size})' - ) - assert os.path.isfile(local_name) is True - assert os.path.getsize(local_name) == size - - url = "https://github.com/Significant-Gravitas/Auto-GPT/archive/refs/tags/v0.0.0.tar.gz" - assert "Got an HTTP Error whilst trying to download file" in file_ops.download_file( - url, local_name, agent=agent - ) - - url = "https://thiswebsiteiswrong.hmm/v0.0.0.tar.gz" - assert "Failed to establish a new connection:" in file_ops.download_file( - url, local_name, agent=agent - ) diff --git a/tests/unit/test_plugins.py b/tests/unit/test_plugins.py index 3a6f6d7003..80aa1b9dd3 100644 --- a/tests/unit/test_plugins.py +++ b/tests/unit/test_plugins.py @@ -30,8 +30,8 @@ def test_scan_plugins_generic(config: Config): plugins_config.plugins["auto_gpt_guanaco"] = PluginConfig( name="auto_gpt_guanaco", enabled=True ) - plugins_config.plugins["auto_gpt_vicuna"] = PluginConfig( - name="auto_gptp_vicuna", enabled=True + plugins_config.plugins["AutoGPTPVicuna"] = PluginConfig( + name="AutoGPTPVicuna", enabled=True ) result = scan_plugins(config, debug=True) plugin_class_names = [plugin.__class__.__name__ for plugin in result] diff --git a/tests/unit/test_google_search.py b/tests/unit/test_web_search.py similarity index 88% rename from tests/unit/test_google_search.py rename to tests/unit/test_web_search.py index 3f039fdb4a..4f5143069b 100644 --- a/tests/unit/test_google_search.py +++ b/tests/unit/test_web_search.py @@ -4,11 +4,7 @@ import pytest from googleapiclient.errors import HttpError from autogpt.agent.agent import Agent -from autogpt.commands.google_search import ( - google_official_search, - google_search, - safe_google_results, -) +from autogpt.commands.web_search import google, safe_google_results, web_search @pytest.mark.parametrize( @@ -45,8 +41,8 @@ def test_google_search( mock_ddg = mocker.Mock() mock_ddg.return_value = return_value - mocker.patch("autogpt.commands.google_search.DDGS.text", mock_ddg) - actual_output = google_search(query, agent=agent, num_results=num_results) + mocker.patch("autogpt.commands.web_search.DDGS.text", mock_ddg) + actual_output = web_search(query, agent=agent, num_results=num_results) expected_output = safe_google_results(expected_output) assert actual_output == expected_output @@ -88,7 +84,7 @@ def test_google_official_search( agent: Agent, ): mock_googleapiclient.return_value = search_results - actual_output = google_official_search(query, agent=agent, num_results=num_results) + actual_output = google(query, agent=agent, num_results=num_results) assert actual_output == safe_google_results(expected_output) @@ -136,5 +132,5 @@ def test_google_official_search_errors( ) mock_googleapiclient.side_effect = error - actual_output = google_official_search(query, agent=agent, num_results=num_results) + actual_output = google(query, agent=agent, num_results=num_results) assert actual_output == safe_google_results(expected_output)