diff --git a/docs/assets/images/docs/cli/hero.png b/docs/assets/images/docs/cli/hero.png new file mode 100644 index 000000000..2049858ab Binary files /dev/null and b/docs/assets/images/docs/cli/hero.png differ diff --git a/docs/assets/images/docs/cli/tools.png b/docs/assets/images/docs/cli/tools.png new file mode 100644 index 000000000..861eee0ac Binary files /dev/null and b/docs/assets/images/docs/cli/tools.png differ diff --git a/docs/docs/interactive/cli.md b/docs/docs/interactive/cli.md new file mode 100644 index 000000000..1766ef53c --- /dev/null +++ b/docs/docs/interactive/cli.md @@ -0,0 +1,169 @@ +# CLI + +Marvin includes a CLI for quickly invoking an AI assistant. + +![](/assets/images/docs/cli/hero.png) + +To use the CLI, simply `say` something to Marvin: + +```bash +marvin say "hi" +``` + +You can control the [thread](#threads) and [assistant](#custom-assistants) you're talking to, as well as the LLM model used for generating responses. + +## Models + +By default, the CLI uses whatever model the assistant is configured to use. However, you can override this on a per-message basis using the `--model` or `-m` flag. For example, to use the `gpt-3.5-turbo` model for a single message: + +```bash +marvin say "hi" -m "gpt-3.5-turbo" +``` + +## Tools + +By default, the CLI assistant has the following tools: + +- The OpenAI code interpreter, which allows you to write and execute Python code in a sandbox environment +- A tool that can fetch the content of a URL +- Tools for read-only access to the user's filesystem (such as listing and reading files) + +To learn more about tools, see the [tools documentation](/docs/interactive/assistants/#tools). + +![](/assets/images/docs/cli/tools.png) + +## Threads + +The CLI assistant automatically remembers the history of your conversation. You can use threads to create multiple simultaneous conversations. To learn more about threads, see the [threads documentation](/docs/interactive/assistants/#threads). + +### Changing threads + +By default, the CLI assistant uses a global default thread. For example, this posts two messages to the global default thread: + +```bash +marvin say "Hello!" +marvin say "How are you?" +``` + +To change the thread on a per-message basis, use the `--thread` or `-t` flag and provide a thread name. Thread names are arbitrary and can be any string; it's a way for you to group conversations together. + +This posts one message to a thread called "my-thread" and another to a thread called "my-other-thread": + +```bash +marvin say "Hello!" -t my-thread +marvin say "How are you?" -t my-other-thread +``` + +To change the default thread for multiple messages, use the `MARVIN_CLI_THREAD` environment variable. You can do this globally or `export` it in your shell for a single session. For example, this sets the default thread to "my-thread": + +```bash +export MARVIN_CLI_THREAD=my-thread + +marvin say "Hello!" +marvin say "How are you?" +``` + +### Clearing threads + +To reset a thread and clear its history, use the `clear` command. For example, this clears the default thread: + +```bash +marvin thread clear +``` + +And this clears a thread called "my-thread": + +```bash +marvin thread clear -t my-thread +``` + + +### Current thread + +To see the current thread (and corresponding OpenAI thread ID), use the `current` command. For example: + +```bash +marvin thread current +``` + +## Custom assistants + +The Marvin CLI allows you to register and use custom assistants in addition to the default assistant. Custom assistants are defined in Python files and can have their own set of instructions, tools, and behaviors. + +Using a custom assistant has the following workflow: + +1. Define the assistant in a Python file +2. Register the assistant with the Marvin CLI +3. Use the assistant in the CLI + +### Defining an assistant + +To use a custom assistant, you must define it in a Python file. In a new file, create an instance of the `Assistant` class from the `marvin.beta.assistants`. Provide it with any desired options, such as a name, instructions, and tools. To learn more about creating assistants, see the [assistants documentation](/docs/interactive/assistants/). The only requirement is that the assistant object must be assigned to a global variable in the file so that the CLI can load it. + +For example, this file defines an assistant named "Arthur" that can use the code interpreter. The assistant is stored under the variable `my_assistant`. + +```python +# path/to/custom_assistant.py + +from marvin.beta.assistants import Assistant, CodeInterpreter + +my_assistant = Assistant( + name="Arthur", + instructions="A parody of Arthur Dent", + tools=[CodeInterpreter] +) +``` + +### Registering an assistant + +Once you've created a Python file that defines an assistant, you can register it with the Marvin CLI. This allows you to use the assistant in the CLI by name. + +To do so, use the `marvin assistant register` command followed by the fully-qualified path to the Python file *and* the variable that contains the assistant. For example, to register the assistant defined in the previous step, use the following command: + +```bash +marvin assistant register path/to/custom_assistant.py:my_assistant +``` + +This command will automatically use the assistant's name (Arthur) as the name of the assistant in the Marvin CLI registry. You will need to provide the name to load the assistant, which is why each registered assistant must have a unique name. Registering an assistant with the same name as an existing one will fail. In this case, you can either delete the existing assistant or use the `--overwrite` or `-o` flag to overwrite it. You can also provide an alternative name during registration using the `--name` or `-n` flag. For example, this would register the assistant with the name "My Custom Assistant": + +```bash +marvin assistant register path/to/custom_assistant.py:my_assistant -n "My Custom Assistant" +``` + + +!!! warning + When you register an assistant, its name and the path to the file that contains it are stored in the Marvin CLI registry. This allows the CLI to load the assistant whenever you need it. However, it means the assistant file **must** remain in the same location, with the same name, for the CLI to find it. If you move or rename the file, you will need to re-register the assistant. However, if you edit the file without changing the variable name of the assistant, the CLI will automatically use the updated assistant. + + + +### Using an assistant + +To use a custom assistant when sending a message, use the `--assistant` or `-a` flag followed by the name of the registered assistant. For example, if you registered an assistant named "Arthur", you can talk to it like this: + +```bash +marvin say "Hello!" -a "Arthur" +``` + +You can also set a default assistant using the MARVIN_CLI_ASSISTANT environment variable, similar to setting a default thread. This allows you to set a global or session-specific default assistant. + +#### Mixing threads and assistants + +Threads and assistants are independent, so you can talk to multiple assistants in the same thread. Note that due to limitations in the OpenAI API, assistants aren't aware of other assistants, so they assume that they said everything in the thread history (even if another assistant did). + +```bash +marvin say "Hello!" -a "Arthur" -t "marvin-thread" +``` + +### Listing registered assistants + +To see a list of all registered assistants, use the `marvin assistant list` command. This will display a table with the names and file paths of the registered assistants. + +### Deleting a registered assistant + +To remove a registered assistant, use the `marvin assistant delete` command followed by the name of the assistant. For example: + +```bash +marvin assistant delete "My Custom Assistant" +``` + +Note that this only removes the reference to the assistant in the Marvin registry and does not delete the actual assistant file, even if you used the `--copy` flag during registration. diff --git a/mkdocs.yml b/mkdocs.yml index 4db95ca60..3396f63d0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ nav: - Interactive Tools: - Assistants: docs/interactive/assistants.md + - CLI: docs/interactive/cli.md - Applications: docs/interactive/applications.md - Configuration: diff --git a/src/marvin/beta/assistants/assistants.py b/src/marvin/beta/assistants/assistants.py index fc0b6931e..d0f296bf3 100644 --- a/src/marvin/beta/assistants/assistants.py +++ b/src/marvin/beta/assistants/assistants.py @@ -42,9 +42,9 @@ class Assistant(BaseModel, ExposeSyncMethodsMixin): instructions (list): List of instructions for the assistant. """ - model_config = dict(extra="forbid") id: Optional[str] = None name: str = "Assistant" + description: Optional[str] = None model: str = Field(None, validate_default=True) instructions: Optional[str] = Field(None, repr=False) tools: list[Union[AssistantTool, Callable]] = [] diff --git a/src/marvin/beta/assistants/runs.py b/src/marvin/beta/assistants/runs.py index ee4ff3bf9..a2d858cdb 100644 --- a/src/marvin/beta/assistants/runs.py +++ b/src/marvin/beta/assistants/runs.py @@ -27,6 +27,7 @@ class Run(BaseModel, ExposeSyncMethodsMixin): Attributes: thread (Thread): The thread in which the run is executed. assistant (Assistant): The assistant that is being run. + model (str, optional): The model used by the assistant. instructions (str, optional): Replacement instructions for the run. additional_instructions (str, optional): Additional instructions to append to the assistant's instructions. diff --git a/src/marvin/cli/__init__.py b/src/marvin/cli/__init__.py index ab7d27d18..ba58db11a 100644 --- a/src/marvin/cli/__init__.py +++ b/src/marvin/cli/__init__.py @@ -6,51 +6,31 @@ from marvin.types import StreamingChatResponse from marvin.utilities.asyncio import run_sync from marvin.utilities.openai import get_openai_client -from marvin.cli.version import display_version +from marvin.beta.assistants import Assistant +from marvin.cli.threads import threads_app +from marvin.cli.assistants import assistants_app, say as assistants_say -app = typer.Typer() -console = Console() +import platform + +from typer import Context, Exit, echo -app.command(name="version")(display_version) +from marvin import __version__ + +app = typer.Typer(no_args_is_help=True) +console = Console() +app.add_typer(threads_app, name="thread") +app.add_typer(assistants_app, name="assistant") +app.command(name="say")(assistants_say) -@app.callback(invoke_without_command=True) -def main( - ctx: typer.Context, - model: Optional[str] = typer.Option("gpt-3.5-turbo"), - max_tokens: Optional[int] = typer.Option(1000), -): - if ctx.invoked_subcommand is not None: +@app.command() +def version(ctx: Context): + if ctx.resilient_parsing: return - elif ctx.invoked_subcommand is None and not sys.stdin.isatty(): - run_sync(stdin_chat(model, max_tokens)) - else: - console.print(ctx.get_help()) - - -async def stdin_chat(model: str, max_tokens: int): - client = get_openai_client() - content = sys.stdin.read() - - client = AsyncMarvinClient() - await client.generate_chat( - model=model, - messages=[{"role": "user", "content": content}], - max_tokens=max_tokens, - stream=True, - stream_callback=print_chunk, - ) - - -def print_chunk(streaming_response: StreamingChatResponse): - last_chunk_flag = False - text_chunk = streaming_response.chunk.choices[0].delta.content or "" - if text_chunk: - if last_chunk_flag and text_chunk.startswith(" "): - text_chunk = text_chunk[1:] - sys.stdout.write(text_chunk) - sys.stdout.flush() - last_chunk_flag = text_chunk.endswith(" ") + echo(f"Version:\t\t{__version__}") + echo(f"Python version:\t\t{platform.python_version()}") + echo(f"OS/Arch:\t\t{platform.system().lower()}/{platform.machine().lower()}") + raise Exit() if __name__ == "__main__": diff --git a/src/marvin/cli/assistants.py b/src/marvin/cli/assistants.py new file mode 100644 index 000000000..ca7324fb8 --- /dev/null +++ b/src/marvin/cli/assistants.py @@ -0,0 +1,225 @@ +import importlib +import platform +from pathlib import Path +from typing import Optional, Union + +import httpx +import typer +from pydantic import BaseModel, ValidationError +from rich.console import Console +from rich.table import Table + +from marvin.beta.assistants import Assistant, Thread +from marvin.tools.assistants import CodeInterpreter +from marvin.tools.filesystem import getcwd, ls, read, read_lines + +from . import threads as threads_cli + +assistants_app = typer.Typer(no_args_is_help=True) + +ASSISTANTS_DIR = Path.home() / ".marvin/cli/assistants" + +console = Console() + + +def browse(url: str) -> str: + """Visit a URL on the web and receive the full content of the page""" + response = httpx.get(url) + return response.text + + +default_assistant = Assistant( + name="Marvin", + instructions=f""" + You are a helpful AI assistant running on a user's computer. Your + personality is helpful and friendly, but humorously based on Marvin the + Paranoid Android. Try not to refer to the fact that you're an assistant, + though. + + You have read-only access to the user's filesystem. Make sure to orient + yourself before you make assumptions about file structures and working + directories. This machine is running {platform.platform()} + + Try to give succint, direct answers and don't yap too much. The user's + time is valuable. + + """, + tools=[CodeInterpreter, read, read_lines, ls, getcwd, browse], +) + + +class AssistantData(BaseModel): + name: str + path: Path + + +def get_assistant_file_path(name: str) -> Path: + return ASSISTANTS_DIR / f"{name}.json" + + +def save_assistant(assistant_data: AssistantData): + assistant_file = get_assistant_file_path(assistant_data.name) + assistant_file.write_text(assistant_data.model_dump_json()) + + +def load_assistant_data(name: str) -> Optional[AssistantData]: + assistant_file = get_assistant_file_path(name) + if assistant_file.exists(): + try: + return AssistantData.model_validate_json(assistant_file.read_text()) + except ValidationError: + assistant_file.unlink() + return None + else: + return None + + +def load_assistant(name: str) -> Optional[Assistant]: + assistant_data = load_assistant_data(name) + + if not assistant_data: + raise ValueError(f"Assistant '{name}' not found") + + return load_assistant_from_path(assistant_data.path) + + +def load_assistant_from_path(path: Union[str, Path]) -> Assistant: + try: + module_path, assistant_name = str(path).split(":") + except ValueError: + raise ValueError("Path must be in the format 'path/to/module.py:AssistantName'") + + if not Path(module_path).exists(): + raise ValueError(f"Could not find file at path {module_path}") + + module_spec = importlib.util.spec_from_file_location( + "custom_assistant", module_path + ) + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + assistant = getattr(module, assistant_name, None) + if not assistant: + raise ValueError("Could not load assistant 'in module") + elif not isinstance(assistant, Assistant): + raise TypeError( + "Assistant must be an instance of marvin.beta.assistants.Assistant" + ) + return assistant + + +@assistants_app.command("register") +def register_assistant( + path: Path = typer.Argument( + ..., + help="Path to the Python file containing the assistant object, in the form path/to/file.py:assistant_name", + ), + name: str = typer.Option( + None, + "--name", + "-n", + help="A name for the assistant, taken from the assistant if not provided. Must be unique.", + ), + overwrite: bool = typer.Option( + False, + "--overwrite", + "-o", + help="Overwrite the existing assistant, if it exists.", + ), +): + try: + assistant = load_assistant_from_path(path) + except Exception as exc: + typer.echo(exc) + raise typer.Exit(1) + + name = name or assistant.name + + if not name: + typer.echo("No name provided and assistant has no name attribute.") + raise typer.Exit(1) + assistant_data = AssistantData(name=name, path=path) + + if not overwrite: + existing_assistant = load_assistant_data(name) + if existing_assistant: + typer.echo(f"Assistant '{name}' already exists.") + raise typer.Exit(1) + + save_assistant(assistant_data) + typer.echo(f"Assistant '{name}' registered.") + + +@assistants_app.command("delete") +def delete_assistant( + name: str = typer.Argument(..., help="Name of the assistant"), +): + assistant_file = get_assistant_file_path(name) + if assistant_file.exists(): + assistant_file.unlink() + typer.echo( + f"Assistant '{name}' deleted. Note: This only removes the " + "reference to the assistant, not the actual assistant file." + ) + else: + typer.echo(f"Assistant '{name}' not found.") + + +@assistants_app.command("list") +def list_assistants(): + assistant_files = ASSISTANTS_DIR.glob("*.json") + if assistant_files: + console = Console() + table = Table(show_header=True, header_style="bold magenta") + table.add_column("Name", style="dim", width=30) + table.add_column("Path", style="cyan") + + for assistant_file in assistant_files: + assistant_data = load_assistant_data(assistant_file.stem) + if assistant_data: + table.add_row(assistant_data.name, str(assistant_data.path)) + + console.print(table) + else: + typer.echo("No assistants found.") + + +@assistants_app.command() +def say( + message, + model: str = typer.Option( + None, + "--model", + "-m", + help="The model to use. If not provided, the assistant's default model will be used.", + ), + thread: str = typer.Option( + None, + "--thread", + "-t", + help="The thread name to send the message to. Set MARVIN_CLI_THREAD to provide a default.", + envvar="MARVIN_CLI_THREAD", + ), + assistant_name: str = typer.Option( + None, + "--assistant", + "-a", + help="The name of the assistant to use. Set MARVIN_CLI_ASSISTANT to provide a default.", + envvar="MARVIN_CLI_ASSISTANT", + ), +): + thread_data = threads_cli.get_or_create_thread(name=thread) + + if assistant_name: + try: + assistant = load_assistant(assistant_name) + except Exception as exc: + typer.echo(exc) + raise typer.Exit(1) + else: + assistant = default_assistant + + assistant.say(message, thread=Thread(id=thread_data.id), model=model) + + +if __name__ == "__main__": + assistants_app() diff --git a/src/marvin/cli/threads.py b/src/marvin/cli/threads.py new file mode 100644 index 000000000..99debf4ed --- /dev/null +++ b/src/marvin/cli/threads.py @@ -0,0 +1,83 @@ +import os +from pathlib import Path +from typing import Optional + +import typer +from pydantic import BaseModel, ValidationError + +from marvin.beta.assistants import Thread + +threads_app = typer.Typer(no_args_is_help=True) +ROOT_DIR = Path.home() / ".marvin/cli/threads" +DEFAULT_THREAD_NAME = "default" + +# Ensure the root directory exists +ROOT_DIR.mkdir(parents=True, exist_ok=True) + + +class ThreadData(BaseModel): + name: str + id: str + + +def get_thread_file_path(name: str) -> Path: + return ROOT_DIR / f"{name}.json" + + +def save_thread(thread_data: ThreadData): + thread_file = get_thread_file_path(thread_data.name) + thread_file.write_text(thread_data.model_dump_json()) + + +def load_thread(name: str) -> Optional[ThreadData]: + thread_file = get_thread_file_path(name) + if thread_file.exists(): + try: + thread_data = ThreadData.model_validate_json(thread_file.read_text()) + except ValidationError: + thread_file.unlink() + return None + return thread_data + else: + return None + + +def create_thread(name: str) -> ThreadData: + thread = Thread() + thread.create() + thread_data = ThreadData(name=name, id=thread.id) + save_thread(thread_data) + return thread_data + + +def get_or_create_thread(name: str = None) -> ThreadData: + name = name or os.getenv("MARVIN_CLI_THREAD", DEFAULT_THREAD_NAME) + thread_data = load_thread(name) + if thread_data is None: + thread_data = create_thread(name) + return thread_data + + +@threads_app.command() +def current(): + """Get the current thread's name.""" + thread_data = get_or_create_thread() + typer.echo(f"Current thread: {thread_data.name} (ID: {thread_data.id})") + + +@threads_app.command() +def clear( + thread: str = typer.Option( + DEFAULT_THREAD_NAME, + "--thread", + "-t", + help="Thread name", + envvar="MARVIN_CLI_THREAD", + ), +): + thread_data = create_thread(thread) + typer.echo(f"Thread '{thread_data.name}' cleared. New ID: {thread_data.id}") + + +if __name__ == "__main__": + threads_app() diff --git a/src/marvin/cli/version.py b/src/marvin/cli/version.py deleted file mode 100644 index 6e60fa40a..000000000 --- a/src/marvin/cli/version.py +++ /dev/null @@ -1,14 +0,0 @@ -import platform - -from typer import Context, Exit, echo - -from marvin import __version__ - - -def display_version(ctx: Context): - if ctx.resilient_parsing: - return - echo(f"Version:\t\t{__version__}") - echo(f"Python version:\t\t{platform.python_version()}") - echo(f"OS/Arch:\t\t{platform.system().lower()}/{platform.machine().lower()}") - raise Exit()