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:
parent
d90101ea70
commit
6a2f37edea
10 changed files with 116 additions and 93 deletions
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
::: arcade.foo
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue