diff --git a/docs/source/llm.ipynb b/docs/source/llm.ipynb index c738adca..c0364687 100644 --- a/docs/source/llm.ipynb +++ b/docs/source/llm.ipynb @@ -35,7 +35,7 @@ "from pydantic import field_validator\n", "from pydantic_core import PydanticCustomError\n", "\n", - "from effectful.handlers.llm import Template, Tool\n", + "from effectful.handlers.llm import Agent, Template, Tool\n", "from effectful.handlers.llm.completions import (\n", " LiteLLMProvider,\n", " RetryLLMHandler,\n", @@ -96,17 +96,17 @@ "name": "stdout", "output_type": "stream", "text": [ - "In the sea where the shimmering fish \n", - "Dance around like a silvery wish,\n", - "They wiggle and glide,\n", - "With the tide, side by side,\n", - "Turning waves into their swirlish dish.\n", + "In the sea where the little fish swim, \n", + "One fish thought he’d try to be slim. \n", + "He’d wiggle and dash, \n", + "In hopes of a splash, \n", + "But found he was chubby and prim.\n", "----------------------------------------\n", - "There once was a fish named Blue,\n", - "Who swam in a sea of bright hue.\n", - "With scales shining bright,\n", - "He'd dance in the light,\n", - "And none were as charming as Blue.\n" + "In the deep blue sea swam a trout, \n", + "Who loved to leap and jump about. \n", + "With scales all aglitter, \n", + "It danced with a flitter, \n", + "And got in a whale's belly without a doubt! \n" ] } ], @@ -136,13 +136,13 @@ "output_type": "stream", "text": [ "\n", - "Silent stream below,\n", - "Gleaming scales in dancing waves—\n", - "Fish glide through cool dreams.\n", + "Silver scales in streams, \n", + "Glide through currents, swift and free, \n", + "Water whispers peace.\n", "----------------------------------------\n", - "Silent stream below,\n", - "Gleaming scales in dancing waves—\n", - "Fish glide through cool dreams.\n", + "Silver scales in streams, \n", + "Glide through currents, swift and free, \n", + "Water whispers peace.\n", "\n" ] }, @@ -151,7 +151,7 @@ "output_type": "stream", "text": [ "/Users/nguyendat/Marc/effectful/.venv/lib/python3.12/site-packages/pydantic/main.py:528: UserWarning: Pydantic serializer warnings:\n", - " PydanticSerializationUnexpectedValue(Expected 10 fields but got 6: Expected `Message` - serialized value may not be as expected [field_name='message', input_value=Message(content='{\"value\"...: None}, annotations=[]), input_type=Message])\n", + " PydanticSerializationUnexpectedValue(Expected 10 fields but got 6: Expected `Message` - serialized value may not be as expected [field_name='message', input_value=Message(content=\"Gentle f...: None}, annotations=[]), input_type=Message])\n", " PydanticSerializationUnexpectedValue(Expected `StreamingChoices` - serialized value may not be as expected [field_name='choices', input_value=Choices(finish_reason='st...ider_specific_fields={}), input_type=Choices])\n", " return self.__pydantic_serializer__.to_json(\n" ] @@ -160,21 +160,37 @@ "name": "stdout", "output_type": "stream", "text": [ - "In streams not too deep, \n", - "Silver swimmers glide below, \n", - "Silent fins whisper.\n", - "----------------------------------------\n", - "Silvery fish dart,\n", - "Through the gentle stream they glide—\n", - "Nature's dance unfolds.\n", + "Gentle fish glides through, \n", + "Ripples dance on water's face, \n", + "Silent grace below. \n", + "----------------------------------------\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/nguyendat/Marc/effectful/.venv/lib/python3.12/site-packages/pydantic/main.py:528: UserWarning: Pydantic serializer warnings:\n", + " PydanticSerializationUnexpectedValue(Expected 10 fields but got 6: Expected `Message` - serialized value may not be as expected [field_name='message', input_value=Message(content=\"Swimming...: None}, annotations=[]), input_type=Message])\n", + " PydanticSerializationUnexpectedValue(Expected `StreamingChoices` - serialized value may not be as expected [field_name='choices', input_value=Choices(finish_reason='st...ider_specific_fields={}), input_type=Choices])\n", + " return self.__pydantic_serializer__.to_json(\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Swimming in water, \n", + "Silent scales glimmer like stars, \n", + "Ocean's gentle grace.\n", "\n", - "Fish beneath the waves,\n", - "Silent currents in their dance—\n", - "Nature's quiet grace.\n", + "In the clear pond's gleam, \n", + "Silent fish weave through light streams, \n", + "Nature's whispered dream.\n", "----------------------------------------\n", - "In the whispering stream,\n", - "silver scales dance and shimmer—\n", - "a fleeting shadow.\n" + "Silver scales glisten, \n", + "Silent streams embrace their dance, \n", + "Nature's tranquil grace.\n" ] } ], @@ -262,8 +278,18 @@ "name": "stdout", "output_type": "stream", "text": [ - "def count_a(s: str) -> int:\n", - " return s.count('a')\n" + "def count_a(input_string: str) -> int:\n", + " \"\"\"\n", + " Counts the occurrences of the letter 'a' in the given string.\n", + "\n", + " :param input_string: The string to search for 'a' characters.\n", + " :return: The count of 'a' characters in the string.\n", + " \"\"\"\n", + " count = 0\n", + " for char in input_string:\n", + " if char == 'a':\n", + " count += 1\n", + " return count\n" ] } ], @@ -312,12 +338,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Based on the weather descriptions:\n", - "- **Chicago**: Cold\n", - "- **New York**: Wet\n", - "- **Barcelona**: Sunny\n", - "\n", - "I suggest Barcelona since it has sunny weather, which is generally considered good for most people.\n" + "Based on the current weather conditions, Barcelona has good weather as it is currently sunny.\n" ] } ], @@ -377,7 +398,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "This is an image of a simple yellow smiley face with black eyes and a smile on a yellow background.\n" + "The image is a simple yellow smiley face with black eyes and a curved mouth, set against a bright yellow background.\n" ] } ], @@ -433,7 +454,7 @@ "Who's there?\n", "Lizard.\n", "Lizard who?\n", - "Lizard who? Lizard be a joke if I wasn't at your door!\n", + "Lizard who? Lizard you can do, I can do better!\n", "> The crowd laughs politely.\n" ] } @@ -495,14 +516,28 @@ "text": [ "Sub-templates available to write_story: dict_keys(['limerick', 'haiku_no_cache', 'primes', 'count_char', 'cities', 'weather', 'vacation', 'describe_image', 'write_joke', 'rate_joke', 'story_with_moral', 'story_funny'])\n", "=== Story with moral ===\n", + "Here's a story about a curious cat named Whiskers who embarked on an adventurous journey, only to learn an important lesson about the balance between curiosity and caution. \n", "\n", + "In his quest to follow a mesmerizing butterfly, Whiskers ventured far from home, enchanted by the beauty of nature. Along the way, he encountered a wise old owl who reminded him of the importance of being mindful of his surroundings. Whiskers returned home with newfound wisdom, realizing that while curiosity can lead to wonderful discoveries, it's essential to remain aware and cautious to avoid getting lost.\n", "\n", - "---\n", + "**Moral of the story: Curiosity can lead to remarkable discoveries, but always remember to explore with awareness and caution to avoid losing your way.**\n", "\n", "=== Funny story ===\n", + "Once upon a time in a cozy little town, there lived a cat named Whiskers. Whiskers wasn't your ordinary feline; he was endowed with insatiable curiosity and an unerring sense of mischief. His favorite pastime was sneaking around his neighborhood, peeking into every nook and cranny, much to the chagrin of the townspeople.\n", "\n", + "Whiskers' latest adventure began when he spotted a shiny object inside Mr. Johnson's garage. The door was slightly ajar, just enough for a slender cat to slip through. Of course, he couldn't resist the temptation. With a twitch of his tail and a playful leap, Whiskers was inside.\n", "\n", - "And so, Whiskers the curious cat continued to slink through life, tail high, always ready for another amusing escapade.\n" + "As soon as he entered, he was faced with the most peculiar sight—an old automatic vacuum cleaner. To Whiskers, this was no ordinary device. He saw it as a mysterious, growling beast that needed to be conquered. With a mighty pounce, he landed squarely on top of it. Unfortunately for Whiskers, he had activated the vacuum, sending it whirring around the garage with him clinging for dear life.\n", + "\n", + "The “wild ride” that ensued was a scene to behold. The vacuum zoomed forward, backward, and in crazy circles, causing Mr. Johnson’s carefully stacked boxes to tumble down like dominoes. Paint cans rattled, and a garden gnome was knocked over, all while Whiskers' fur stood on end with a mixture of excitement and terror.\n", + "\n", + "Hearing the commotion, Mrs. Johnson rushed to the garage, expecting to find some sort of intruder. Instead, she found Whiskers riding the chaos-inducing vacuum. With laughter bubbling up, she quickly turned off the machine, releasing a very dizzy kitty.\n", + "\n", + "Whiskers stumbled off, shook his furry head, and gave Mrs. Johnson a look that seemed to say, \"I totally meant to do that.\" She couldn't help but laugh and gave Whiskers a good scratch behind the ears as a reward for his bravery.\n", + "\n", + "After this hair-raising adventure, Whiskers promised himself to stay out of the Johnsons' garage, at least for a day or two. Instead, he decided to take a nap in a sunbeam, where the only danger was a dream about chasing more imaginary beasts.\n", + "\n", + "And so, in the little town, Whiskers cemented his reputation as the cat who tamed the wild, untamable vacuum, and the townsfolk couldn't wait to see what this curious feline would do next.\n" ] } ], @@ -541,6 +576,98 @@ " print(write_story(\"a curious cat\", \"funny\"))" ] }, + { + "cell_type": "markdown", + "id": "z7yy72fumf", + "metadata": {}, + "source": [ + "## Agents and Lexical Scoping\n", + "\n", + "`Agent` serves as a natural mechanism for encapsulating Templates and Tools into disjoint sets. Because templates capture tools from their **lexical scope** (following Python's ordinary scoping rules), templates on one agent instance cannot see templates on another instance — unless they are explicitly in scope.\n", + "\n", + "This means:\n", + "- An agent's methods see sibling methods on the same instance (via `self`) and any tools defined in enclosing scopes.\n", + "- Templates defined inside a function body see local variables in that function, but not variables from other function bodies.\n", + "- A top-level orchestrator template can reference multiple agents, while those agents' own methods remain isolated from each other." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "sk04iylc4vh", + "metadata": {}, + "outputs": [], + "source": [ + "class Chatbot(Agent):\n", + " \"\"\"You are a friendly chatbot.\"\"\"\n", + "\n", + " @Template.define\n", + " def respond(self, user_query: str) -> str:\n", + " \"\"\"Respond to the user's query: {user_query}.\"\"\"\n", + " raise NotHandled\n", + "\n", + " @Template.define\n", + " def greet(self, user_name: str) -> str:\n", + " \"\"\"Greet {user_name} warmly.\"\"\"\n", + " raise NotHandled\n", + "\n", + "\n", + "class TravelAdvisor(Agent):\n", + " \"\"\"You are a travel advisor.\"\"\"\n", + "\n", + " @Template.define\n", + " def recommend_destination(self, user_query: str) -> str:\n", + " \"\"\"Recommend a travel destination based on {user_query}.\"\"\"\n", + " raise NotHandled\n", + "\n", + " @Tool.define\n", + " def search_weather(self, city: str) -> str:\n", + " \"\"\"Search the weather for a given city.\"\"\"\n", + " return {\"Paris\": \"sunny\", \"London\": \"rainy\"}.get(city, \"unknown\")\n", + "\n", + "\n", + "def main():\n", + " chatbot = Chatbot()\n", + " another_chatbot = Chatbot() # noqa: F841\n", + " travel_advisor = TravelAdvisor() # noqa: F841\n", + "\n", + " @Template.define\n", + " def simulate_user_interaction() -> str:\n", + " \"\"\"Use {another_chatbot} and {travel_advisor} to simulate an interaction between a user and assistant.\"\"\"\n", + " raise NotHandled\n", + "\n", + " # chatbot.respond sees write_poem (enclosing module scope) and chatbot.greet (sibling via self),\n", + " # but NOT another_chatbot's or travel_advisor's methods (different lexical scope).\n", + " assert \"self__greet\" in chatbot.respond.tools\n", + " assert \"self__recommend_destination\" not in chatbot.respond.tools\n", + "\n", + " # simulate_user_interaction sees all agents (they're local variables in main),\n", + " # but none of those agents' methods can see simulate_user_interaction.\n", + " assert \"another_chatbot__respond\" in simulate_user_interaction.tools\n", + " assert \"travel_advisor__recommend_destination\" in simulate_user_interaction.tools\n", + " assert \"simulate_user_interaction\" not in chatbot.respond.tools\n", + "\n", + " print(\"chatbot.respond tools:\", list(chatbot.respond.tools.keys()))\n", + " print(\n", + " \"simulate_user_interaction tools:\", list(simulate_user_interaction.tools.keys())\n", + " )\n", + "\n", + "\n", + "main()" + ] + }, + { + "cell_type": "markdown", + "id": "3g84db95wof", + "metadata": {}, + "source": [ + "In this example, `chatbot.respond` has access to `write_poem` (module scope) and `chatbot.greet` (sibling method via `self`), but **not** to `another_chatbot.respond` or `travel_advisor.recommend_destination`. Meanwhile, `simulate_user_interaction` (defined inside `main`) can see `chatbot`, `another_chatbot`, and `travel_advisor`, but none of those agents' methods can see it back.\n", + "\n", + "This is exactly the behavior you'd expect for ordinary Python objects — if `Chatbot.respond` were an ordinary instance method, it wouldn't have access to lexical variables in the body of `main`.\n", + "\n", + "In contrast, if the body of `main` were inlined into the top-level module scope (where `Chatbot` and `TravelAdvisor` are defined), this encapsulation would be broken and all the templates would see all of the others." + ] + }, { "cell_type": "markdown", "id": "bd25826d", @@ -564,7 +691,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 12, "id": "4334d07a", "metadata": {}, "outputs": [ @@ -573,7 +700,7 @@ "output_type": "stream", "text": [ "Error: Tool execution failed: Error executing tool 'unstable_service': Service unavailable! Attempt 1/3. Please retry.\n", - "Result: The unstable service successfully returned the following data: `[1, 2, 3]`. Retries: 3\n" + "Result: I successfully fetched the data: `[1, 2, 3]`. Retries: 3\n" ] } ], @@ -622,7 +749,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 13, "id": "39b2b225", "metadata": {}, "outputs": [ @@ -630,11 +757,11 @@ "name": "stdout", "output_type": "stream", "text": [ - "Error: Error decoding response: 1 validation error for Response\n", + "Error: Error decoding response: 1 validation error for Response_Rating\n", "value.score\n", " score must be 1–5, got 9 [type=invalid_score, input_value=9, input_type=int]. Please provide a valid response and try again.\n", "Score: 5/5\n", - "Explanation: Die Hard is a quintessential action film that has deeply influenced the genre. Its engaging storyline, memorable characters, and groundbreaking action scenes have made it a beloved classic. The film's humor and suspense balance combined with Bruce Willis' iconic performance contribute to its enduring appeal. It rightfully earns a top score of 5 out of 5 for its impact and entertainment value.\n" + "Explanation: Die Hard is a timeless action film that stands out for its tight storytelling, engaging action scenes, and memorable performances. The movie effectively combines suspense, humor, and character development, making it a beloved classic in the action genre. Given its impact and quality, I would rate it a 5 out of 5.\n" ] } ], @@ -697,7 +824,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 14, "id": "9d02bc67", "metadata": {}, "outputs": [ @@ -707,49 +834,54 @@ "text": [ "Sub-templates available to write_story: dict_keys(['limerick', 'haiku_no_cache', 'primes', 'count_char', 'cities', 'weather', 'vacation', 'describe_image', 'write_joke', 'rate_joke', 'story_with_moral', 'story_funny', 'write_story', 'unstable_service', 'fetch_data', 'give_rating_for_movie', 'write_chapter', 'judge_chapter'])\n", "=== Story with moral ===\n", - "def generate_moral_story(topic: str) -> str:\n", - " story_so_far = \"\"\n", - " chapter_number = 1\n", - " chapter_name_prefix = \"Chapter\"\n", + "def write_moral_story(topic: str) -> str:\n", + " story = \"\" # Initialize an empty story string\n", + " chapter_number = 1 # Start with the first chapter\n", " \n", " while True:\n", + " chapter_name = f\"Chapter {chapter_number}: {topic}\"\n", " try:\n", - " chapter_name = f\"{chapter_name_prefix} {chapter_number}\"\n", - " chapter = write_chapter(chapter_number, chapter_name)\n", - " if judge_chapter(story_so_far, chapter_number):\n", - " story_so_far += chapter + \"\\n\"\n", - " chapter_number += 1\n", - " \n", - " # For the purpose of the demonstration, let's stop after 3 chapters\n", - " if chapter_number > 3:\n", - " break\n", - " else:\n", - " # If the chapter isn't coherent, we might revise it or try a different topic.\n", - " chapter_number += 1\n", - " continue\n", + " chapter_text = write_chapter(chapter_number, chapter_name)\n", + " story += chapter_text + \"\\n\"\n", + " \n", + " if not judge_chapter(story, chapter_number):\n", + " break\n", + " \n", + " chapter_number += 1 # Move to the next chapter\n", " except Exception as e:\n", - " # Handle exception by logging or showing a message, then continue\n", - " print(f\"An error occurred: {e}. Trying again.\")\n", - " continue \n", + " # Handle any exceptions raised by the helper functions\n", + " return f\"An error occurred while trying to write the story: {str(e)}\"\n", + " \n", + " return story\n", + "\n", + "Once upon a time in the small, quaint village of Numeria, there was a mysterious legend surrounding the number \"1.\" It was believed to be the most powerful and significant number in existence, the origin of all numbers and the building block of the universe.\n", + "\n", + "The villagers worshipped the number \"1\" with great reverence, attributing it to unity, beginnings, and singularity. They celebrated a special festival each year called \"Uno Fest\" to honor this special number. At the center of the festival was a magical ceremony where they would light a single candle, representing the oneness of life.\n", + "\n", + "On the eve of the current year's Uno Fest, a young boy named Eli was curious to discover the true power of the number \"1.\" Eli believed that if he could understand its power, he could bring joy and prosperity to everyone in the village. He wandered into the forest, known for its mystical aura, hoping to find answers.\n", + "\n", + "As he ventured deeper into the woods, Eli stumbled upon an ancient stone tablet inscribed with the symbol \"1.\" As he touched it, a wise old spirit appeared. \"Young Eli,\" the spirit spoke gently, \"what do you seek?\" Eli replied, \"I want to understand the true power of '1.'\"\n", + "\n", + "The spirit smiled and began to explain, \"The power of '1' lies not in its numerical value, but in its meaning. It is the essence of unity and the beginning of everything. It symbolizes the potential in new beginnings and the importance of staying true to oneself. Every great journey begins with one single step.\"\n", "\n", - " return story_so_far\n", - "Once upon a time, in the quaint town of Arithmetville, there was a number named Four. Four lived a simple life in the Number Kingdom where each digit was celebrated for its unique role. The citizens, ranging from One to Nine, all had their special talents, but Four often felt overshadowed by the glamour of Seven or the strength of Nine.\n", + "Inspired by the spirit's words, Eli returned to the village filled with newfound wisdom. At the Uno Fest, he shared his experience with the villagers, preaching the importance of unity and the power within each of them to make a positive difference. The festival of celebration turned into a day of reflection and unity, solidifying the village's bond.\n", "\n", - "Four was neat and symmetrical, embodying balance and order. However, despite its perfect symmetry, Four struggled with feelings of inadequacy. \"I'm just ordinary,\" Four would sigh, watching Three, the number of harmony and growth, excel in social gatherings with its effortless charisma.\n", + "And so, the legend of the number \"1\" continued, carrying forward the eternal truth that every great achievement starts with a single, powerful step. Eli's journey reminded everyone in Numeria that they, too, had the power to begin anew, united in their strength and purpose. The moral of the story was clear: In unity, there is strength, and every great journey begins with one small step.\n", + "As the first rays of the sun began to peek over the horizon, an old clockmaker named Elias woke up in the small village of Havenwood. Elias had lived in the village all his life, surrounded by ticking clocks and the gentle hum of their mechanisms. Each clock in his shop was a testament to his craftsmanship and passion.\n", "\n", - "One bright and sunny day, a problem arose in the Number Kingdom when Number Madness—a chaotic jumble that scrambled numbers out of order—descended upon the kingdom. The great leader Ten gathered all the digits to find a solution.\n", + "Elias had a special fondness for the number 2, considering it his lucky number. All his life, he noticed that significant events happened in pairs: two opportunities to become a clockmaker, two chances to meet the love of his life, and eventually two beautiful children. He often mused, \"Things come in pairs for a reason.\"\n", "\n", - "\"We need someone who can provide stability and order to defeat Number Madness,\" Ten declared.\n", + "One fine morning, as Elias was dusting and winding the clocks in his shop, he stumbled upon an old, dusty wooden box. The box wasn't remarkable in appearance, but when Elias opened it, he found two identical pocket watches, each intricately designed with silver filigree and an unmistakable engraving of a pair of doves. How these watches ended up in the shop was a mystery, but Elias decided to restore them.\n", "\n", - "Six said it was too curvy, and Eight, though powerful, said it was often mistaken for infinity and couldn't help. But the wise old Zero whispered, \"What about Four?\"\n", + "Days turned into weeks, and Elias worked meticulously on the pocket watches. He polished every surface, repaired the delicate gears, and reattached the hands. Upon completion, he marveled at their beauty and symmetry—two perfect creations reflecting his life's philosophy.\n", "\n", - "Hesitant but hopeful, Four stepped forward. Armed with knowledge of perfect divisions and its role in creating stability, Four devised a plan. Using its even nature, Four aligned the numbers perfectly, counteracting the chaos with its impeccable sense of balance. Number Madness was soon vanquished.\n", + "As fate would have it, a traveler visited Havenwood and was drawn to Elias’s shop. The traveler, a young woman named Amelia, was captivated by the twins’ watches. She explained that she was on a journey to find something significant, something that spoke to her heart.\n", "\n", - "The kingdom cheered, and even Seven and Nine applauded Four. For the first time, Four felt proud, realizing that everyone, including itself, played an integral role in the grand equation of life.\n", + "Elias, sensing the unique connection between the watches and this traveler, shared their story. He spoke of his life of dualities and how everything always seemed to come in pairs, especially the most meaningful elements. Touched by his tale, Amelia purchased both watches, declaring one would be for her and the other for her twin sister, whom she had not seen in years.\n", "\n", - "From that day forward, Four embraced its identity and continued to be the sturdy backbone of stability in the Number Kingdom. And so, the simple truth was revealed: It's in the everyday skill of balancing that greatness is found.\n", + "As Amelia left the shop, Elias felt a surge of contentment. He realized that his belief in the power of two was not just a whimsical notion, but a living testament to the interconnectedness of lives and the potential of renewed connections.\n", "\n", - "**Moral of the story:** Embrace who you are, for every role is vital, and true advantage often lies in what makes you different.\n", + "Amelia's story and the paired watches’ journey reminded Elias of the simple truth: that sometimes, things are meant to be found—not alone, but together. Whether it was pairs of events or connections, the number 2 would always hold a special place in the heart of a humble clockmaker who saw symmetry and significance in every ticking second.\n", "\n", "\n" ] @@ -818,4 +950,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/effectful/handlers/llm/encoding.py b/effectful/handlers/llm/encoding.py index bcfa15a4..6b42c232 100644 --- a/effectful/handlers/llm/encoding.py +++ b/effectful/handlers/llm/encoding.py @@ -31,7 +31,7 @@ from PIL import Image import effectful.handlers.llm.evaluation as evaluation -from effectful.handlers.llm.template import Tool +from effectful.handlers.llm.template import Agent, Tool from effectful.internals.unification import nested_type from effectful.ops.semantics import _simple_type from effectful.ops.syntax import _CustomSingleDispatchCallable @@ -975,3 +975,40 @@ def _encodable_tool_call[T]( ) -> Encodable[DecodedToolCall[T], ChatCompletionMessageToolCall]: ctx = ctx or {} return ToolCallEncodable(ty, ChatCompletionMessageToolCall, ctx) + + +@dataclass +class AgentEncodable[T: Agent](Encodable[T, str]): + base: type[T] + enc: type[str] + ctx: Mapping[str, Any] + + def encode(self, value: T) -> str: + parts = [f"Agent: {type(value).__name__}"] + parts.append(f"Description: {inspect.getdoc(type(value))}") + methods = [] + for cls in type(value).__mro__: + for attr_name in vars(cls): + attr = getattr(value, attr_name, None) + if isinstance(attr, Tool): + sig = inspect.signature(attr) + methods.append(f" - {attr_name}{sig}") + if methods: + parts.append("Available methods:\n" + "\n".join(methods)) + return "\n".join(parts) + + def decode(self, encoded_value: str) -> T: + raise TypeError("Agents cannot be decoded from LLM responses") + + def serialize(self, encoded_value: str) -> Sequence[ChatCompletionTextObject]: + return [{"type": "text", "text": encoded_value}] + + def deserialize(self, serialized_value: str) -> str: + raise TypeError("Agents cannot be deserialized from LLM responses") + + +@Encodable.define.register(Agent) +def _encodable_agent[T: Agent]( + ty: type[T], ctx: Mapping[str, Any] | None +) -> Encodable[T, str]: + return AgentEncodable(ty, str, ctx or {}) diff --git a/tests/test_handlers_llm_encoding.py b/tests/test_handlers_llm_encoding.py index bbcda789..d2be2afd 100644 --- a/tests/test_handlers_llm_encoding.py +++ b/tests/test_handlers_llm_encoding.py @@ -19,12 +19,13 @@ from PIL import Image from effectful.handlers.llm.encoding import ( + AgentEncodable, DecodedToolCall, Encodable, SynthesizedFunction, ) from effectful.handlers.llm.evaluation import RestrictedEvalProvider, UnsafeEvalProvider -from effectful.handlers.llm.template import Tool +from effectful.handlers.llm.template import Agent, Template, Tool from effectful.internals.unification import nested_type from effectful.ops.semantics import handler from effectful.ops.types import Operation, Term @@ -474,6 +475,66 @@ def test_define_raises_for_invalid_types(ty): Encodable.define(ty) +# ============================================================================ +# Agent-specific: encode/serialize succeed, decode/deserialize raise TypeError +# ============================================================================ + + +class _TestAgent(Agent): + """A test agent for encoding.""" + + @Template.define + def greet(self, name: str) -> str: + """Greet {name}.""" + raise NotImplementedError + + @Tool.define + def lookup(self, key: str) -> str: + """Look up a key.""" + return key + + +def test_agent_encodable_dispatches(): + enc = Encodable.define(type(_TestAgent())) + assert isinstance(enc, AgentEncodable) + + +def test_agent_encode_contains_class_name_and_doc(): + agent = _TestAgent() + enc = Encodable.define(type(agent)) + text = enc.encode(agent) + assert "Agent: _TestAgent" in text + assert "A test agent for encoding." in text + + +def test_agent_encode_lists_methods(): + agent = _TestAgent() + enc = Encodable.define(type(agent)) + text = enc.encode(agent) + assert "greet" in text + assert "lookup" in text + + +def test_agent_serialize_returns_text_block(): + agent = _TestAgent() + enc = Encodable.define(type(agent)) + blocks = enc.serialize(enc.encode(agent)) + assert len(blocks) == 1 + assert blocks[0]["type"] == "text" + + +def test_agent_decode_raises(): + enc = Encodable.define(type(_TestAgent())) + with pytest.raises(TypeError): + enc.decode("anything") + + +def test_agent_deserialize_raises(): + enc = Encodable.define(type(_TestAgent())) + with pytest.raises(TypeError): + enc.deserialize("anything") + + # ============================================================================ # Image-specific: deserialize raises, decode rejects invalid URLs # ============================================================================