From c6368f3eba7ced6cf858b7f9bf0e1c7a4f42dd95 Mon Sep 17 00:00:00 2001 From: Matthew Zhou Date: Wed, 9 Oct 2024 14:34:36 -0700 Subject: [PATCH] chore: add CLI CI test (#1858) Co-authored-by: Matt Zhou --- .github/workflows/test_cli.yml | 46 +++++++++++++++++ .github/workflows/tests.yml | 2 +- letta/cli/cli.py | 3 +- letta/client/utils.py | 9 +++- letta/interface.py | 8 ++- letta/local_llm/constants.py | 3 ++ letta/streaming_interface.py | 12 +++-- tests/test_cli.py | 91 ++++++++++++++++++++++------------ 8 files changed, 133 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/test_cli.yml diff --git a/.github/workflows/test_cli.yml b/.github/workflows/test_cli.yml new file mode 100644 index 0000000000..1257572c76 --- /dev/null +++ b/.github/workflows/test_cli.yml @@ -0,0 +1,46 @@ +name: Run CLI tests + +env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + + services: + qdrant: + image: qdrant/qdrant + ports: + - 6333:6333 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build and run container + run: bash db/run_postgres.sh + + - name: "Setup Python, Poetry and Dependencies" + uses: packetcoders/action-setup-cache-python-poetry@main + with: + python-version: "3.12" + poetry-version: "1.8.2" + install-args: "-E dev -E postgres -E tests" + + - name: Test `letta run` up until first message + env: + LETTA_PG_PORT: 8888 + LETTA_PG_USER: letta + LETTA_PG_PASSWORD: letta + LETTA_PG_DB: letta + LETTA_PG_HOST: localhost + LETTA_SERVER_PASS: test_server_token + run: | + poetry run pytest -s -vv tests/test_cli.py::test_letta_run_create_new_agent diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1c3580414d..33ab5ff554 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -69,4 +69,4 @@ jobs: LETTA_SERVER_PASS: test_server_token PYTHONPATH: ${{ github.workspace }}:${{ env.PYTHONPATH }} run: | - poetry run pytest -s -vv -k "not test_tools.py and not test_concurrent_connections.py and not test_quickstart and not test_endpoints and not test_storage and not test_server and not test_openai_client and not test_providers" tests + poetry run pytest -s -vv -k "not test_cli.py and not test_tools.py and not test_concurrent_connections.py and not test_quickstart and not test_endpoints and not test_storage and not test_server and not test_openai_client and not test_providers" tests diff --git a/letta/cli/cli.py b/letta/cli/cli.py index 1517aa1752..31a567e1de 100644 --- a/letta/cli/cli.py +++ b/letta/cli/cli.py @@ -11,6 +11,7 @@ from letta.agent import Agent, save_agent from letta.config import LettaConfig from letta.constants import CLI_WARNING_PREFIX, LETTA_DIR +from letta.local_llm.constants import ASSISTANT_MESSAGE_CLI_SYMBOL from letta.log import get_logger from letta.metadata import MetadataStore from letta.schemas.enums import OptionState @@ -276,7 +277,7 @@ def run( memory = ChatMemory(human=human_obj.value, persona=persona_obj.value, limit=core_memory_limit) metadata = {"human": human_obj.name, "persona": persona_obj.name} - typer.secho(f"-> 🤖 Using persona profile: '{persona_obj.name}'", fg=typer.colors.WHITE) + typer.secho(f"-> {ASSISTANT_MESSAGE_CLI_SYMBOL} Using persona profile: '{persona_obj.name}'", fg=typer.colors.WHITE) typer.secho(f"-> 🧑 Using human profile: '{human_obj.name}'", fg=typer.colors.WHITE) # add tools diff --git a/letta/client/utils.py b/letta/client/utils.py index 254a82f157..bcec534c7a 100644 --- a/letta/client/utils.py +++ b/letta/client/utils.py @@ -2,6 +2,11 @@ from IPython.display import HTML, display +from letta.local_llm.constants import ( + ASSISTANT_MESSAGE_CLI_SYMBOL, + INNER_THOUGHTS_CLI_SYMBOL, +) + def pprint(messages): """Utility function for pretty-printing the output of client.send_message in notebooks""" @@ -47,13 +52,13 @@ def pprint(messages): html_content += f"

🛠️ [{date_formatted}] Function Return ({return_status}):

" html_content += f"

{return_string}

" elif "internal_monologue" in message: - html_content += f"

💭 [{date_formatted}] Internal Monologue:

" + html_content += f"

{INNER_THOUGHTS_CLI_SYMBOL} [{date_formatted}] Internal Monologue:

" html_content += f"

{message['internal_monologue']}

" elif "function_call" in message: html_content += f"

🛠️ [[{date_formatted}] Function Call:

" html_content += f"

{message['function_call']}

" elif "assistant_message" in message: - html_content += f"

🤖 [{date_formatted}] Assistant Message:

" + html_content += f"

{ASSISTANT_MESSAGE_CLI_SYMBOL} [{date_formatted}] Assistant Message:

" html_content += f"

{message['assistant_message']}

" html_content += "
" html_content += "" diff --git a/letta/interface.py b/letta/interface.py index 1487db7131..aac10453f1 100644 --- a/letta/interface.py +++ b/letta/interface.py @@ -5,6 +5,10 @@ from colorama import Fore, Style, init from letta.constants import CLI_WARNING_PREFIX +from letta.local_llm.constants import ( + ASSISTANT_MESSAGE_CLI_SYMBOL, + INNER_THOUGHTS_CLI_SYMBOL, +) from letta.schemas.message import Message from letta.utils import json_loads, printd @@ -79,14 +83,14 @@ def warning_message(msg: str): @staticmethod def internal_monologue(msg: str, msg_obj: Optional[Message] = None): # ANSI escape code for italic is '\x1B[3m' - fstr = f"\x1B[3m{Fore.LIGHTBLACK_EX}💭 {{msg}}{Style.RESET_ALL}" + fstr = f"\x1B[3m{Fore.LIGHTBLACK_EX}{INNER_THOUGHTS_CLI_SYMBOL} {{msg}}{Style.RESET_ALL}" if STRIP_UI: fstr = "{msg}" print(fstr.format(msg=msg)) @staticmethod def assistant_message(msg: str, msg_obj: Optional[Message] = None): - fstr = f"{Fore.YELLOW}{Style.BRIGHT}🤖 {Fore.YELLOW}{{msg}}{Style.RESET_ALL}" + fstr = f"{Fore.YELLOW}{Style.BRIGHT}{ASSISTANT_MESSAGE_CLI_SYMBOL} {Fore.YELLOW}{{msg}}{Style.RESET_ALL}" if STRIP_UI: fstr = "{msg}" print(fstr.format(msg=msg)) diff --git a/letta/local_llm/constants.py b/letta/local_llm/constants.py index 1b3ab4e9f3..ed07f4f1fe 100644 --- a/letta/local_llm/constants.py +++ b/letta/local_llm/constants.py @@ -29,3 +29,6 @@ INNER_THOUGHTS_KWARG = "inner_thoughts" INNER_THOUGHTS_KWARG_DESCRIPTION = "Deep inner monologue private to you only." +INNER_THOUGHTS_CLI_SYMBOL = "💭" + +ASSISTANT_MESSAGE_CLI_SYMBOL = "🤖" diff --git a/letta/streaming_interface.py b/letta/streaming_interface.py index 5ca5252b31..e21e5e73e1 100644 --- a/letta/streaming_interface.py +++ b/letta/streaming_interface.py @@ -9,6 +9,10 @@ from rich.markup import escape from letta.interface import CLIInterface +from letta.local_llm.constants import ( + ASSISTANT_MESSAGE_CLI_SYMBOL, + INNER_THOUGHTS_CLI_SYMBOL, +) from letta.schemas.message import Message from letta.schemas.openai.chat_completion_response import ( ChatCompletionChunkResponse, @@ -296,7 +300,7 @@ def update_output(self, content: str): def process_refresh(self, response: ChatCompletionResponse): """Process the response to rewrite the current output buffer.""" if not response.choices: - self.update_output("💭 [italic]...[/italic]") + self.update_output(f"{INNER_THOUGHTS_CLI_SYMBOL} [italic]...[/italic]") return # Early exit if there are no choices choice = response.choices[0] @@ -304,7 +308,7 @@ def process_refresh(self, response: ChatCompletionResponse): tool_calls = choice.message.tool_calls if choice.message.tool_calls else [] if self.fancy: - message_string = f"💭 [italic]{inner_thoughts}[/italic]" if inner_thoughts else "" + message_string = f"{INNER_THOUGHTS_CLI_SYMBOL} [italic]{inner_thoughts}[/italic]" if inner_thoughts else "" else: message_string = "[inner thoughts] " + inner_thoughts if inner_thoughts else "" @@ -326,7 +330,7 @@ def process_refresh(self, response: ChatCompletionResponse): message = function_args[len(prefix) :] else: message = function_args - message_string += f"🤖 [bold yellow]{message}[/bold yellow]" + message_string += f"{ASSISTANT_MESSAGE_CLI_SYMBOL} [bold yellow]{message}[/bold yellow]" else: message_string += f"{function_name}({function_args})" @@ -336,7 +340,7 @@ def stream_start(self): if self.streaming: print() self.live.start() # Start the Live display context and keep it running - self.update_output("💭 [italic]...[/italic]") + self.update_output(f"{INNER_THOUGHTS_CLI_SYMBOL} [italic]...[/italic]") def stream_end(self): if self.streaming: diff --git a/tests/test_cli.py b/tests/test_cli.py index 4e925c33df..107b6c6e5f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,44 +1,73 @@ -import subprocess +import os +import shutil import sys -subprocess.check_call([sys.executable, "-m", "pip", "install", "pexpect"]) -from prettytable.colortable import ColorTable +import pexpect +import pytest -from letta.cli.cli_config import ListChoice, add, delete -from letta.cli.cli_config import list as list_command +from letta.local_llm.constants import ( + ASSISTANT_MESSAGE_CLI_SYMBOL, + INNER_THOUGHTS_CLI_SYMBOL, +) -# def test_configure_letta(): -# configure_letta() +original_letta_path = os.path.expanduser("~/.letta") +backup_letta_path = os.path.expanduser("~/.letta_backup") -options = [ListChoice.agents, ListChoice.sources, ListChoice.humans, ListChoice.personas] +@pytest.fixture +def swap_letta_config(): + if os.path.exists(backup_letta_path): + print("\nDelete the backup ~/.letta directory\n") + shutil.rmtree(backup_letta_path) -def test_cli_list(): - for option in options: - output = list_command(arg=option) - # check if is a list - assert isinstance(output, ColorTable) + if os.path.exists(original_letta_path): + print("\nBackup the original ~/.letta directory\n") + shutil.move(original_letta_path, backup_letta_path) + try: + # Run the test + yield + finally: + # Ensure this runs no matter what + print("\nClean up ~/.letta and restore the original directory\n") + if os.path.exists(original_letta_path): + shutil.rmtree(original_letta_path) -def test_cli_config(): + if os.path.exists(backup_letta_path): + shutil.move(backup_letta_path, original_letta_path) - # test add - for option in ["human", "persona"]: - # create initial - add(option=option, name="test", text="test data") +def test_letta_run_create_new_agent(swap_letta_config): + child = pexpect.spawn("poetry run letta run", encoding="utf-8") + # Start the letta run command + child.logfile = sys.stdout + child.expect("Creating new agent", timeout=10) + # Optional: LLM model selection + try: + child.expect("Select LLM model:", timeout=10) + child.sendline("\033[B\033[B\033[B\033[B\033[B") + except (pexpect.TIMEOUT, pexpect.EOF): + print("[WARNING] LLM model selection step was skipped.") - ## update - # filename = "test.txt" - # open(filename, "w").write("test data new") - # child = pexpect.spawn(f"poetry run letta add --{str(option)} {filename} --name test --strip-ui") - # child.expect("Human test already exists. Overwrite?", timeout=TIMEOUT) - # child.sendline() - # child.expect(pexpect.EOF, timeout=TIMEOUT) # Wait for child to exit - # child.close() + # Optional: Embedding model selection + try: + child.expect("Select embedding model:", timeout=10) + child.sendline("text-embedding-ada-002") + except (pexpect.TIMEOUT, pexpect.EOF): + print("[WARNING] Embedding model selection step was skipped.") - for row in list_command(arg=ListChoice.humans if option == "human" else ListChoice.personas): - if row[0] == "test": - assert "test data" in row - # delete - delete(option=option, name="test") + child.expect("Created new agent", timeout=10) + child.sendline("") + + # Get initial response + child.expect("Enter your message:", timeout=60) + # Capture the output up to this point + full_output = child.before + # Count occurrences of inner thoughts + cloud_emoji_count = full_output.count(INNER_THOUGHTS_CLI_SYMBOL) + assert cloud_emoji_count == 1, f"It appears that there are multiple instances of inner thought outputted." + # Count occurrences of assistant messages + robot = full_output.count(ASSISTANT_MESSAGE_CLI_SYMBOL) + assert robot == 1, f"It appears that there are multiple instances of assistant messages outputted." + # Make sure the user name was repeated back at least once + assert full_output.count("Chad") > 0, f"Chad was not mentioned...please manually inspect the outputs."