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
- 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

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -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.
"""

View file

@ -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:

View file

@ -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."""

View file

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

View file

@ -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"

View file

@ -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