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
This commit is contained in:
Sam Partee 2024-08-19 16:17:38 -07:00 committed by GitHub
parent d90101ea70
commit 6a2f37edea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 116 additions and 93 deletions

View file

@ -55,7 +55,7 @@ jobs:
python -m pip install tox tox-gh-actions python -m pip install tox tox-gh-actions
- name: Test with tox - name: Test with tox
run: tox run: cd arcade && tox
- name: Upload coverage reports to Codecov with GitHub Action on Python 3.10 - name: Upload coverage reports to Codecov with GitHub Action on Python 3.10
uses: codecov/codecov-action@v4.0.1 uses: codecov/codecov-action@v4.0.1

View file

@ -45,7 +45,7 @@ docs-test: ## Test if documentation can be built without warnings or errors
.PHONY: docs .PHONY: docs
docs: ## Build and serve the documentation docs: ## Build and serve the documentation
@poetry run mkdocs serve @poetry run mkdocs serve -a localhost:8777
.PHONY: help .PHONY: help
help: help:

View file

@ -8,10 +8,12 @@ from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.markup import escape from rich.markup import escape
from rich.table import Table from rich.table import Table
from rich.text import Text
from typer.core import TyperGroup from typer.core import TyperGroup
from typer.models import Context from typer.models import Context
from arcade.core.catalog import ToolCatalog from arcade.core.catalog import ToolCatalog
from arcade.core.client import EngineClient
from arcade.core.config import Config from arcade.core.config import Config
from arcade.core.schema import ToolContext from arcade.core.schema import ToolContext
from arcade.core.toolkit import Toolkit from arcade.core.toolkit import Toolkit
@ -38,7 +40,7 @@ def login(
Logs the user into Arcade Cloud. Logs the user into Arcade Cloud.
""" """
# Here you would add the logic to authenticate the user with 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") @cli.command(help="Log out of Arcade Cloud")
@ -47,7 +49,7 @@ def logout() -> None:
Logs the user out of Arcade Cloud. Logs the user out of Arcade Cloud.
""" """
# Here you would add the logic to log 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") @cli.command(help="Create a new toolkit package directory")
@ -71,7 +73,6 @@ def show(
toolkit: Optional[str] = typer.Option( toolkit: Optional[str] = typer.Option(
None, "-t", "--toolkit", help="The toolkit to show the tools of" 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"), actor: Optional[str] = typer.Option(None, help="A running actor address to list tools from"),
) -> None: ) -> None:
""" """
@ -79,7 +80,7 @@ def show(
""" """
try: try:
catalog = create_cli_catalog(toolkit, all_toolkits) catalog = create_cli_catalog(toolkit=toolkit)
# Create a table with Rich library # Create a table with Rich library
table = Table(show_header=True, header_style="bold magenta") table = Table(show_header=True, header_style="bold magenta")
@ -104,9 +105,6 @@ def run(
toolkit: Optional[str] = typer.Option( toolkit: Optional[str] = typer.Option(
None, "-t", "--toolkit", help="The toolkit to include in the run" 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."), 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."), tool: str = typer.Option(None, "--tool", help="The name of the tool to run."),
choice: str = typer.Option( choice: str = typer.Option(
@ -115,7 +113,6 @@ def run(
stream: bool = typer.Option( stream: bool = typer.Option(
False, "-s", "--stream", is_flag=True, help="Stream the tool output." 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"), prompt: str = typer.Argument(..., help="The prompt to use for context"),
) -> None: ) -> None:
""" """
@ -125,30 +122,25 @@ def run(
from arcade.core.executor import ToolExecutor from arcade.core.executor import ToolExecutor
try: try:
catalog = create_cli_catalog(toolkit=toolkit, all_toolkits=all_toolkits) catalog = create_cli_catalog(toolkit=toolkit)
# if user specified a tool tools = [catalog[tool]] if tool else list(catalog)
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)
# TODO put in the engine url from config # allow user to specify the engine url?
client = EngineClient() client = EngineClient()
# TODO better way of doing this # 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) 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 = [ messages = [
{"role": "user", "content": prompt}, {"role": "user", "content": prompt},
] ]
for tool_name, parameters in calls.items():
for tool_name, parameters in calls:
called_tool = catalog[tool_name] called_tool = catalog[tool_name]
console.print(f"Calling tool: {tool_name} with params: {parameters}", style="bold blue") console.print(f"Calling tool: {tool_name} with params: {parameters}", style="bold blue")
@ -177,15 +169,19 @@ def run(
}, },
] ]
if choice == "execute": if choice == "execute":
console.print(output.data.result, style="green") # type: ignore[union-attr] console.print(output.data.result, style="green") # type: ignore[union-attr]
raise typer.Exit(0)
if stream:
stream_response = client.stream_complete(model=model, messages=messages)
display_streamed_markdown(stream_response)
else: else:
response = client.complete(model=model, messages=messages) if stream:
console.print(response.choices[0].message.content, style="bold green") 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: except RuntimeError as e:
error_message = f"❌ Failed to run tool{': ' + escape(str(e)) if str(e) else ''}" 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") @cli.command(help="Chat with a language model")
def chat( def chat(
model: str = typer.Option("gpt-4o-mini", "-m", help="The model to use for prediction."), model: str = typer.Option("gpt-4o", "-m", help="The model to use for prediction."),
choice: str = typer.Option(
None, "-c", "--choice", help="The value of the tool choice argument"
),
stream: bool = typer.Option( stream: bool = typer.Option(
False, "-s", "--stream", is_flag=True, help="Stream the tool output." False, "-s", "--stream", is_flag=True, help="Stream the tool output."
), ),
prompt: str = typer.Argument(..., help="The prompt to use for context"),
) -> None: ) -> 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: try:
messages = [ # start messages conversation
{"role": "user", "content": prompt}, messages = []
]
if stream: chat_header = Text.assemble(
stream_response = client.stream_complete(model=model, messages=messages) "\n",
display_streamed_markdown(stream_response) (
else: "======== Arcade AI Chat ========",
response = client.complete(model=model, messages=messages) "bold magenta underline",
console.print(response.choices[0].message.content, style="bold green") ),
"\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: except RuntimeError as e:
error_message = f"❌ Failed to run tool{': ' + escape(str(e)) if str(e) else ''}" error_message = f"❌ Failed to run tool{': ' + escape(str(e)) if str(e) else ''}"
console.print(error_message, style="bold red") console.print(error_message, style="bold red")
raise typer.Exit()
@cli.command(help="Start an Actor server with specified configurations.") @cli.command(help="Start an Actor server with specified configurations.")
@ -259,7 +284,7 @@ def engine(
""" """
Manage the Arcade Engine (start/stop/restart) 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") @cli.command(help="Manage credientials stored in the Arcade Engine")
@ -271,7 +296,7 @@ def credentials(
""" """
Manage credientials stored in the Arcade Engine 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") @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( def create_cli_catalog(
toolkit: str | None = None, toolkit: str | None = None,
all_toolkits: bool = False, show_toolkits: bool = False,
) -> ToolCatalog: ) -> ToolCatalog:
""" """
Load toolkits from the python environment. Load toolkits from the python environment.
""" """
if toolkit:
if all_toolkits: try:
toolkits = Toolkit.find_all_arcade_toolkits() prefixed_toolkit = "arcade_" + toolkit
if not toolkits: toolkits = [Toolkit.from_package(prefixed_toolkit)]
console.print("No toolkits found in Python environment.", style="bold red") except ValueError:
raise typer.Exit(code=1) 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: else:
if not toolkit: toolkits = Toolkit.find_all_arcade_toolkits()
console.print("No toolkit specified and '-a' not supplied.", style="bold red")
raise typer.Exit(code=1) if not toolkits:
else: console.print("❌ No toolkits found or specified", style="bold red")
# load the toolkit from python package typer.Exit(code=1)
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)
catalog = ToolCatalog() catalog = ToolCatalog()
for loaded_toolkit in toolkits: 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) catalog.add_toolkit(loaded_toolkit)
return catalog return catalog

View file

@ -133,8 +133,8 @@ class ToolCatalog(BaseModel):
raise ToolDefinitionError( raise ToolDefinitionError(
f"Could not find tool {tool_name} in module {module_name}" f"Could not find tool {tool_name} in module {module_name}"
) )
except ImportError: except ImportError as e:
raise ToolDefinitionError(f"Could not import module {module_name}") raise ToolDefinitionError(f"Could not import module {module_name}. Reason: {e}")
self.add_tool(tool_func, module, toolkit) self.add_tool(tool_func, module, toolkit)

View file

@ -99,15 +99,20 @@ def called_tool(chat_completion: ChatCompletion) -> bool:
return False 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. Returns the tool arguments from the chat completion object.
""" """
tool_args_list = {} tool_args_list = []
message = chat_completion.choices[0].message message = chat_completion.choices[0].message
if message.tool_calls: if message.tool_calls:
for tool_call in 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 return tool_args_list
@ -125,10 +130,10 @@ class EngineClient:
model: str, model: str,
messages: Optional[list[dict[str, Any]]] = None, messages: Optional[list[dict[str, Any]]] = None,
tool_choice: Optional[str] = "required", tool_choice: Optional[str] = "required",
parallel_tool_calls: Optional[bool] = False, parallel_tool_calls: Optional[bool] = True,
prompt: Optional[str] = "", prompt: Optional[str] = "",
**kwargs: Any, **kwargs: Any,
) -> ToolCalls: ) -> list[tuple[str, dict[str, Any]]]:
""" """
Infer the arguments for a given tool and call the OpenAI API. Infer the arguments for a given tool and call the OpenAI API.
""" """

