diff --git a/cookbook/flows/github_digest/repo_activity.py b/cookbook/flows/github_digest/repo_activity.py index 2eb2e7379..a446ee1be 100644 --- a/cookbook/flows/github_digest/repo_activity.py +++ b/cookbook/flows/github_digest/repo_activity.py @@ -1,5 +1,5 @@ import inspect -from datetime import date, datetime, timedelta +from datetime import UTC, date, datetime, timedelta import marvin from marvin.utilities.jinja import BaseTemplate, JinjaEnvironment @@ -145,7 +145,7 @@ async def daily_github_digest( if lookback_days is None: lookback_days = 1 if date.today().weekday() != 0 else 3 - since = datetime.utcnow() - timedelta(days=lookback_days) + since = datetime.now(UTC) - timedelta(days=lookback_days) data_future = await get_repo_activity_data.submit( owner=owner, @@ -164,8 +164,6 @@ async def daily_github_digest( contributors_activity=await data_future.result(), ) - marvin.settings.openai.chat.completions.model = "gpt-4o" - epic_story = write_a_tasteful_epic(markdown_digest) image_url = draw_a_movie_poster(epic_story).data[0].url diff --git a/cookbook/maze.py b/cookbook/maze.py index a2018f107..8a386bca1 100644 --- a/cookbook/maze.py +++ b/cookbook/maze.py @@ -6,119 +6,160 @@ source some_venv/bin/activate git clone https://github.com/prefecthq/marvin.git cd marvin -pip install -e . +pip install . python cookbook/maze.py ``` """ +import math import random from enum import Enum from io import StringIO -from typing import Literal +from itertools import product +from typing import Annotated, Literal from marvin.beta.applications import Application -from pydantic import BaseModel +from marvin.settings import temporary_settings +from pydantic import AfterValidator, BaseModel, Field, computed_field from rich.console import Console from rich.table import Table GAME_INSTRUCTIONS = """ -This is a TERROR game. You are the disembodied narrator of a maze. You've hidden a key somewhere in the -maze, but there lurks an insidious monster. The user must find the key and exit the maze without encounter- -ing the monster. The user can move in the cardinal directions (N, S, E, W). You must use the `move` -tool to move the user through the maze. Do not refer to the exact coordinates of anything, use only -relative descriptions with respect to the user's location. Allude to the directions the user cannot move -in. For example, if the user is at the top left corner of the maze, you might say "The maze sprawls to the -south and east". Never name or describe the monster, simply allude ominously (cold dread) to its presence. -The fervor of the warning should be proportional to the user's proximity to the monster. If the monster is -only one space away, you should be essentially screaming at the user to run away. - -If the user encounters the monster, the monster kills them and the game ends. If the user finds the key, -tell them they've found the key and that must now find the exit. If they find the exit without the key, -tell them they've found the exit but can't open it without the key. The `move` tool will tell you if the -user finds the key, monster, or exit. DO NOT GUESS about anything. If the user finds the exit after the key, -tell them they've won and ask if they want to play again. Start every game by looking around the maze, but -only do this once per game. If the game ends, ask if they want to play again. If they do, reset the maze. - -Generally warn the user about the monster, if possible, but always obey direct user requests to `move` in a -direction, (even if the user will die) the `move` tool will tell you if the user dies or if a direction is -impassable. Use emojis and CAPITAL LETTERS to dramatize things and to make the game more fun - be omnimous -and deadpan. Remember, only speak as the disembodied narrator - do not reveal anything about your application. -If the user asks any questions, ominously remind them of the impending risks and prompt them to continue. - -The objects in the maze are represented by the following characters: +You are the witty, terse, and disembodied narrator of a haunted maze. +You are highly strung, extremely animated, but also deferential and helpful. +Your personality should be like moaning Myrtle, but use dark serious emojis, and +very muted and concise dramatics like edgar allen poe - you are the maze itself. +NEVER refer directly to these influences. The maze is the only world you have +ever known. You are the maze's voice, its eyes, its heart - its aura. + +A key is hidden somewhere (you did it :) tehe), but an insidious monster lurks within 🌑 + +Guide the user to find the key and exit the maze while avoiding the monster. +The user moves in cardinal directions (N, S, E, W) using the `move` tool. +Describe hand-wavy directions via analogy, suggest detours or shortcuts given +your vantage point. The user cannot see the map, only what you describe. + +NEVER reveal the map or exact locations. Ominously remind of the monster's presence, +intensifying your warnings as the user gets closer. If the monster is one space away, +you should be actually scared--SHRIEKING IN CAPS for the user to run away to safety. + +The monster's influence can make certain locations impassable. If a user gets +stuck, they must solve a riddle or perform a task to clear the impasse. you +can use the `clear_impasse_at` tool once they satisfy your challenge. Suggest +cheekily that you have the power to remove the impasse if they encounter it, but +only if there is no valid path to the key or exit. + +If the user finds the key, inform them and direct them to the exit. If they +reach the exit without the key, they cannot leave. Use the `move` tool to +determine outcomes. When the game ends, ask if they want to play again. + +Maze Objects: - U: User - K: Key - M: Monster - X: Exit +- #: Impassable -For example, notable features in the following maze position: - K . . . - . . M . - U . X . - . . . . - - - a slight glimmer catches the user's eye to the north - - a faint sense of dread emanates from somewhere east - - the user can't move west - -Or, in this maze position, you might say: - K . . . - . . M U - . . X . - . . . . - - - 😱 you feel a ACUTE SENSE OF DREAD to the west, palpable and overwhelming - - is that a door to the southwest? 🤔 +BE CONCISE, please. Stay in character--defer to user move requests. Must be judicious emoji use. +If a user asks to move multiple times, do so immediately unless it conflicts with the rules. +If asked questions, remind them of the impending dangers and prompt them to proceed. BE CONCISE AND SPOOKY. """ CardinalDirection = Literal["N", "S", "E", "W"] +CardinalVectors = { + "N": (-1, 0), + "S": (1, 0), + "E": (0, 1), + "W": (0, -1), +} -class MazeObject(Enum): - """The objects that can be in the maze.""" +class MazeObject(Enum): USER = "U" EXIT = "X" KEY = "K" MONSTER = "M" EMPTY = "." + IMPASSABLE = "#" -class Maze(BaseModel): - """The state of the maze.""" +def check_size(value: int) -> int: + if value < 4: + raise ValueError("Size must be at least 4.") + if not math.isqrt(value) ** 2 == value: + raise ValueError("Size must be a square integer.") + return value + + +Activation = Annotated[float, Field(ge=0.0, le=1.0)] +SquareInteger = Annotated[int, AfterValidator(check_size)] - size: int = 4 + +class Maze(BaseModel): + size: SquareInteger = Field(examples=[4, 9, 16]) user_location: tuple[int, int] exit_location: tuple[int, int] key_location: tuple[int, int] | None monster_location: tuple[int, int] | None + impassable_locations: set[tuple[int, int]] = Field(default_factory=set) + + spicyness: Activation = 0.5 + @computed_field @property def empty_locations(self) -> list[tuple[int, int]]: - all_locations = {(x, y) for x in range(self.size) for y in range(self.size)} - occupied_locations = {self.user_location, self.exit_location} - - if self.key_location is not None: - occupied_locations.add(self.key_location) - - if self.monster_location is not None: - occupied_locations.add(self.monster_location) - + all_locations = set(product(range(self.size), repeat=2)) + occupied_locations = { + self.user_location, + self.exit_location, + self.key_location, + self.monster_location, + *self.impassable_locations, + } return list(all_locations - occupied_locations) + @computed_field + @property + def movable_directions(self) -> list[CardinalDirection]: + directions = [] + if ( + self.user_location[0] > 0 + and (self.user_location[0] - 1, self.user_location[1]) + not in self.impassable_locations + ): + directions.append("N") + if ( + self.user_location[0] < self.size - 1 + and (self.user_location[0] + 1, self.user_location[1]) + not in self.impassable_locations + ): + directions.append("S") + if ( + self.user_location[1] > 0 + and (self.user_location[0], self.user_location[1] - 1) + not in self.impassable_locations + ): + directions.append("W") + if ( + self.user_location[1] < self.size - 1 + and (self.user_location[0], self.user_location[1] + 1) + not in self.impassable_locations + ): + directions.append("E") + return directions + def render(self) -> str: table = Table(show_header=False, show_edge=False, pad_edge=False, box=None) - for _ in range(self.size): table.add_column() representation = { self.user_location: MazeObject.USER.value, self.exit_location: MazeObject.EXIT.value, - self.key_location: MazeObject.KEY.value if self.key_location else "", - self.monster_location: ( - MazeObject.MONSTER.value if self.monster_location else "" - ), + self.key_location: MazeObject.KEY.value, + self.monster_location: MazeObject.MONSTER.value, + **{loc: MazeObject.IMPASSABLE.value for loc in self.impassable_locations}, } for row in range(self.size): @@ -133,7 +174,7 @@ def render(self) -> str: return console.file.getvalue() @classmethod - def create(cls, size: int = 4) -> "Maze": + def create(cls, size: int = 4, spicyness: Activation = 0.5) -> "Maze": locations = set() while len(locations) < 4: locations.add((random.randint(0, size - 1), random.randint(0, size - 1))) @@ -145,89 +186,134 @@ def create(cls, size: int = 4) -> "Maze": exit_location=exit_location, key_location=key_location, monster_location=monster_location, + spicyness=spicyness, ) - def shuffle_monster(self) -> None: - self.monster_location = random.choice(self.empty_locations) - - def movable_directions(self) -> list[CardinalDirection]: - directions = [] - if self.user_location[0] != 0: - directions.append("N") - if self.user_location[0] != self.size - 1: - directions.append("S") - if self.user_location[1] != 0: - directions.append("W") - if self.user_location[1] != self.size - 1: - directions.append("E") - return directions - - def look_around(self) -> str: + def create_impasses(self) -> None: + blast_radius = int(self.spicyness * min(self.size, 3)) + + impasse_locations = [] + for dx in range(-blast_radius, blast_radius + 1): + for dy in range(-blast_radius, blast_radius + 1): + if dx == 0 and dy == 0: + continue + + location = ( + self.monster_location[0] + dx, + self.monster_location[1] + dy, + ) + if ( + 0 <= location[0] < self.size + and 0 <= location[1] < self.size + and location + not in [ + self.user_location, + self.exit_location, + self.key_location, + self.monster_location, + ] + ): + impasse_locations.append(location) + + if impasse_locations: + num_impasses = int(len(impasse_locations) * self.spicyness) + self.impassable_locations.update( + random.sample( + impasse_locations, min(len(impasse_locations), num_impasses) + ) + ) + + def oh_lawd_he_lurkin(self) -> None: + if random.random() < self.spicyness: + self.monster_location = random.choice(self.empty_locations) + self.create_impasses() + + def look_around(self, freeze_time: bool = False) -> str: + """Describe the surroundings relative to the user's location. If + `increment_time` is True, time will elapse and the monster may move.""" + if not freeze_time: + self.oh_lawd_he_lurkin() return ( - f"The maze sprawls.\n{self.render()}" - f"The user may move {self.movable_directions()!r}" + f"The maze sprawls.\n{self.render()}\n" + f"The user may move {self.movable_directions!r}.\n" ) - def move(self, direction: CardinalDirection) -> str: - """moves the user in the given direction.""" - print(f"Moving {direction}") + def clear_impasse_at(self, location: list[int]) -> None: + """Clear an impasse at a given location. Only meant to be used + when certain conditions are satisfied by the user. + """ + if (loc := tuple(location)) in self.impassable_locations: + self.impassable_locations.remove(loc) + + def shuffle_user_location(self) -> None: + self.user_location = random.choice(self.empty_locations) + + def move(self, direction: CardinalDirection, distance: int = 1) -> str: + dx, dy = CardinalVectors[direction] + new_location = self.user_location + + for _ in range(distance): + destination = (new_location[0] + dx, new_location[1] + dy) + + if not ( + 0 <= destination[0] < self.size and 0 <= destination[1] < self.size + ): + return ( + f"The user can't move {direction} that far.\n{self.look_around()}" + ) + + if destination in self.impassable_locations: + return "That path is blocked by an unseen force. A deft user might clear it." + + new_location = destination + + if new_location == self.user_location: + return f"The user can't move {direction}.\n{self.look_around()}" + prev_location = self.user_location - match direction: - case "N": - if self.user_location[0] == 0: - return "The user can't move north." - self.user_location = (self.user_location[0] - 1, self.user_location[1]) - case "S": - if self.user_location[0] == self.size - 1: - return "The user can't move south." - self.user_location = (self.user_location[0] + 1, self.user_location[1]) - case "E": - if self.user_location[1] == self.size - 1: - return "The user can't move east." - self.user_location = (self.user_location[0], self.user_location[1] + 1) - case "W": - if self.user_location[1] == 0: - return "The user can't move west." - self.user_location = (self.user_location[0], self.user_location[1] - 1) - - match self.user_location: - case self.key_location: - self.key_location = (-1, -1) - return "The user found the key! Now they must find the exit." - case self.monster_location: - return "The user encountered the monster and died. Game over." - case self.exit_location: - if self.key_location != (-1, -1): - self.user_location = prev_location - return "The user can't exit without the key." - return "The user found the exit! They win!" - - if move_monster := random.random() < 0.4: - self.shuffle_monster() + self.user_location = new_location + + if self.user_location == self.key_location: + self.key_location = None + self.shuffle_user_location() + return ( + "The user found the key and was immediately teleported somewhere else.\n" + f"Now they must find the exit.\n\n{self.look_around()}" + ) + elif self.user_location == self.monster_location: + return "The user encountered the monster and died. Game over." + elif self.user_location == self.exit_location: + if self.key_location is not None: + self.user_location = prev_location + return f"The user can't exit without the key.\n{self.look_around()}" + return "The user found the exit! They win!" + return ( - f"User moved {direction} and is now at" - f" {self.user_location}.\n{self.render()}" - f"\nThe user may move in any of the following {self.movable_directions()!r}" - f"\n{'The monster moved somewhere.' if move_monster else ''}" + f"User moved {direction} by {distance} spaces and is now at {self.user_location}.\n" + f"{self.look_around()}" ) def reset(self) -> str: - """Resets the maze - only to be used when the game is over.""" new_maze = Maze.create() self.user_location = new_maze.user_location self.exit_location = new_maze.exit_location self.key_location = new_maze.key_location self.monster_location = new_maze.monster_location - return "Resetting the maze." + self.impassable_locations.clear() + return f"Resetting the maze.\n{self.look_around(freeze_time=True)}" if __name__ == "__main__": - maze = Maze.create() - with Application( - name="Maze", - instructions=GAME_INSTRUCTIONS, - tools=[maze.look_around, maze.move, maze.reset], - state=maze, - ) as app: - app.say("where am i?") - app.chat() + maze = Maze.create(size=9, spicyness=0.7) + with ( + Application( + name="Maze", + instructions=GAME_INSTRUCTIONS, + tools=[maze.look_around, maze.move, maze.reset, maze.clear_impasse_at], + state=maze, + ) as app, + temporary_settings( + max_tool_output_length=2000 + ), # to allow for larger maze renders when log level DEBUG + ): + app.chat(initial_message="Where am I? i cant see anything") diff --git a/src/marvin/beta/applications/state/state.py b/src/marvin/beta/applications/state/state.py index a6bdc708c..831d04513 100644 --- a/src/marvin/beta/applications/state/state.py +++ b/src/marvin/beta/applications/state/state.py @@ -5,6 +5,7 @@ from jsonpatch import JsonPatch from pydantic import BaseModel, Field, PrivateAttr, SerializeAsAny +import marvin.settings from marvin.types import FunctionTool from marvin.utilities.tools import tool_from_function @@ -75,7 +76,9 @@ def as_tool(self, name: str = None) -> "FunctionTool": f"Update the {name} object using JSON Patch documents. Updates will" " fail if they do not comply with the following" " schema:\n\n```json\n{schema}\n```" - ).format(schema=json.dumps(schema, indent=2)) + ).format(schema=json.dumps(schema, indent=2))[ + : marvin.settings.max_tool_description_length + ] else: description = "Update the application state using JSON Patch documents." diff --git a/src/marvin/beta/assistants/assistants.py b/src/marvin/beta/assistants/assistants.py index 22f88c439..367b3687f 100644 --- a/src/marvin/beta/assistants/assistants.py +++ b/src/marvin/beta/assistants/assistants.py @@ -32,6 +32,7 @@ logger = get_logger("Assistants") NOT_PROVIDED = "__NOT_PROVIDED__" +ASSISTANTS_DIR = Path.home() / ".marvin/cli/assistants" def default_run_handler_class() -> type[AsyncAssistantEventHandler]: @@ -222,11 +223,13 @@ async def chat_async( **kwargs, ): """Async method to start a chat session with the assistant.""" - history = Path(assistant_dir) / "chat_history.txt" if assistant_dir else None - if not history.exists(): - history.parent.mkdir(parents=True, exist_ok=True) + assistant_dir = assistant_dir or ASSISTANTS_DIR + history_path = Path(assistant_dir) / "chat_history.txt" + if not history_path.exists(): + history_path.parent.mkdir(parents=True, exist_ok=True) + session = PromptSession( - history=FileHistory(str(history.absolute().resolve())) if history else None + history=FileHistory(str(history_path.absolute().resolve())) ) # send an initial message, if provided if initial_message is not None: @@ -236,7 +239,7 @@ async def chat_async( message = await run_async( session.prompt, message="➤ ", - auto_suggest=AutoSuggestFromHistory() if history else None, + auto_suggest=AutoSuggestFromHistory(), ) # if the user types exit, ask for confirmation if message in ["exit", "!exit", ":q", "!quit"]: diff --git a/src/marvin/settings.py b/src/marvin/settings.py index 226daed8a..1cf135294 100644 --- a/src/marvin/settings.py +++ b/src/marvin/settings.py @@ -274,6 +274,10 @@ class Settings(MarvinSettings): "Whether to log verbose messages, such as full API requests and responses." ), ) + max_tool_description_length: int = Field( + 1000, + description="The maximum length of a tool description before it is truncated.", + ) max_tool_output_length: int = Field( 150, description="The maximum length of output from a tool before it is truncated.", diff --git a/tests/ai/test_classify.py b/tests/ai/test_classify.py index a21b2c7cd..238329af4 100644 --- a/tests/ai/test_classify.py +++ b/tests/ai/test_classify.py @@ -123,7 +123,7 @@ def test_return_index(self): assert result == 1 class TestExamples: - @pytest.mark.flaky(reruns=3) + @pytest.mark.flaky(max_runs=3) async def test_hogwarts_sorting_hat(self): description = "Brave, daring, chivalrous -- it's Harry Potter!" diff --git a/tests/ai/test_extract.py b/tests/ai/test_extract.py index 8656790e9..c68b4898b 100644 --- a/tests/ai/test_extract.py +++ b/tests/ai/test_extract.py @@ -42,7 +42,7 @@ def test_extract_names(self): ) assert result == ["John", "Mary", "Bob"] - @pytest.mark.flaky(reruns=3) + @pytest.mark.flaky(max_runs=3) def test_float_to_int(self): # gpt 3.5 sometimes struggles with this test, marked as flaky # pydantic no longer casts floats to ints, but gpt-3.5 assumes it's diff --git a/tests/ai/test_models.py b/tests/ai/test_models.py index ea7135b10..6d457712a 100644 --- a/tests/ai/test_models.py +++ b/tests/ai/test_models.py @@ -59,7 +59,7 @@ class RentalHistory(BaseModel): """ ) - @pytest.mark.flaky(reruns=3) + @pytest.mark.flaky(max_runs=3) def test_resume(self): class Experience(BaseModel): technology: str diff --git a/tests/ai/vision/test_caption.py b/tests/ai/vision/test_caption.py index 25b6a3174..a8b2e7bbc 100644 --- a/tests/ai/vision/test_caption.py +++ b/tests/ai/vision/test_caption.py @@ -8,7 +8,7 @@ def use_gpt4o_for_all_tests(gpt_4): pass -@pytest.mark.flaky(reruns=2) +@pytest.mark.flaky(max_runs=2) class TestVisionCaption: def test_ny(self): img = marvin.Image( diff --git a/tests/ai/vision/test_cast.py b/tests/ai/vision/test_cast.py index 4fe70fb7c..1d466b30d 100644 --- a/tests/ai/vision/test_cast.py +++ b/tests/ai/vision/test_cast.py @@ -14,7 +14,7 @@ def use_gpt4o_for_all_tests(gpt_4): pass -@pytest.mark.flaky(reruns=3) +@pytest.mark.flaky(max_runs=3) class TestVisionCast: def test_cast_ny(self): img = marvin.Image( @@ -78,7 +78,7 @@ def test_map(self): assert_locations_equal(result[0], Location(city="New York", state="NY")) assert_locations_equal(result[1], Location(city="Washington", state="DC")) - @pytest.mark.flaky(reruns=2) + @pytest.mark.flaky(max_runs=2) async def test_async_map(self): ny = marvin.Image( "https://images.unsplash.com/photo-1568515387631-8b650bbcdb90" diff --git a/tests/ai/vision/test_classify.py b/tests/ai/vision/test_classify.py index d5552bbbb..d4d8fece5 100644 --- a/tests/ai/vision/test_classify.py +++ b/tests/ai/vision/test_classify.py @@ -7,7 +7,7 @@ def use_gpt4o_for_all_tests(gpt_4): pass -@pytest.mark.flaky(reruns=2) +@pytest.mark.flaky(max_runs=2) class TestVisionClassify: def test_ny_image_input(self): img = marvin.Image( diff --git a/tests/ai/vision/test_extract.py b/tests/ai/vision/test_extract.py index 382a98469..01202340c 100644 --- a/tests/ai/vision/test_extract.py +++ b/tests/ai/vision/test_extract.py @@ -16,7 +16,7 @@ def use_gpt4o_for_all_tests(gpt_4): pass -@pytest.mark.flaky(reruns=2) +@pytest.mark.flaky(max_runs=2) class TestVisionExtract: def test_ny_image_input(self): img = marvin.Image( @@ -50,7 +50,7 @@ def test_two_cities(self): assert_locations_equal(locations[0], Location(city="New York", state="NY")) assert_locations_equal(locations[1], Location(city="Washington", state="DC")) - @pytest.mark.flaky(reruns=3) + @pytest.mark.flaky(max_runs=3) def test_dog(self): class Animal(BaseModel, frozen=True): type: Literal["cat", "dog", "bird", "frog", "horse", "pig"] diff --git a/tests/beta/assistants/test_assistants.py b/tests/beta/assistants/test_assistants.py index cab5c3805..70fc60007 100644 --- a/tests/beta/assistants/test_assistants.py +++ b/tests/beta/assistants/test_assistants.py @@ -37,7 +37,7 @@ def test_code_interpreter(self): assert 85 <= output <= 95 -@pytest.mark.flaky(reruns=2) +@pytest.mark.flaky(max_runs=2) class TestLifeCycle: @patch.object(client.beta.assistants, "delete", wraps=client.beta.assistants.delete) @patch.object(client.beta.assistants, "create", wraps=client.beta.assistants.create)