diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0b82a7b6..4eb1c5cf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -55,7 +55,7 @@ jobs: python -m pip install tox tox-gh-actions - name: Test with tox - run: tox + run: cd arcade && tox - name: Upload coverage reports to Codecov with GitHub Action on Python 3.10 uses: codecov/codecov-action@v4.0.1 diff --git a/arcade/Makefile b/arcade/Makefile index 592f37e6..83815fa6 100644 --- a/arcade/Makefile +++ b/arcade/Makefile @@ -45,7 +45,7 @@ docs-test: ## Test if documentation can be built without warnings or errors .PHONY: docs docs: ## Build and serve the documentation - @poetry run mkdocs serve + @poetry run mkdocs serve -a localhost:8777 .PHONY: help help: diff --git a/arcade/arcade/cli/main.py b/arcade/arcade/cli/main.py index 1d2ca237..4f66080d 100644 --- a/arcade/arcade/cli/main.py +++ b/arcade/arcade/cli/main.py @@ -8,10 +8,12 @@ from rich.console import Console from rich.markdown import Markdown from rich.markup import escape from rich.table import Table +from rich.text import Text from typer.core import TyperGroup from typer.models import Context from arcade.core.catalog import ToolCatalog +from arcade.core.client import EngineClient from arcade.core.config import Config from arcade.core.schema import ToolContext from arcade.core.toolkit import Toolkit @@ -38,7 +40,7 @@ def login( Logs the user into Arcade Cloud. """ # Here you would add the logic to authenticate the user with Arcade Cloud - pass + raise NotImplementedError("This feature is not yet implemented.") @cli.command(help="Log out of Arcade Cloud") @@ -47,7 +49,7 @@ def logout() -> None: Logs the user out of Arcade Cloud. """ # Here you would add the logic to log the user out of Arcade Cloud - pass + raise NotImplementedError("This feature is not yet implemented.") @cli.command(help="Create a new toolkit package directory") @@ -71,7 +73,6 @@ def show( toolkit: Optional[str] = typer.Option( None, "-t", "--toolkit", help="The toolkit to show the tools of" ), - all_toolkits: bool = typer.Option(False, "-a", "--all", help="Show all installed toolkits"), actor: Optional[str] = typer.Option(None, help="A running actor address to list tools from"), ) -> None: """ @@ -79,7 +80,7 @@ def show( """ try: - catalog = create_cli_catalog(toolkit, all_toolkits) + catalog = create_cli_catalog(toolkit=toolkit) # Create a table with Rich library table = Table(show_header=True, header_style="bold magenta") @@ -104,9 +105,6 @@ def run( toolkit: Optional[str] = typer.Option( None, "-t", "--toolkit", help="The toolkit to include in the run" ), - all_toolkits: bool = typer.Option( - False, "-a", "--all", is_flag=True, help="Use all installed toolkits" - ), model: str = typer.Option("gpt-4o", "-m", help="The model to use for prediction."), tool: str = typer.Option(None, "--tool", help="The name of the tool to run."), choice: str = typer.Option( @@ -115,7 +113,6 @@ def run( stream: bool = typer.Option( False, "-s", "--stream", is_flag=True, help="Stream the tool output." ), - actor: Optional[str] = typer.Option(None, "--actor", help="The actor to use for prediction."), prompt: str = typer.Argument(..., help="The prompt to use for context"), ) -> None: """ @@ -125,30 +122,25 @@ def run( from arcade.core.executor import ToolExecutor try: - catalog = create_cli_catalog(toolkit=toolkit, all_toolkits=all_toolkits) + catalog = create_cli_catalog(toolkit=toolkit) - # if user specified a tool - if tool: - # check if the tool is in the catalog/toolkit - if tool not in catalog: - console.print(f"❌ Tool not found in toolkit: {toolkit}", style="bold red") - raise typer.Exit(code=1) - else: - tools = [catalog[tool]] - else: - # use all the tools in the catalog - tools = list(catalog) + tools = [catalog[tool]] if tool else list(catalog) - # TODO put in the engine url from config + # allow user to specify the engine url? client = EngineClient() + # TODO better way of doing this - tool_choice = "required" if choice in ["generate", "execute"] else choice + tool_choice = "auto" if choice in ["execute", "generate"] else choice calls = client.call_tool(tools, tool_choice=tool_choice, prompt=prompt, model=model) + if len(calls) == 0: + console.print("[bold red]No tools were called[/bold red]") + messages = [ {"role": "user", "content": prompt}, ] - for tool_name, parameters in calls.items(): + + for tool_name, parameters in calls: called_tool = catalog[tool_name] console.print(f"Calling tool: {tool_name} with params: {parameters}", style="bold blue") @@ -177,15 +169,19 @@ def run( }, ] - if choice == "execute": - console.print(output.data.result, style="green") # type: ignore[union-attr] - - if stream: - stream_response = client.stream_complete(model=model, messages=messages) - display_streamed_markdown(stream_response) + if choice == "execute": + console.print(output.data.result, style="green") # type: ignore[union-attr] + raise typer.Exit(0) else: - response = client.complete(model=model, messages=messages) - console.print(response.choices[0].message.content, style="bold green") + if stream: + stream_response = client.stream_complete(model=model, messages=messages) + display_streamed_markdown(stream_response) + else: + response = client.complete(model=model, messages=messages) + if not len(response.choices) and not response.choices[0].message.content: + console.print("No response from the tool.", style="bold red") + else: + console.print(Markdown(response.choices[0].message.content or "")) except RuntimeError as e: error_message = f"❌ Failed to run tool{': ' + escape(str(e)) if str(e) else ''}" @@ -194,36 +190,65 @@ def run( @cli.command(help="Chat with a language model") def chat( - model: str = typer.Option("gpt-4o-mini", "-m", help="The model to use for prediction."), - choice: str = typer.Option( - None, "-c", "--choice", help="The value of the tool choice argument" - ), + model: str = typer.Option("gpt-4o", "-m", help="The model to use for prediction."), stream: bool = typer.Option( False, "-s", "--stream", is_flag=True, help="Stream the tool output." ), - prompt: str = typer.Argument(..., help="The prompt to use for context"), ) -> None: """ - Run a tool using an LLM to predict the arguments. + Chat with a language model. """ - from arcade.core.client import EngineClient - client = EngineClient() + config = Config.load_from_file() + if not config.engine or not config.engine_url: + console.print("❌ Engine configuration not found or URL is missing.", style="bold red") + typer.Exit(code=1) + + client = EngineClient(base_url=config.engine_url) + try: - messages = [ - {"role": "user", "content": prompt}, - ] + # start messages conversation + messages = [] - if stream: - stream_response = client.stream_complete(model=model, messages=messages) - display_streamed_markdown(stream_response) - else: - response = client.complete(model=model, messages=messages) - console.print(response.choices[0].message.content, style="bold green") + chat_header = Text.assemble( + "\n", + ( + "======== Arcade AI Chat ========", + "bold magenta underline", + ), + "\n", + ) + console.print(chat_header) + + while True: + user_input = console.input("\n[bold magenta]User: [/bold magenta]") + messages.append({"role": "user", "content": user_input}) + + if stream: + stream_response = client.stream_complete( + model=model, messages=messages, tool_choice="generate" + ) + display_streamed_markdown(stream_response) + else: + response = client.complete(model=model, messages=messages, tool_choice="generate") + message_content = response.choices[0].message.content or "" + role = response.choices[0].message.role + + if role == "assistant": + console.print("\n[bold blue]Assistant:[/bold blue] ", Markdown(message_content)) + else: + console.print(f"\n[bold magenta]{role}:[/bold magenta] {message_content}") + + messages.append({"role": role, "content": message_content}) + + except KeyboardInterrupt: + console.print("Chat stopped by user.", style="bold blue") + typer.Exit() except RuntimeError as e: error_message = f"❌ Failed to run tool{': ' + escape(str(e)) if str(e) else ''}" console.print(error_message, style="bold red") + raise typer.Exit() @cli.command(help="Start an Actor server with specified configurations.") @@ -259,7 +284,7 @@ def engine( """ Manage the Arcade Engine (start/stop/restart) """ - pass + raise NotImplementedError("This feature is not yet implemented.") @cli.command(help="Manage credientials stored in the Arcade Engine") @@ -271,7 +296,7 @@ def credentials( """ Manage credientials stored in the Arcade Engine """ - pass + raise NotImplementedError("This feature is not yet implemented.") @cli.command(help="Show/edit configuration details of the Arcade Engine") @@ -360,35 +385,31 @@ def display_streamed_markdown(stream: Stream[ChatCompletionChunk]) -> None: def create_cli_catalog( toolkit: str | None = None, - all_toolkits: bool = False, + show_toolkits: bool = False, ) -> ToolCatalog: """ Load toolkits from the python environment. """ - - if all_toolkits: - toolkits = Toolkit.find_all_arcade_toolkits() - if not toolkits: - console.print("No toolkits found in Python environment.", style="bold red") - raise typer.Exit(code=1) + if toolkit: + try: + prefixed_toolkit = "arcade_" + toolkit + toolkits = [Toolkit.from_package(prefixed_toolkit)] + except ValueError: + try: # try without prefix + toolkits = [Toolkit.from_package(toolkit)] + except ValueError as e: + console.print(f"❌ {e}", style="bold red") + typer.Exit(code=1) else: - if not toolkit: - console.print("No toolkit specified and '-a' not supplied.", style="bold red") - raise typer.Exit(code=1) - else: - # load the toolkit from python package - try: - prefixed_toolkit = "arcade_" + toolkit - toolkits = [Toolkit.from_package(prefixed_toolkit)] - except ValueError: - try: # try without prefix - toolkits = [Toolkit.from_package(toolkit)] - except ValueError as e: - console.print(f"❌ {e}", style="bold red") - raise typer.Exit(code=1) + toolkits = Toolkit.find_all_arcade_toolkits() + + if not toolkits: + console.print("❌ No toolkits found or specified", style="bold red") + typer.Exit(code=1) catalog = ToolCatalog() for loaded_toolkit in toolkits: - console.print(f"Loading toolkit: {loaded_toolkit.name}", style="bold blue") + if show_toolkits: + console.print(f"Loading toolkit: {loaded_toolkit.name}", style="bold blue") catalog.add_toolkit(loaded_toolkit) return catalog diff --git a/arcade/arcade/core/catalog.py b/arcade/arcade/core/catalog.py index 37f3858a..7a6f3795 100644 --- a/arcade/arcade/core/catalog.py +++ b/arcade/arcade/core/catalog.py @@ -133,8 +133,8 @@ class ToolCatalog(BaseModel): raise ToolDefinitionError( f"Could not find tool {tool_name} in module {module_name}" ) - except ImportError: - raise ToolDefinitionError(f"Could not import module {module_name}") + except ImportError as e: + raise ToolDefinitionError(f"Could not import module {module_name}. Reason: {e}") self.add_tool(tool_func, module, toolkit) diff --git a/arcade/arcade/core/client.py b/arcade/arcade/core/client.py index 9a8f9e37..9c30e3ed 100644 --- a/arcade/arcade/core/client.py +++ b/arcade/arcade/core/client.py @@ -99,15 +99,20 @@ def called_tool(chat_completion: ChatCompletion) -> bool: return False -def get_tool_args(chat_completion: ChatCompletion) -> ToolCalls: +def get_tool_args(chat_completion: ChatCompletion) -> list[tuple[str, dict[str, Any]]]: """ Returns the tool arguments from the chat completion object. """ - tool_args_list = {} + tool_args_list = [] message = chat_completion.choices[0].message if message.tool_calls: for tool_call in message.tool_calls: - tool_args_list[tool_call.function.name] = json.loads(tool_call.function.arguments) + tool_args_list.append( + ( + tool_call.function.name, + json.loads(tool_call.function.arguments), + ) + ) return tool_args_list @@ -125,10 +130,10 @@ class EngineClient: model: str, messages: Optional[list[dict[str, Any]]] = None, tool_choice: Optional[str] = "required", - parallel_tool_calls: Optional[bool] = False, + parallel_tool_calls: Optional[bool] = True, prompt: Optional[str] = "", **kwargs: Any, - ) -> ToolCalls: + ) -> list[tuple[str, dict[str, Any]]]: """ Infer the arguments for a given tool and call the OpenAI API. """ diff --git a/arcade/arcade/core/config.py b/arcade/arcade/core/config.py index 5139a6c9..d2ee4712 100644 --- a/arcade/arcade/core/config.py +++ b/arcade/arcade/core/config.py @@ -91,7 +91,7 @@ class Config(BaseModel): if self.engine is None: raise ValueError("Engine not set") protocol = "https" if self.engine.tls else "http" - return f"{protocol}://{self.engine.host}:{self.engine.port}" + return f"{protocol}://{self.engine.host}:{self.engine.port}/v1" @classmethod def ensure_config_dir_exists(cls) -> None: diff --git a/arcade/arcade/core/schema.py b/arcade/arcade/core/schema.py index 259c9863..5c0c3ab7 100644 --- a/arcade/arcade/core/schema.py +++ b/arcade/arcade/core/schema.py @@ -149,7 +149,7 @@ class ToolCallError(BaseModel): class ToolCallOutput(BaseModel): """The output of a tool invocation.""" - value: Union[str, int, float, bool, dict] | None = None + value: Union[str, int, float, bool, dict, list[str]] | None = None """The value returned by the tool.""" error: ToolCallError | None = None """The error that occurred during the tool invocation.""" diff --git a/arcade/docs/modules.md b/arcade/docs/modules.md index 6f3b6b4a..e69de29b 100644 --- a/arcade/docs/modules.md +++ b/arcade/docs/modules.md @@ -1 +0,0 @@ -::: arcade.foo diff --git a/arcade/pyproject.toml b/arcade/pyproject.toml index 72fa0461..c7b5f7d3 100644 --- a/arcade/pyproject.toml +++ b/arcade/pyproject.toml @@ -27,8 +27,9 @@ pyjwt = "^2.8.0" fastapi = { version = "^0.110.0", optional = true } flask = { version = "^3.0.3", optional = true } + [tool.poetry.group.dev.dependencies] -pytest = "^7.2.0" +pytest = "^8.1.1" pytest-cov = "^4.0.0" mypy = "^1.5.1" pre-commit = "^3.4.0" @@ -36,21 +37,18 @@ tox = "^4.11.1" pytest-asyncio = "^0.23.7" types-toml = "^0.10.8" uvicorn = "^0.22.0" +mkdocs = ">=1.5.2" +mkdocs-material = ">=9.3.0" +mkdocstrings = {extras = ["python"], version = ">=0.23.1"} [tool.poetry.extras] fastapi = ["fastapi"] flask = ["flask"] -[tool.poetry.group.docs.dependencies] -mkdocs = "^1.4.2" -mkdocs-material = "^9.2.7" -mkdocstrings = {extras = ["python"], version = "^0.23.0"} - [tool.poetry.scripts] arcade = "arcade.cli.main:cli" - [tool.mypy] files = ["arcade"] disallow_untyped_defs = "True" diff --git a/toolkits/gmail/arcade_gmail/tools/gmail.py b/toolkits/gmail/arcade_gmail/tools/gmail.py index a71a4ff5..a52697f7 100644 --- a/toolkits/gmail/arcade_gmail/tools/gmail.py +++ b/toolkits/gmail/arcade_gmail/tools/gmail.py @@ -3,8 +3,8 @@ from base64 import urlsafe_b64decode from typing import Annotated from bs4 import BeautifulSoup +from google.auth.credentials import Credentials from googleapiclient.discovery import build -from google.oauth2.credentials import Credentials from arcade.core.schema import ToolContext from arcade.sdk import tool @@ -23,7 +23,7 @@ async def get_emails( ) -> dict[str, list[dict[str, str]]]: """Read emails from a Gmail account and extract plain text content, removing any HTML.""" - # Call the Gmail API + # Build the Gmail service using the provided OAuth2 token service = build("gmail", "v1", credentials=Credentials(context.authorization.token)) # Request a list of all the messages