View file

@ -91,7 +91,7 @@ class Config(BaseModel):
if self.engine is None: if self.engine is None:
raise ValueError("Engine not set") raise ValueError("Engine not set")
protocol = "https" if self.engine.tls else "http" 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 @classmethod
def ensure_config_dir_exists(cls) -> None: def ensure_config_dir_exists(cls) -> None:

View file

@ -149,7 +149,7 @@ class ToolCallError(BaseModel):
class ToolCallOutput(BaseModel): class ToolCallOutput(BaseModel):
"""The output of a tool invocation.""" """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.""" """The value returned by the tool."""
error: ToolCallError | None = None error: ToolCallError | None = None
"""The error that occurred during the tool invocation.""" """The error that occurred during the tool invocation."""

View file

@ -1 +0,0 @@
::: arcade.foo

View file

@ -27,8 +27,9 @@ pyjwt = "^2.8.0"
fastapi = { version = "^0.110.0", optional = true } fastapi = { version = "^0.110.0", optional = true }
flask = { version = "^3.0.3", optional = true } flask = { version = "^3.0.3", optional = true }
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pytest = "^7.2.0" pytest = "^8.1.1"
pytest-cov = "^4.0.0" pytest-cov = "^4.0.0"
mypy = "^1.5.1" mypy = "^1.5.1"
pre-commit = "^3.4.0" pre-commit = "^3.4.0"
@ -36,21 +37,18 @@ tox = "^4.11.1"
pytest-asyncio = "^0.23.7" pytest-asyncio = "^0.23.7"
types-toml = "^0.10.8" types-toml = "^0.10.8"
uvicorn = "^0.22.0" uvicorn = "^0.22.0"
mkdocs = ">=1.5.2"
mkdocs-material = ">=9.3.0"
mkdocstrings = {extras = ["python"], version = ">=0.23.1"}
[tool.poetry.extras] [tool.poetry.extras]
fastapi = ["fastapi"] fastapi = ["fastapi"]
flask = ["flask"] 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] [tool.poetry.scripts]
arcade = "arcade.cli.main:cli" arcade = "arcade.cli.main:cli"
[tool.mypy] [tool.mypy]
files = ["arcade"] files = ["arcade"]
disallow_untyped_defs = "True" disallow_untyped_defs = "True"

View file

@ -3,8 +3,8 @@ from base64 import urlsafe_b64decode
from typing import Annotated from typing import Annotated
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from google.auth.credentials import Credentials
from googleapiclient.discovery import build from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
from arcade.core.schema import ToolContext from arcade.core.schema import ToolContext
from arcade.sdk import tool from arcade.sdk import tool
@ -23,7 +23,7 @@ async def get_emails(
) -> dict[str, list[dict[str, str]]]: ) -> dict[str, list[dict[str, str]]]:
"""Read emails from a Gmail account and extract plain text content, removing any HTML.""" """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)) service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
# Request a list of all the messages # Request a list of all the messages