From 6a2f37edea4dc9682f9726a1024fa06d99578c15 Mon Sep 17 00:00:00 2001 From: Sam Partee Date: Mon, 19 Aug 2024 16:17:38 -0700 Subject: [PATCH] Introduce `arcade run` and `arcade chat` Commands (#15) Two new commands to the Arcade CLI: `arcade run` and `arcade chat`. These commands enhance the usability of the Arcade CLI by integrating language model capabilities for running tools and engaging in chat sessions. Users can now leverage LLMs directly from the command line --- .github/workflows/main.yml | 2 +- arcade/Makefile | 2 +- arcade/arcade/cli/main.py | 165 ++++++++++++--------- arcade/arcade/core/catalog.py | 4 +- arcade/arcade/core/client.py | 15 +- arcade/arcade/core/config.py | 2 +- arcade/arcade/core/schema.py | 2 +- arcade/docs/modules.md | 1 - arcade/pyproject.toml | 12 +- toolkits/gmail/arcade_gmail/tools/gmail.py | 4 +- 10 files changed, 116 insertions(+), 93 deletions(-) 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