From dbdb441e6226017683c9f153b9a7e602aca0080c Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 18 Feb 2025 15:14:28 -0800 Subject: [PATCH] Updates some skill lib docs. Adds some skill routines. (#328) --- .../assistant/skill_assistant.py | 192 ++++++------- .../openai-client/openai_client/tools.py | 5 + .../python/skills/skill-library/README.md | 131 +++------ .../skill-library/skill_library/engine.py | 12 +- .../skill-library/skill_library/skill.py | 18 +- .../skill_library/skills/common/__init__.py | 4 +- .../skills/common/routines/consolidate.py | 68 +++++ .../common/routines/execute_research_plan.py | 16 -- .../common/routines/generate_routine.py | 105 -------- .../common/routines/select_user_intent.py | 13 +- .../skills/common/routines/summarize.py | 15 +- .../skill_library/skills/eval/__init__.py | 3 + .../skill_library/skills/eval/eval_skill.py | 16 ++ .../skills/eval/routines/eval.py | 58 ++++ .../skill_library/skills/meta/__init__.py | 3 + .../skill_library/skills/meta/meta_skill.py | 16 ++ .../skills/meta/routines/generate_routine.py | 253 ++++++++++++++++++ .../skill_library/skills/posix/__init__.py | 4 +- .../skill_library/skills/research/README.md | 41 +++ .../skill_library/skills/research/__init__.py | 3 + .../skills/research/research_skill.py | 16 ++ .../routines/answer_question_about_content.py | 13 +- .../routines/evaluate_answer.py | 14 +- .../routines/generate_research_plan.py | 12 +- .../routines/generate_search_query.py | 11 +- .../routines/update_research_plan.py | 37 ++- .../routines/web_research.py | 24 +- .../routines/web_search.py | 15 +- .../skill-library/skill_library/types.py | 30 ++- .../skill-library/skill_library/usage.py | 94 ++++--- 30 files changed, 813 insertions(+), 429 deletions(-) create mode 100644 libraries/python/skills/skill-library/skill_library/skills/common/routines/consolidate.py delete mode 100644 libraries/python/skills/skill-library/skill_library/skills/common/routines/execute_research_plan.py delete mode 100644 libraries/python/skills/skill-library/skill_library/skills/common/routines/generate_routine.py create mode 100644 libraries/python/skills/skill-library/skill_library/skills/eval/__init__.py create mode 100644 libraries/python/skills/skill-library/skill_library/skills/eval/eval_skill.py create mode 100644 libraries/python/skills/skill-library/skill_library/skills/eval/routines/eval.py create mode 100644 libraries/python/skills/skill-library/skill_library/skills/meta/__init__.py create mode 100644 libraries/python/skills/skill-library/skill_library/skills/meta/meta_skill.py create mode 100644 libraries/python/skills/skill-library/skill_library/skills/meta/routines/generate_routine.py create mode 100644 libraries/python/skills/skill-library/skill_library/skills/research/README.md create mode 100644 libraries/python/skills/skill-library/skill_library/skills/research/__init__.py create mode 100644 libraries/python/skills/skill-library/skill_library/skills/research/research_skill.py rename libraries/python/skills/skill-library/skill_library/skills/{common => research}/routines/answer_question_about_content.py (74%) rename libraries/python/skills/skill-library/skill_library/skills/{common => research}/routines/evaluate_answer.py (86%) rename libraries/python/skills/skill-library/skill_library/skills/{common => research}/routines/generate_research_plan.py (92%) rename libraries/python/skills/skill-library/skill_library/skills/{common => research}/routines/generate_search_query.py (89%) rename libraries/python/skills/skill-library/skill_library/skills/{common => research}/routines/update_research_plan.py (72%) rename libraries/python/skills/skill-library/skill_library/skills/{common => research}/routines/web_research.py (65%) rename libraries/python/skills/skill-library/skill_library/skills/{common => research}/routines/web_search.py (76%) diff --git a/assistants/skill-assistant/assistant/skill_assistant.py b/assistants/skill-assistant/assistant/skill_assistant.py index 82ef87e8..01bedb1c 100644 --- a/assistants/skill-assistant/assistant/skill_assistant.py +++ b/assistants/skill-assistant/assistant/skill_assistant.py @@ -31,9 +31,11 @@ ConversationContext, ) from skill_library import Engine -from skill_library.skills.common.common_skill import CommonSkill, CommonSkillConfig -from skill_library.skills.posix.posix_skill import PosixSkill, PosixSkillConfig -from skill_library.usage import get_routine_usage +from skill_library.skills.common import CommonSkill, CommonSkillConfig +from skill_library.skills.eval import EvalSkill, EvalSkillConfig +from skill_library.skills.meta import MetaSkill, MetaSkillConfig +from skill_library.skills.posix import PosixSkill, PosixSkillConfig +from skill_library.skills.research import ResearchSkill, ResearchSkillConfig from assistant.skill_event_mapper import SkillEventMapper from assistant.workbench_helpers import WorkbenchMessageProvider @@ -81,31 +83,15 @@ async def content_evaluator_factory(conversation_context: ConversationContext) - app = assistant_service.fastapi_app() -# The AssistantApp class provides a set of decorators for adding event handlers -# to respond to conversation events. In VS Code, typing "@assistant." (or the -# name of your AssistantApp instance) will show available events and methods. -# -# See the semantic-workbench-assistant AssistantApp class for more information -# on available events and methods. Examples: -# - @assistant.events.conversation.on_created (event triggered when the -# assistant is added to a conversation) -# - @assistant.events.conversation.participant.on_created (event triggered when -# a participant is added) -# - @assistant.events.conversation.message.on_created (event triggered when a -# new message of any type is created) -# - @assistant.events.conversation.message.chat.on_created (event triggered when -# a new chat message is created) - -# This assistant registry is used to manage the assistants for this service and +# This engine registry is used to manage the skill engines for this service and # to register their event subscribers so we can map events to the workbench. # -# NOTE: Currently, the skill assistant library doesn't have the notion of -# "conversations" so we map a skill library assistant to a particular -# conversation in the workbench. This means if you have a different conversation -# with the same "skill assistant" it will appear as a different assistant in the -# skill assistant library. We can improve this in the future by adding a -# conversation ID to the skill assistant library and mapping it to a -# conversation in the workbench. +# NOTE: Currently, the skill library doesn't have the notion of "conversations" +# so we map a skill library engine to a particular conversation in the +# workbench. This means if you have a different conversation with the same +# "skill assistant" it will appear as a different engine in the skill assistant +# library. We can improve this in the future by adding a conversation ID to the +# skill library and mapping it to a conversation in the workbench. engine_registry = SkillEngineRegistry() @@ -116,7 +102,7 @@ async def on_conversation_created(conversation_context: ConversationContext) -> Handle the event triggered when the assistant is added to a conversation. """ - # send a welcome message to the conversation + # Send a welcome message to the conversation. config = await assistant_config.get(conversation_context.assistant) welcome_message = config.welcome_message await conversation_context.send_messages( @@ -128,52 +114,16 @@ async def on_conversation_created(conversation_context: ConversationContext) -> ) -@assistant_service.events.conversation.message.chat.on_created -async def on_message_created( - conversation_context: ConversationContext, event: ConversationEvent, message: ConversationMessage -) -> None: - """Handle new chat messages""" - logger.debug("Message received", extra_data({"content": message.content})) - - config = await assistant_config.get(conversation_context.assistant) - engine = await get_or_register_skill_engine(conversation_context, config) - - # Check if routine is running - if engine.is_routine_running(): - try: - logger.debug("Resuming routine with message", extra_data({"message": message.content})) - resume_task = asyncio.create_task(engine.resume_routine(message.content)) - resume_task.add_done_callback( - lambda t: logger.debug("Routine resumed", extra_data({"success": not t.exception()})) - ) - except Exception as e: - logger.error(f"Failed to resume routine: {e}") - finally: - return - - # Use a chat driver to respond. - async with conversation_context.set_status("thinking..."): - chat_driver_config = ChatDriverConfig( - openai_client=openai_client.create_client(config.service_config), - model=config.chat_driver_config.openai_model, - instructions=config.chat_driver_config.instructions, - message_provider=WorkbenchMessageProvider(conversation_context.id, conversation_context), - functions=ChatFunctions(engine).list_functions(), - ) - chat_driver = ChatDriver(chat_driver_config) - chat_functions = ChatFunctions(engine) - chat_driver_config.functions = [chat_functions.list_routines] - - metadata: dict[str, Any] = {"debug": {"content_safety": event.data.get(content_safety.metadata_key, {})}} - await chat_driver.respond(message.content, metadata=metadata or {}) - - @assistant_service.events.conversation.message.command.on_created async def on_command_message_created( conversation_context: ConversationContext, event: ConversationEvent, message: ConversationMessage ) -> None: """ - Handle the event triggered when a new command message is created in the conversation. + Handle the event triggered when a new command message is created in the + conversation. Commands in the skill assistant currently are oriented around + running skills manually. We will update this in the future to add a few more + commands that we'll register to the chat driver so we can call them + conversationally. """ config = await assistant_config.get(conversation_context.assistant) @@ -188,7 +138,8 @@ async def on_command_message_created( ```markdown - __/help__: Display this help message. - __/list_routines__: List all routines. - - __/run("<name>", ...args)__: Run a routine. + - __/run__("<name>", ...args): Run a routine. + - __/reset__: Reset the assistant. ``` """).strip() await conversation_context.send_messages( @@ -198,6 +149,10 @@ async def on_command_message_created( ), ) case _: + """ + For every other command we receive, we're going to try to map it to + one of the registered ChatFunctions below and execute the command. + """ try: function_string, args, kwargs = ToolFunctions.parse_fn_string(command_string) if not function_string: @@ -225,16 +180,61 @@ async def on_command_message_created( await conversation_context.send_messages( NewConversationMessage( content=str(result), - message_type=MessageType.notice, + message_type=MessageType.note, ), ) -# Get or register an assistant for the conversation. +@assistant_service.events.conversation.message.chat.on_created +async def on_message_created( + conversation_context: ConversationContext, event: ConversationEvent, message: ConversationMessage +) -> None: + """Handle new chat messages""" + logger.debug("Message received", extra_data({"content": message.content})) + + config = await assistant_config.get(conversation_context.assistant) + engine = await get_or_register_skill_engine(conversation_context, config) + + # Check if routine is running. + if engine.is_routine_running(): + try: + logger.debug("Resuming routine with message", extra_data({"message": message.content})) + resume_task = asyncio.create_task(engine.resume_routine(message.content)) + resume_task.add_done_callback( + lambda t: logger.debug("Routine resumed", extra_data({"success": not t.exception()})) + ) + except Exception as e: + logger.error(f"Failed to resume routine: {e}") + finally: + return + + # Use a chat driver to respond. + async with conversation_context.set_status("thinking..."): + chat_driver_config = ChatDriverConfig( + openai_client=openai_client.create_client(config.service_config), + model=config.chat_driver_config.openai_model, + instructions=config.chat_driver_config.instructions, + message_provider=WorkbenchMessageProvider(conversation_context.id, conversation_context), + functions=ChatFunctions(engine).list_functions(), + ) + chat_driver = ChatDriver(chat_driver_config) + chat_functions = ChatFunctions(engine) + chat_driver_config.functions = [chat_functions.list_routines] + + metadata: dict[str, Any] = {"debug": {"content_safety": event.data.get(content_safety.metadata_key, {})}} + await chat_driver.respond(message.content, metadata=metadata or {}) + + async def get_or_register_skill_engine( conversation_context: ConversationContext, config: AssistantConfigModel ) -> Engine: - # Get an assistant from the registry. + """ + Get or register a skill engine for the conversation. This is used to manage + the skill engines for this service and to register their event subscribers + so we can map events to the workbench. + """ + + # Get an engine from the registry. engine_id = conversation_context.id engine = engine_registry.get_engine(engine_id) @@ -246,12 +246,18 @@ async def get_or_register_skill_engine( language_model = openai_client.create_client(config.service_config) message_provider = WorkbenchMessageProvider(engine_id, conversation_context) + # Create the engine and register it. This is where we configure which + # skills the engine can use and their configuration. engine = Engine( engine_id=conversation_context.id, message_history_provider=message_provider.get_history, drive_root=assistant_drive_root, metadata_drive_root=assistant_metadata_drive_root, skills=[ + ( + MetaSkill, + MetaSkillConfig(name="meta", language_model=language_model, drive=assistant_drive.subdrive("meta")), + ), ( CommonSkill, CommonSkillConfig( @@ -260,6 +266,14 @@ async def get_or_register_skill_engine( drive=assistant_drive.subdrive("common"), ), ), + ( + EvalSkill, + EvalSkillConfig( + name="eval", + language_model=language_model, + drive=assistant_drive.subdrive("eval"), + ), + ), ( PosixSkill, PosixSkillConfig( @@ -268,12 +282,14 @@ async def get_or_register_skill_engine( mount_dir="/mnt/data", ), ), - # GuidedConversationSkillDefinition( - # name="guided_conversation", - # language_model=language_model, - # drive=assistant_drive.subdrive("guided_conversation"), - # chat_driver_config=chat_driver_config, - # ), + ( + ResearchSkill, + ResearchSkillConfig( + name="research", + language_model=language_model, + drive=assistant_drive.subdrive("research"), + ), + ), ], ) @@ -284,35 +300,25 @@ async def get_or_register_skill_engine( class ChatFunctions: """ - These functions provide usage context and output markdown. It's a layer - closer to the assistant. + These are functions that can be run from the chat. """ def __init__(self, engine: Engine) -> None: self.engine = engine - async def clear_stack(self) -> str: - """Clears the assistant's routine stack and event queue.""" + async def reset(self) -> str: + """Resets the skill engine run state. Useful for troubleshooting.""" await self.engine.clear(include_drives=False) return "Assistant stack cleared." async def list_routines(self) -> str: """Lists all the routines available in the assistant.""" - routines: list[str] = [] - for skill_name, skill in self.engine._skills.items(): - for routine_name in skill.list_routines(): - routine = skill.get_routine(routine_name) - if not routine: - continue - usage = get_routine_usage(routine, f"{skill_name}.{routine_name}") - routines.append(f"- {usage.to_markdown()}") - + routines = self.engine.routines_usage() if not routines: return "No routines available." - routine_string = "```markdown\n" + "\n".join(routines) + "\n```" - return routine_string + return "```markdown\n" + routines + "\n```" async def run(self, designation: str, *args, **kwargs) -> str: try: diff --git a/libraries/python/openai-client/openai_client/tools.py b/libraries/python/openai-client/openai_client/tools.py index 64a59e43..65b35b5c 100644 --- a/libraries/python/openai-client/openai_client/tools.py +++ b/libraries/python/openai-client/openai_client/tools.py @@ -295,6 +295,11 @@ async def execute_function_string(self, function_string: str, string_response: b @staticmethod def parse_fn_string(function_string: str) -> tuple[str | None, list[Any], dict[str, Any]]: + """ + Parse a string representing a function call into its name, positional + arguments, and keyword arguments. + """ + # As a convenience, remove any leading slashes. function_string = function_string.lstrip("/") diff --git a/libraries/python/skills/skill-library/README.md b/libraries/python/skills/skill-library/README.md index 002ced89..072dc14a 100644 --- a/libraries/python/skills/skill-library/README.md +++ b/libraries/python/skills/skill-library/README.md @@ -7,59 +7,50 @@ a.k.a. agents. It does this through the concept of a "skill". ### Skills -Think of a skill as a package of assistant capabilities. A skill can contain -"[actions](#actions)" that an assistant can perform and -"[routines](#routines-and-routine-runners)" that are entire procedures (which use -actions) which an assistant can run. +Think of a skill as a package of assistant capabilities. A skill contains +"[routines](#routines)" that are entire procedures an assistant can run. Using an everyday example in our own lives, you can imagine hiring a chef to -cook you a meal. The chef would be skilled at actions in the kitchen (like -chopping or mixing or frying) but would also be able to perform full routines -(recipes), allowing them to make particular dishes according to your preferences. - -A demonstration [Posix skill](../skills/posix-skill/README.md) (file system -interaction) is provided. Various actions are provided in the skill that provide -posix-like ability to manage a file system (creating directories and files, -listing files, reading files, etc.). In addition, though, a routine is provided -that can create a user directory with all of its associated sub directories. - -To create a skill, a developer writes the skills actions and routines and puts -them in a [`Skill`](./skill_library/skill.py) class along with a -`SkillDefinition` used to configure the skill. +cook you a meal. The chef would be skilled at doing things in the kitchen (like +chopping or mixing or frying) and would also be able to execute full recipes, +allowing them to make particular dishes according to your preferences. All of +these actions can be encoded in a skill with routines. + +A [Posix skill](./skill_library/skills/posix/) (file system +interaction) is provided. Various routines are provided in the skill that +provide posix-like ability to manage a file system (creating directories and +files, listing files, reading files, etc.). In addition, though, a "compound" +routine (one that runs other routines) is provided that can create a user +directory with all of its associated sub directories. + +We ship this and some other skill packages with the library +[here](./skill_library/skills/), but you can import skill packages from +anywhere. When a skill engine is registered to an assistant, a user will be able to see the -skill's actions by running the message command `/list_actions` and routines with -`/list_routines`. - -The skill library helps in maintaining and distributing functionality with -skills as each skill is a separate Python package, however skills refer to other -skills using a [skill registry](#skill-registry). +skill's routines by running the message command `/list_routines`. See: [skill.py](./skill_library/skill.py) -#### Actions - -Actions can be any Python function. Their only requirement is that they take a -[`RunContext`](#run-context) as their first argument. - -See: [actions.py](./skill_library/actions.py) - -#### Routines and Routine Runners +#### Routines -Routines are instructions that guide an agent to perform a set of actions in a +Routines are instructions that guide an agent to perform a program in a prescribed manner, oftentimes in collaboration with users over many -interactions. A routine and its routine runner is kindof like a recipe (routine) -for a chef (routine runner). The routine contains the instructions and the -runner is responsible for following those instructions. +interactions. + +Implementation-wise, a routine is simply a Python module with a `main` function +that follows a particular signature. You put all the routines inside a `routine` +directory inside a skill package. ### Skill Engine -The `Engine` is the object that gets instantiated with all the running -skills. The engine contains an "assistant drive" that can be scoped to a -specific persistence location to keep all the state of the engine in one -spot. The engine also handles the event handling for all registered skills. -Once an engine is started you can call (or subscribe to) its `events` -endpoint to get a list of generated events. +The `Engine` is the object that gets instantiated with all the running skills. +The engine can be asked to list or run any routine it has been configured with +and will do so in a managed "run context". The engine contains an "assistant +drive" that can be scoped to a specific persistence location to keep all the +state of the engine in one spot. The engine also handles the event handling for +all registered skills. Once an engine is started you can call (or subscribe to) +its `events` endpoint to get a list of generated events. See: [engine.py](./skill_library/engine.py), [Assistant Drive](../../assistant-drive/README.md) @@ -78,7 +69,7 @@ See: [Skill Assistant](../../../../assistants/skill-assistant/README.md) ## State -The skill library provides multiple powerful ways to manage state in an assistant. +The skill library provides multiple ways to manage state in an assistant. ### Drives @@ -108,59 +99,3 @@ another location, but putting it on the stack is a simple way to avoid more complex configuration. See: [routine_stack.py](./skill_library/routine_stack.py) - -#### Using the routine stack - -The routine stack is provided in the [run context](#run-context). Create a resource block using the stack inside of a routine or action like this: - -```python -async with run_context.stack_frame_state() as state: - state["some_variable_name"] = "some value" -``` - -The only thing to keep in mind is that when the resource block is exited, -everything in the state object will be serialized to disk, so make sure the -values you are assigning are serializable. - -## User scenario - -Let me walk through the expected flow: - -1. The initial `/run_routine` command hits `on_command_message_created` in skill_assistant.py - - It parses the command and calls ChatFunctions.run_routine - - This calls engine.run_routine with "common.web_research" and the parameters - - Engine creates a task to run the routine and returns a future - - ChatFunctions awaits this future - -2. The routine starts executing: - - Makes a research plan using common.generate_research_plan - - Writes it to a file - - Then hits the first `ask_user` call with "Does this plan look ok?" - - This creates a MessageEvent with that prompt - - The routine pauses, waiting for input via the input_future - -3. When the user sends their response (like "Yes, looks good"): - - That message hits `on_message_created` in skill_assistant.py - - It checks `is_routine_running()` which should return true because we have a current_routine - - It calls `resume_routine` with the user's message - - This sets the result on the input_future - - The routine continues executing from where it was paused at ask_user - -4. This cycle repeats for any other ask_user calls in the routine: - - Routine pauses at ask_user - - User responds - - Message gets routed to resume_routine - - Routine continues - -5. Finally when the routine completes: - - It sets its final result on the result_future - - Cleans up (current_routine = None) - - The original run_routine future completes - -The key points are: - -1. While a routine is running, ALL messages should be routed to resume_routine -2. The routine's state (current_routine) needs to persist between messages -3. The futures mechanism lets us pause/resume the routine while keeping it "alive" - -Looking at this flow, I suspect our issue might be that we're not properly maintaining the routine's state between messages. Let's verify the routine is still considered "running" when we get the user's response. diff --git a/libraries/python/skills/skill-library/skill_library/engine.py b/libraries/python/skills/skill-library/skill_library/engine.py index bb5bec8a..a73f7e43 100644 --- a/libraries/python/skills/skill-library/skill_library/engine.py +++ b/libraries/python/skills/skill-library/skill_library/engine.py @@ -20,15 +20,16 @@ from .routine_stack import RoutineStack from .skill import Skill, SkillConfig from .types import RunContext +from .usage import routines_usage as usage_routines_usage class Engine: """ Main coordination point for skills, routines and user interaction. - The Engine manages the execution of routines and actions from skills that - are available to the system. Skills are registered with configurations on - initialization. When a routine is run, the Engine: + The Engine manages the execution of routines from skills that are registered + to it. Skills are registered with configurations on initialization. When a + routine is run, the Engine: 1. Creates a task to execute the routine asynchronously 2. Manages user interaction by: @@ -117,6 +118,7 @@ async def clear(self, include_drives: bool = True) -> None: if include_drives: self.metadrive.delete_drive() self.drive.delete_drive() + self._emit(StatusUpdatedEvent()) logger.debug("Skill engine state cleared.", extra_data({"engine_id": self.engine_id})) @@ -174,6 +176,10 @@ def list_routines(self) -> list[str]: routines.extend(f"{skill_name}.{routine}" for routine in skill.list_routines()) return routines + def routines_usage(self) -> str: + """Get a list of all routines and their usage.""" + return usage_routines_usage(self._skills) + def is_routine_running(self) -> bool: return self._current_input_future is not None diff --git a/libraries/python/skills/skill-library/skill_library/skill.py b/libraries/python/skills/skill-library/skill_library/skill.py index bed179ca..d05b4b39 100644 --- a/libraries/python/skills/skill-library/skill_library/skill.py +++ b/libraries/python/skills/skill-library/skill_library/skill.py @@ -6,9 +6,8 @@ from pydantic import BaseModel -from skill_library.types import AskUserFn, EmitFn, RunContext, RunRoutineFn - from .logging import extra_data, logger +from .types import AskUserFn, EmitFn, RunContext, RunRoutineFn @runtime_checkable @@ -134,3 +133,18 @@ def get_routine(self, name: str) -> RoutineFn | None: def list_routines(self) -> list[str]: """Return list of available routine names""" return list(self._routines.keys()) + + # def list_attributes(self) -> list[str]: + # """List all available custom attributes in the skill""" + + # attributes = [attr for attr in dir(self) if not attr.startswith("_") and callable(getattr(self, attr))] + + # attrs = [] + + # # Get type annotations for each attribute + # for attr in attributes: + # attr_type = getattr(self, attr).__annotations__.get(attr) + # if attr_type: + # attrs.append(f"{attr}({format_type(attr_type)})") + + # return attrs diff --git a/libraries/python/skills/skill-library/skill_library/skills/common/__init__.py b/libraries/python/skills/skill-library/skill_library/skills/common/__init__.py index afa07b08..9c07907a 100644 --- a/libraries/python/skills/skill-library/skill_library/skills/common/__init__.py +++ b/libraries/python/skills/skill-library/skill_library/skills/common/__init__.py @@ -1,3 +1,3 @@ -from .common_skill import CommonSkill +from .common_skill import CommonSkill, CommonSkillConfig -__all__ = ["CommonSkill"] +__all__ = ["CommonSkill", "CommonSkillConfig"] diff --git a/libraries/python/skills/skill-library/skill_library/skills/common/routines/consolidate.py b/libraries/python/skills/skill-library/skill_library/skills/common/routines/consolidate.py new file mode 100644 index 00000000..e626160c --- /dev/null +++ b/libraries/python/skills/skill-library/skill_library/skills/common/routines/consolidate.py @@ -0,0 +1,68 @@ +from typing import Any, Optional, cast + +from openai_client import ( + CompletionError, + create_system_message, + create_user_message, + extra_data, + make_completion_args_serializable, + message_content_from_completion, + validate_completion, +) +from skill_library import AskUserFn, EmitFn, RunContext, RunRoutineFn +from skill_library.logging import logger +from skill_library.skills.common import CommonSkill + +DEFAULT_MAX_LENGTH = 10000 + + +async def main( + context: RunContext, + routine_state: dict[str, Any], + emit: EmitFn, + run: RunRoutineFn, + ask_user: AskUserFn, + content: str, + max_length: Optional[int] = DEFAULT_MAX_LENGTH, +) -> str: + """ + Consolidate various pieces of content into a cohesive whole. + """ + common_skill = cast(CommonSkill, context.skills["common"]) + language_model = common_skill.config.language_model + + system_message = "Consolide the content provided by the user into a cohesive whole. Try not to lose any information, but reorder and deduplicate as necessary and give it all a singular tone. Just respond with your consolidated content." + + completion_args = { + "model": "gpt-4o", + "messages": [ + create_system_message(system_message), + create_user_message(content), + ], + "max_tokens": max_length, + } + + logger.debug("Completion call.", extra=extra_data(make_completion_args_serializable(completion_args))) + metadata = {} + metadata["completion_args"] = make_completion_args_serializable(completion_args) + try: + completion = await language_model.beta.chat.completions.parse( + **completion_args, + ) + validate_completion(completion) + logger.debug("Completion response.", extra=extra_data({"completion": completion.model_dump()})) + metadata["completion"] = completion.model_dump() + except Exception as e: + completion_error = CompletionError(e) + metadata["completion_error"] = completion_error.message + logger.error( + completion_error.message, + extra=extra_data({"completion_error": completion_error.body, "metadata": context.metadata_log}), + ) + raise completion_error from e + else: + consolidation = message_content_from_completion(completion) + metadata["consolidation"] = consolidation + return consolidation + finally: + context.log("consolidated", metadata) diff --git a/libraries/python/skills/skill-library/skill_library/skills/common/routines/execute_research_plan.py b/libraries/python/skills/skill-library/skill_library/skills/common/routines/execute_research_plan.py deleted file mode 100644 index ec2722da..00000000 --- a/libraries/python/skills/skill-library/skill_library/skills/common/routines/execute_research_plan.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Any - -from skill_library import AskUserFn, EmitFn, RunContext, RunRoutineFn - - -async def main( - context: RunContext, - routine_state: dict[str, Any], - emit: EmitFn, - run: RunRoutineFn, - ask_user: AskUserFn, -): - """ - Execute a research plan by following the steps outlined in the plan. - """ - pass diff --git a/libraries/python/skills/skill-library/skill_library/skills/common/routines/generate_routine.py b/libraries/python/skills/skill-library/skill_library/skills/common/routines/generate_routine.py deleted file mode 100644 index abbb0e44..00000000 --- a/libraries/python/skills/skill-library/skill_library/skills/common/routines/generate_routine.py +++ /dev/null @@ -1,105 +0,0 @@ -from typing import Any, cast - -from openai_client import ( - CompletionError, - create_system_message, - create_user_message, - extra_data, - make_completion_args_serializable, - message_content_from_completion, - validate_completion, -) -from skill_library import AskUserFn, EmitFn, RunContext, RunRoutineFn -from skill_library.logging import logger -from skill_library.skills.common import CommonSkill - -SYSTEM_PROMPT = """ -You are a part of an AGI system that generates routines to satisfy a specific goal. Routines are the building blocks of the AGI system and can be thought of as procedural knowledge. Routines can execute other routines, ask the user for input, and emit messages to the user. Routines can be used to perform a wide variety of tasks, such as generating text, answering questions, or performing calculations. - -Routine functions are put in a module and shipped as part of "skill" packages. Skills are Python packages that contain a set of routines. An AGI system can have multiple skills, each with its own set of routines. Skills are loaded into the AGI system at runtime and can be used to extend the capabilities of the system. Each skill has its own configuration, which is used to initialize the skill and its routines. - -Routine specification: - -A routine is a Python function that takes a RunContext, routine_state, emit, run, and ask_user as arguments. The function can return anything. Here's what the required arguments can be used for: - -- context: The context of the conversation. You can use this to get information about the user's goal and the current state of the conversation. The context has the following attributes: - - session_id: A unique identifier for the session. This is useful for tracking the conversation. - - run_id: A unique identifier for the run. This is useful for tracking the conversation. - - run_drive: A drive object that can be used to read and write files to a particular location. This is useful for storing data that needs to persist between sessions. - - skills: A dictionary of skills that are available to the routine. Each skill has a name and a function that can be used to run the skill. - - log(Metadata): A function that can be used to log metadata about the conversation. This is our primary logging mechanism. Metadata is a dictionary of key-value pairs (dict[str, Any]). -- routine_state: A dictionary that can be used to store state between steps in the routine. This is useful for maintaining context between messages. -- emit: A function that can be used to emit messages to the user. This is useful for asking the user for input or providing updates on the progress of the routine. -- run: A function that can be used to run other routines. This is useful for breaking up a large routine into smaller, more manageable pieces. A list of all available routines is below. -- ask_user: A function that can be used to ask the user for input. This is useful for getting information from the user that is needed to complete the routine. - -Available routines: - -{{routines}} - -Your job is to respond to a user's description of their goal by returning a routine that satisfies the goal. Respond with just the routine. - -Example: - -async def main( - context: RunContext, - routine_state: dict[str, Any], - emit: EmitFn, - run: RunRoutineFn, - ask_user: AskUserFn, - another_arg: str, -) -> str: - - # Skills can be configured with arbitrary arguments. These can be used in the routine by referencing any skill instance from the context. - common_skill = cast(CommonSkill, context.skills["common"]) - language_model = common_skill.language_model - - ask_user("What is your name?") - run("common.", another_arg) -""" - - -async def main( - context: RunContext, - routine_state: dict[str, Any], - emit: EmitFn, - run: RunRoutineFn, - ask_user: AskUserFn, - goal: str, -) -> str: - """Generate a routine to satisfy a specific goal.""" - - common_skill = cast(CommonSkill, context.skills["common"]) - language_model = common_skill.config.language_model - - completion_args = { - "model": "gpt-4o", - "messages": [ - create_system_message(SYSTEM_PROMPT), - create_user_message( - goal, - ), - ], - } - - logger.debug("Completion call.", extra=extra_data(make_completion_args_serializable(completion_args))) - context.log({"completion_args": make_completion_args_serializable(completion_args)}) - try: - completion = await language_model.beta.chat.completions.parse( - **completion_args, - ) - validate_completion(completion) - logger.debug("Completion response.", extra=extra_data({"completion": completion.model_dump()})) - context.log({"completion": completion.model_dump()}) - except Exception as e: - completion_error = CompletionError(e) - context.log({"completion_error": completion_error.message}) - logger.error( - completion_error.message, - extra=extra_data({"completion_error": completion_error.body, "metadata": context.metadata_log}), - ) - raise completion_error from e - else: - search_query = message_content_from_completion(completion).strip().strip('"') - context.log({"search_query": search_query}) - return search_query diff --git a/libraries/python/skills/skill-library/skill_library/skills/common/routines/select_user_intent.py b/libraries/python/skills/skill-library/skill_library/skills/common/routines/select_user_intent.py index 43fa2062..8cc6ce70 100644 --- a/libraries/python/skills/skill-library/skill_library/skills/common/routines/select_user_intent.py +++ b/libraries/python/skills/skill-library/skill_library/skills/common/routines/select_user_intent.py @@ -10,7 +10,7 @@ validate_completion, ) from pydantic import BaseModel -from skill_library import AskUserFn, EmitFn, Metadata, RunContext, RunRoutineFn +from skill_library import AskUserFn, EmitFn, RunContext, RunRoutineFn from skill_library.logging import logger from skill_library.skills.common import CommonSkill @@ -22,7 +22,7 @@ async def main( run: RunRoutineFn, ask_user: AskUserFn, options: dict[str, str], -) -> tuple[str, Metadata]: +) -> str: """Select the user's intent from a set of options based on the conversation history.""" common_skill = cast(CommonSkill, context.skills["common"]) @@ -52,8 +52,8 @@ class Output(BaseModel): "response_format": Output, } - metadata = {} logger.debug("Completion call.", extra=extra_data(make_completion_args_serializable(completion_args))) + metadata = {} metadata["completion_args"] = make_completion_args_serializable(completion_args) try: completion = await language_model.beta.chat.completions.parse( @@ -67,9 +67,12 @@ class Output(BaseModel): metadata["completion_error"] = completion_error.message logger.error( completion_error.message, - extra=extra_data({"completion_error": completion_error.body, "metadata": metadata}), + extra=extra_data({"completion_error": completion_error.body}), ) raise completion_error from e else: intent = cast(Output, completion.choices[0].message.parsed).intent - return intent, metadata + metadata["intent"] = intent + return intent + finally: + context.log("select_user_intent", metadata) diff --git a/libraries/python/skills/skill-library/skill_library/skills/common/routines/summarize.py b/libraries/python/skills/skill-library/skill_library/skills/common/routines/summarize.py index f529cdcc..1e295773 100644 --- a/libraries/python/skills/skill-library/skill_library/skills/common/routines/summarize.py +++ b/libraries/python/skills/skill-library/skill_library/skills/common/routines/summarize.py @@ -34,7 +34,7 @@ async def main( common_skill = cast(CommonSkill, context.skills["common"]) language_model = common_skill.config.language_model - system_message = "You are a summarizer. Your job is to summarize the content provided by the user." + system_message = "You are a summarizer. Your job is to summarize the content provided by the user. Don't lose important information." if aspect: system_message += f" Summarize the content only from this aspect: {aspect}" @@ -48,21 +48,26 @@ async def main( } logger.debug("Completion call.", extra=extra_data(make_completion_args_serializable(completion_args))) - context.log({"completion_args": make_completion_args_serializable(completion_args)}) + metadata = {} + metadata["completion_args"] = make_completion_args_serializable(completion_args) try: completion = await language_model.beta.chat.completions.parse( **completion_args, ) validate_completion(completion) logger.debug("Completion response.", extra=extra_data({"completion": completion.model_dump()})) - context.log({"completion": completion.model_dump()}) + metadata["completion"] = completion.model_dump() except Exception as e: completion_error = CompletionError(e) - context.log({"completion_error": completion_error.message}) + metadata["completion_error"] = completion_error.message logger.error( completion_error.message, extra=extra_data({"completion_error": completion_error.body, "metadata": context.metadata_log}), ) raise completion_error from e else: - return message_content_from_completion(completion) + summary = message_content_from_completion(completion) + metadata["summary"] = summary + return summary + finally: + context.log("summarize", metadata) diff --git a/libraries/python/skills/skill-library/skill_library/skills/eval/__init__.py b/libraries/python/skills/skill-library/skill_library/skills/eval/__init__.py new file mode 100644 index 00000000..446b5ce1 --- /dev/null +++ b/libraries/python/skills/skill-library/skill_library/skills/eval/__init__.py @@ -0,0 +1,3 @@ +from .eval_skill import EvalSkill, EvalSkillConfig + +__all__ = ["EvalSkill", "EvalSkillConfig"] diff --git a/libraries/python/skills/skill-library/skill_library/skills/eval/eval_skill.py b/libraries/python/skills/skill-library/skill_library/skills/eval/eval_skill.py new file mode 100644 index 00000000..6cee7734 --- /dev/null +++ b/libraries/python/skills/skill-library/skill_library/skills/eval/eval_skill.py @@ -0,0 +1,16 @@ +from assistant_drive import Drive +from skill_library import LanguageModel, Skill, SkillConfig + + +class EvalSkillConfig(SkillConfig): + """Configuration for the evaluation skill""" + + language_model: LanguageModel + drive: Drive + + +class EvalSkill(Skill): + config: EvalSkillConfig + + def __init__(self, config: EvalSkillConfig): + super().__init__(config) diff --git a/libraries/python/skills/skill-library/skill_library/skills/eval/routines/eval.py b/libraries/python/skills/skill-library/skill_library/skills/eval/routines/eval.py new file mode 100644 index 00000000..2401c3fc --- /dev/null +++ b/libraries/python/skills/skill-library/skill_library/skills/eval/routines/eval.py @@ -0,0 +1,58 @@ +from typing import Any, Dict, cast + +from events import ErrorEvent, MessageEvent +from openai_client import ( + CompletionError, + create_system_message, + create_user_message, + message_content_from_completion, + validate_completion, +) +from skill_library import AskUserFn, EmitFn, RunContext, RunRoutineFn +from skill_library.skills.common import CommonSkill + + +async def main( + context: RunContext, + routine_state: dict[str, Any], + emit: EmitFn, + run: RunRoutineFn, + ask_user: AskUserFn, + content: str, + scale: Dict[int, str], +) -> str: + """Rate the given content using the provided scale. The scale is a dictionary where each key is an integer representing a rating and each value is a description of what that rating means.""" + common_skill = cast(CommonSkill, context.skills["common"]) + language_model = common_skill.config.language_model + + scale_description = "; ".join([f"{key}: {value}" for key, value in scale.items()]) + system_message = ( + "You are a content rater. Your job is to rate the given content based " + "on the provided scale. Provide just the numeric score. " + f"The scale is as follows: {scale_description}." + ) + + completion_args = { + "model": "gpt-4o", + "messages": [ + create_system_message(system_message), + create_user_message(content), + ], + "max_tokens": 10, # We only need a short response for a rating. + } + + try: + completion = await language_model.beta.chat.completions.parse( + **completion_args, + ) + validate_completion(completion) + rating = message_content_from_completion(completion).strip() + except Exception as e: + completion_error = CompletionError(e) + emit(ErrorEvent(message="Failed to rate the content.")) + raise completion_error from e + + emit(MessageEvent(message=f"The content is rated as: {rating}")) + context.log("rate_content", {"content": content, "scale": scale, "rating": rating}) + + return rating diff --git a/libraries/python/skills/skill-library/skill_library/skills/meta/__init__.py b/libraries/python/skills/skill-library/skill_library/skills/meta/__init__.py new file mode 100644 index 00000000..8a4391d0 --- /dev/null +++ b/libraries/python/skills/skill-library/skill_library/skills/meta/__init__.py @@ -0,0 +1,3 @@ +from .meta_skill import MetaSkill, MetaSkillConfig + +__all__ = ["MetaSkill", "MetaSkillConfig"] diff --git a/libraries/python/skills/skill-library/skill_library/skills/meta/meta_skill.py b/libraries/python/skills/skill-library/skill_library/skills/meta/meta_skill.py new file mode 100644 index 00000000..3ffc118f --- /dev/null +++ b/libraries/python/skills/skill-library/skill_library/skills/meta/meta_skill.py @@ -0,0 +1,16 @@ +from assistant_drive import Drive +from skill_library import LanguageModel, Skill, SkillConfig + + +class MetaSkillConfig(SkillConfig): + """Configuration for the skill meta skill""" + + language_model: LanguageModel + drive: Drive + + +class MetaSkill(Skill): + config: MetaSkillConfig + + def __init__(self, config: MetaSkillConfig): + super().__init__(config) diff --git a/libraries/python/skills/skill-library/skill_library/skills/meta/routines/generate_routine.py b/libraries/python/skills/skill-library/skill_library/skills/meta/routines/generate_routine.py new file mode 100644 index 00000000..52242b73 --- /dev/null +++ b/libraries/python/skills/skill-library/skill_library/skills/meta/routines/generate_routine.py @@ -0,0 +1,253 @@ +from typing import Any, cast + +from events import MessageEvent +from openai_client import ( + CompletionError, + create_system_message, + create_user_message, + extra_data, + format_with_liquid, + make_completion_args_serializable, + message_content_from_completion, + validate_completion, +) +from skill_library import AskUserFn, EmitFn, RunContext, RunRoutineFn +from skill_library.logging import logger +from skill_library.skills.common import CommonSkill + +SYSTEM_PROMPT = ''' +You are a part of an AGI system that generates routines to satisfy a specific goal. Routines are the building blocks of the AGI system and can be thought of as procedural knowledge. Routines can execute other routines, ask the user for input, and emit messages to the user. Routines can be used to perform a wide variety of tasks, such as generating text, answering questions, or performing calculations. + +Routine functions are put in a module and shipped as part of "skill" packages. Skills are Python packages that contain a set of routines. An AGI system can have multiple skills, each with its own set of routines. Skills are loaded into the AGI system at runtime and can be used to extend the capabilities of the system. Each skill has its own configuration, which is used to initialize the skill and its routines. + +## Routine specification: + +A routine is a Python `main` function that takes a `RunContext`, `routine_state`, `emit`, `run`, and `ask_user` as arguments. The function can return anything. Here's what the required arguments can be used for: + +- context: The context of the conversation. You can use this to get information about the user's goal and the current state of the conversation. The context has the following attributes: + - session_id: str - A unique identifier for the session. This is useful for tracking the conversation. + - run_id: str - A unique identifier for the run. This is useful for tracking the conversation. + - run_drive: Drive - A drive object that can be used to read and write files to a particular location. This is useful for storing data that needs to persist between sessions. + - skills: dict[str, Skill] - A dictionary of skills that are available to the routine. Each skill has a name and a function that can be used to run the skill. + - log(dict[str, Any]): A function that can be used to log metadata about the conversation. This is our primary logging mechanism. Metadata must be serializable. +- routine_state: dict[str, Any] - A dictionary that can be used to store state between steps in the routine. This is useful for maintaining context between messages. +- emit(EventProtocol) - A function that can be used to emit messages to the user. This is useful for asking the user for input or providing updates on the progress of the routine. EventProtocol must be one of the following (can be imported from the `events` package): + - StatusUpdatedEvent(message="something") // Communicates what the routine is currently doing. + - MessageEvent(message="something") // Passed on to the user as a chat message. + - InformationEvent(message="something") // Displayed to the user for informational purposes, but not kept in the chat history. + - ErrorEvent(message="something") // Indicates to the user that something went wrong. +- run: A function that can be used to run any routine. This is useful for breaking up a large routine into smaller, more manageable pieces. A list of all available routines is provided below. +- ask_user: A function that can be used to ask the user for input. This is useful for getting information from the user that is needed to complete the routine. + +The routine function can then have any number of additional arguments (args or kwargs). These arguments can be used to pass in data that is needed to complete the routine. + + +## Type information + +``` +LanguageModel = AsyncOpenAI | AsyncAzureOpenAI + +AskUserFn = Callable[[str], Awaitable[str]] +ActionFn = Callable[[RunContext], Awaitable[Any]] +EmitFn = Callable[[EventProtocol], None] + +class RunRoutineFn(Protocol): + async def __call__(self, designation: str, *args: Any, **kwargs: Any) -> Any: ... +``` + +## Available skills and thier configuration + +{{skills}} + + +## Available routines + +{{routines}} + + +## Instructions + +Your job is to respond to a user's description of their goal by returning a routine that satisfies the goal. Respond with the routine, delimited by markdown python triple backticks. + + +## Examples + +### A simple example + +``` story_unboringer.py + +from typing import Any, Optional, cast + +from .types import RunContext, EmitFn, RunRoutineFn, AskUserFn +from events import StatusUpdatedEvent, MessageEvent, InformationEvent, ErrorEvent + +async def main( + context: RunContext, + routine_state: dict[str, Any], + emit: EmitFn, + run: RunRoutineFn, + ask_user: AskUserFn, + another_arg: str, +) -> str: + """ + Docstrings are extracted and used as the routine description. This is useful for + providing additional context to the user about what the routine does, so always + add an informative one for the routine function. + """ + + # Skills can be configured. Configured attributed can be used in the routine by referencing any skill instance from the context. + common_skill = cast(CommonSkill, context.skills["common"]) + language_model = common_skill.language_model + + story = ask_user("Tell me a story.") + emit(StatusUpdatedEvent(message="Summarizing the story...")) + summarization = run("common.summarize", content=story) + context.log("story unboringer", {"story": story, "summarization": summarization}) + + return f"That's a long story... if I heard you right, you're saying: {summarization}" +``` + +### An example of using a language model + +If you need to use a language model, it will be passed into the skill as a configuration item. You should use our openai_client to make calls to the language model. + +``` +from typing import Any, Optional, cast + +from openai_client import ( + CompletionError, + create_system_message, + create_user_message, + extra_data, + make_completion_args_serializable, + message_content_from_completion, + validate_completion, +) +from skill_library import AskUserFn, EmitFn, RunContext, RunRoutineFn +from skill_library.logging import logger +from skill_library.skills.common import CommonSkill + +DEFAULT_MAX_SUMMARY_LENGTH = 5000 + +async def main( + context: RunContext, + routine_state: dict[str, Any], + emit: EmitFn, + run: RunRoutineFn, + ask_user: AskUserFn, + content: str, + aspect: Optional[str] = None, + max_length: Optional[int] = DEFAULT_MAX_SUMMARY_LENGTH, +) -> str: + """ + Summarize the content from the given aspect. The content may be relevant or + not to a given aspect. If no aspect is provided, summarize the content as + is. + """ + common_skill = cast(CommonSkill, context.skills["common"]) + language_model = common_skill.config.language_model + + system_message = "You are a summarizer. Your job is to summarize the content provided by the user. Don't lose important information." + if aspect: + system_message += f" Summarize the content only from this aspect: {aspect}" + + completion_args = { + "model": "gpt-4o", + "messages": [ + create_system_message(system_message), + create_user_message(content), + ], + "max_tokens": max_length, + } + + logger.debug("Completion call.", extra=extra_data(make_completion_args_serializable(completion_args))) + metadata = {} + metadata["completion_args"] = make_completion_args_serializable(completion_args) + try: + completion = await language_model.beta.chat.completions.parse( + **completion_args, + ) + validate_completion(completion) + logger.debug("Completion response.", extra=extra_data({"completion": completion.model_dump()})) + metadata["completion"] = completion.model_dump() + except Exception as e: + completion_error = CompletionError(e) + metadata["completion_error"] = completion_error.message + logger.error( + completion_error.message, + extra=extra_data({"completion_error": completion_error.body, "metadata": context.metadata_log}), + ) + raise completion_error from e + else: + summary = message_content_from_completion(completion) + metadata["summary"] = summary + return summary + finally: + context.log("summarize", metadata) +``` + +''' + + +async def main( + context: RunContext, + routine_state: dict[str, Any], + emit: EmitFn, + run: RunRoutineFn, + ask_user: AskUserFn, + goal: str, +) -> str: + """Generate a skill library routine to satisfy a specific goal.""" + + common_skill = cast(CommonSkill, context.skills["common"]) + language_model = common_skill.config.language_model + + skill_configs = [] + for skill in context.skills.values(): + skill_configs.append(skill.config.model_dump()) + + system_prompt = format_with_liquid(SYSTEM_PROMPT, {"routines": context.routine_usage(), "skills": skill_configs}) + completion_args = { + "model": "gpt-4o", + "messages": [ + create_system_message(system_prompt), + create_user_message( + goal, + ), + ], + } + completion_args = { + "model": "gpt-4o", + "messages": [ + create_system_message(SYSTEM_PROMPT), + create_user_message( + goal, + ), + ], + } + + logger.debug("Completion call.", extra=extra_data(make_completion_args_serializable(completion_args))) + metadata = {} + metadata["completion_args"] = make_completion_args_serializable(completion_args) + try: + completion = await language_model.beta.chat.completions.parse( + **completion_args, + ) + validate_completion(completion) + logger.debug("Completion response.", extra=extra_data({"completion": completion.model_dump()})) + metadata["completion"] = completion.model_dump() + except Exception as e: + completion_error = CompletionError(e) + metadata["completion_error"] = completion_error.message + logger.error( + completion_error.message, + extra=extra_data({"completion_error": completion_error.body, "metadata": context.metadata_log}), + ) + raise completion_error from e + else: + routine = message_content_from_completion(completion).strip() + metadata["routine"] = routine + emit(MessageEvent(message=routine)) + return routine + finally: + context.log("generate_routine", metadata) diff --git a/libraries/python/skills/skill-library/skill_library/skills/posix/__init__.py b/libraries/python/skills/skill-library/skill_library/skills/posix/__init__.py index efc391ec..5132d44e 100644 --- a/libraries/python/skills/skill-library/skill_library/skills/posix/__init__.py +++ b/libraries/python/skills/skill-library/skill_library/skills/posix/__init__.py @@ -1,3 +1,3 @@ -from .posix_skill import PosixSkill +from .posix_skill import PosixSkill, PosixSkillConfig -__all__ = ["PosixSkill"] +__all__ = ["PosixSkill", "PosixSkillConfig"] diff --git a/libraries/python/skills/skill-library/skill_library/skills/research/README.md b/libraries/python/skills/skill-library/skill_library/skills/research/README.md new file mode 100644 index 00000000..d1092028 --- /dev/null +++ b/libraries/python/skills/skill-library/skill_library/skills/research/README.md @@ -0,0 +1,41 @@ +# Research skill + +## Web Research [web_researcy.py](./routines/web_research.py) + +This routine conducts thorough research on a given topic, interacts with the user to refine the research plan, gathers information from web searches, evaluates answers, and generates a structured research report. Here's how it works: + +1. **Generate a Research Plan:** + - The program starts by running a routine (`research.generate_research_plan`) to create a research plan based on the given topic. + - The plan is then saved to a file (`plan_name.txt`). + +2. **User Review & Refinement:** + - The user is asked if the plan looks good or needs modifications. + - If the user wants updates, the plan is read from the file and refined (`research.update_research_plan`). + - This loop continues until the user confirms the plan or chooses to exit. + +3. **Execute the Research Plan:** + - If the user exits, the temporary research plan file is deleted. + - Otherwise, the research begins, and a results file (`plan_name_research_answers.md`) is created with a header. + +4. **Web Search & Answer Collection:** + - For each research question in the plan: + - The system searches the web for relevant information (`research.web_search`). + - It generates an answer using the retrieved content (`research.answer_question_about_content`). + - The answer is evaluated for accuracy (`research.evaluate_answer`). + - If the answer is good, it is appended to the results file. + - If the answer is not satisfactory, the system refines its search based on previous attempts. + +5. **Summarize & Report Generation:** + - After answering all research questions, the collected information is read from the file. + - A summary of the research is generated (`common.summarize`). + - The final research report is saved as `plan_name_research_report.txt`. + - The program prints a completion message and returns the report. + +### **Key Features:** + +- **Iterative refinement** with user feedback before executing research. +- **Automated web searching** to gather relevant information. +- **Evaluation of answers** to ensure quality before adding them to the report. +- **Final summarization** for a concise, structured research report. + +This routine is designed to **automate and streamline** the process of conducting detailed web-based research, reducing manual effort while maintaining quality through user interaction and validation steps. diff --git a/libraries/python/skills/skill-library/skill_library/skills/research/__init__.py b/libraries/python/skills/skill-library/skill_library/skills/research/__init__.py new file mode 100644 index 00000000..506a4d24 --- /dev/null +++ b/libraries/python/skills/skill-library/skill_library/skills/research/__init__.py @@ -0,0 +1,3 @@ +from .research_skill import ResearchSkill, ResearchSkillConfig + +__all__ = ["ResearchSkill", "ResearchSkillConfig"] diff --git a/libraries/python/skills/skill-library/skill_library/skills/research/research_skill.py b/libraries/python/skills/skill-library/skill_library/skills/research/research_skill.py new file mode 100644 index 00000000..4c633e66 --- /dev/null +++ b/libraries/python/skills/skill-library/skill_library/skills/research/research_skill.py @@ -0,0 +1,16 @@ +from assistant_drive import Drive +from skill_library import LanguageModel, Skill, SkillConfig + + +class ResearchSkillConfig(SkillConfig): + """Configuration for the common skill""" + + language_model: LanguageModel + drive: Drive + + +class ResearchSkill(Skill): + config: ResearchSkillConfig + + def __init__(self, config: ResearchSkillConfig): + super().__init__(config) diff --git a/libraries/python/skills/skill-library/skill_library/skills/common/routines/answer_question_about_content.py b/libraries/python/skills/skill-library/skill_library/skills/research/routines/answer_question_about_content.py similarity index 74% rename from libraries/python/skills/skill-library/skill_library/skills/common/routines/answer_question_about_content.py rename to libraries/python/skills/skill-library/skill_library/skills/research/routines/answer_question_about_content.py index 66c36ea1..61aad88e 100644 --- a/libraries/python/skills/skill-library/skill_library/skills/common/routines/answer_question_about_content.py +++ b/libraries/python/skills/skill-library/skill_library/skills/research/routines/answer_question_about_content.py @@ -39,7 +39,7 @@ class Output(BaseModel): "messages": [ create_system_message( ( - "You are an expert in the field and hold a piece of content you are answering questions on. When the user asks a question, provide a detailed answer based solely on your content. Reason through the content to identify relevant information and structure your answer in a clear and concise manner. If the content does not contain the answer, provide a response indicating that the information is not available." + "You are an expert in the field and hold a piece of content you are answering questions with. When the user asks a question, provide a detailed answer based solely on your content. Reason through the content to identify relevant information and structure your answer in a clear and thorough manner. If the content does not contain the answer, provide a response indicating that the information is not available. Prefer thorough over and complete answers.\n" "\n\nTHE CONTENT:\n\n" f"{content}" ), @@ -54,17 +54,18 @@ class Output(BaseModel): completion_args["max_tokens"] = max_length logger.debug("Completion call.", extra=extra_data(make_completion_args_serializable(completion_args))) - context.log({"completion_args": make_completion_args_serializable(completion_args)}) + metadata = {} + metadata["completion_args"] = make_completion_args_serializable(completion_args) try: completion = await language_model.beta.chat.completions.parse( **completion_args, ) validate_completion(completion) logger.debug("Completion response.", extra=extra_data({"completion": completion.model_dump()})) - context.log({"completion": completion.model_dump()}) + metadata["completion"] = completion.model_dump() except Exception as e: completion_error = CompletionError(e) - context.log({"completion_error": completion_error.message}) + metadata["completion_error"] = completion_error.message logger.error( completion_error.message, extra=extra_data({"completion_error": completion_error.body, "metadata": context.metadata_log}), @@ -72,5 +73,7 @@ class Output(BaseModel): raise completion_error from e else: research_questions = cast(Output, completion.choices[0].message.parsed).answer - context.log({"research_questions": research_questions}) + metadata["research_questions"] = research_questions return research_questions + finally: + context.log("answer_question_about_content", metadata) diff --git a/libraries/python/skills/skill-library/skill_library/skills/common/routines/evaluate_answer.py b/libraries/python/skills/skill-library/skill_library/skills/research/routines/evaluate_answer.py similarity index 86% rename from libraries/python/skills/skill-library/skill_library/skills/common/routines/evaluate_answer.py rename to libraries/python/skills/skill-library/skill_library/skills/research/routines/evaluate_answer.py index 0077564a..c99e0c78 100644 --- a/libraries/python/skills/skill-library/skill_library/skills/common/routines/evaluate_answer.py +++ b/libraries/python/skills/skill-library/skill_library/skills/research/routines/evaluate_answer.py @@ -9,7 +9,7 @@ validate_completion, ) from pydantic import BaseModel -from skill_library import AskUserFn, EmitFn, Metadata, RunContext, RunRoutineFn +from skill_library import AskUserFn, EmitFn, RunContext, RunRoutineFn from skill_library.logging import logger from skill_library.skills.common import CommonSkill @@ -22,7 +22,7 @@ async def main( ask_user: AskUserFn, question: str, answer: str, -) -> tuple[bool, Metadata]: +) -> tuple[bool, str]: """Decide whether an answer actually answers a question well, and return a reason why.""" common_skill = cast(CommonSkill, context.skills["common"]) @@ -45,8 +45,8 @@ class Output(BaseModel): "response_format": Output, } - metadata = {} logger.debug("Completion call.", extra=extra_data(make_completion_args_serializable(completion_args))) + metadata = {} metadata["completion_args"] = make_completion_args_serializable(completion_args) try: completion = await language_model.beta.chat.completions.parse( @@ -60,9 +60,13 @@ class Output(BaseModel): metadata["completion_error"] = completion_error.message logger.error( completion_error.message, - extra=extra_data({"completion_error": completion_error.body, "metadata": metadata}), + extra=extra_data({"completion_error": completion_error.body}), ) raise completion_error from e else: response: Output = cast(Output, completion.choices[0].message.parsed) - return response.is_good_answer, metadata + metadata["is_good_answer"] = response.is_good_answer + metadata["reasoning"] = response.reasoning + return response.is_good_answer, response.reasoning + finally: + context.log("evaluate_answer", metadata) diff --git a/libraries/python/skills/skill-library/skill_library/skills/common/routines/generate_research_plan.py b/libraries/python/skills/skill-library/skill_library/skills/research/routines/generate_research_plan.py similarity index 92% rename from libraries/python/skills/skill-library/skill_library/skills/common/routines/generate_research_plan.py rename to libraries/python/skills/skill-library/skill_library/skills/research/routines/generate_research_plan.py index 1739fdc0..9063db7c 100644 --- a/libraries/python/skills/skill-library/skill_library/skills/common/routines/generate_research_plan.py +++ b/libraries/python/skills/skill-library/skill_library/skills/research/routines/generate_research_plan.py @@ -12,12 +12,11 @@ from skill_library import AskUserFn, EmitFn, RunContext, RunRoutineFn from skill_library.logging import logger from skill_library.skills.common import CommonSkill -from skill_library.types import Metadata async def main( context: RunContext, routine_state: dict[str, Any], emit: EmitFn, run: RunRoutineFn, ask_user: AskUserFn, topic: str -) -> tuple[list[str], Metadata]: +) -> list[str]: """ Generate a research plan on a given topic. The plan will consist of a set of research questions to be answered. @@ -42,8 +41,8 @@ class Output(BaseModel): "response_format": Output, } - metadata = {} logger.debug("Completion call.", extra=extra_data(make_completion_args_serializable(completion_args))) + metadata = {} metadata["completion_args"] = make_completion_args_serializable(completion_args) try: completion = await language_model.beta.chat.completions.parse( @@ -57,9 +56,12 @@ class Output(BaseModel): metadata["completion_error"] = completion_error.message logger.error( completion_error.message, - extra=extra_data({"completion_error": completion_error.body, "metadata": metadata}), + extra=extra_data({"completion_error": completion_error.body}), ) raise completion_error from e else: research_questions = cast(Output, completion.choices[0].message.parsed).research_questions - return research_questions, metadata + metadata["research_questions"] = research_questions + return research_questions + finally: + context.log("generate_research_plan", metadata) diff --git a/libraries/python/skills/skill-library/skill_library/skills/common/routines/generate_search_query.py b/libraries/python/skills/skill-library/skill_library/skills/research/routines/generate_search_query.py similarity index 89% rename from libraries/python/skills/skill-library/skill_library/skills/common/routines/generate_search_query.py rename to libraries/python/skills/skill-library/skill_library/skills/research/routines/generate_search_query.py index 96247ab4..e5514808 100644 --- a/libraries/python/skills/skill-library/skill_library/skills/common/routines/generate_search_query.py +++ b/libraries/python/skills/skill-library/skill_library/skills/research/routines/generate_search_query.py @@ -52,17 +52,18 @@ async def main( } logger.debug("Completion call.", extra=extra_data(make_completion_args_serializable(completion_args))) - context.log({"completion_args": make_completion_args_serializable(completion_args)}) + metadata = {} + metadata["completion_args"] = make_completion_args_serializable(completion_args) try: completion = await language_model.beta.chat.completions.parse( **completion_args, ) validate_completion(completion) logger.debug("Completion response.", extra=extra_data({"completion": completion.model_dump()})) - context.log({"completion": completion.model_dump()}) + metadata["completion"] = completion.model_dump() except Exception as e: completion_error = CompletionError(e) - context.log({"completion_error": completion_error.message}) + metadata["completion_error"] = completion_error.message logger.error( completion_error.message, extra=extra_data({"completion_error": completion_error.body, "metadata": context.metadata_log}), @@ -70,5 +71,7 @@ async def main( raise completion_error from e else: search_query = message_content_from_completion(completion).strip().strip('"') - context.log({"search_query": search_query}) + metadata["search_query"] = search_query return search_query + finally: + context.log("generate_search_query", metadata) diff --git a/libraries/python/skills/skill-library/skill_library/skills/common/routines/update_research_plan.py b/libraries/python/skills/skill-library/skill_library/skills/research/routines/update_research_plan.py similarity index 72% rename from libraries/python/skills/skill-library/skill_library/skills/common/routines/update_research_plan.py rename to libraries/python/skills/skill-library/skill_library/skills/research/routines/update_research_plan.py index ce7f7b69..8ee714a1 100644 --- a/libraries/python/skills/skill-library/skill_library/skills/common/routines/update_research_plan.py +++ b/libraries/python/skills/skill-library/skill_library/skills/research/routines/update_research_plan.py @@ -9,20 +9,26 @@ validate_completion, ) from pydantic import BaseModel -from skill_library import AskUserFn, EmitFn, Metadata, RunContext, RunRoutineFn +from skill_library import AskUserFn, EmitFn, RunContext, RunRoutineFn from skill_library.logging import logger -from skill_library.skills.common import CommonSkill +from skill_library.skills.research import ResearchSkill async def main( - context: RunContext, routine_state: dict[str, Any], emit: EmitFn, run: RunRoutineFn, ask_user: AskUserFn, topic: str -) -> tuple[list[str], Metadata]: + context: RunContext, + routine_state: dict[str, Any], + emit: EmitFn, + run: RunRoutineFn, + ask_user: AskUserFn, + topic: str, + plan: str, +) -> list[str]: """ Update a research plan using information from a conversation. The plan will consist of an updated set of research questions to be answered. """ - common_skill = cast(CommonSkill, context.skills["common"]) - language_model = common_skill.config.language_model + research_skill = cast(ResearchSkill, context.skills["research"]) + language_model = research_skill.config.language_model class Output(BaseModel): reasoning: str @@ -33,10 +39,12 @@ class Output(BaseModel): "messages": [ create_system_message( ( - "You are an expert research assistant. You have previously considered a topic and carefully analyzed it to identify core, tangential, and nuanced areas requiring exploration. You approached the topic methodically, breaking it down into its fundamental aspects, associated themes, and interconnections. You thoroughly thought through the subject step by step and created a comprehensive set of research questions. These questions were presented to the user, who has now provided additional information. Use this information, found in the chat history to update the research plan.\n\n" - "The topic is: {topic}\n\n" - "The previous research questions are:\n\n" - "{research_questions}\n\n" + "You are an expert research assistant. You have previously considered a topic and carefully analyzed it to identify core, tangential, and nuanced areas requiring exploration. You approached the topic methodically, breaking it down into its fundamental aspects, associated themes, and interconnections. You thoroughly thought through the subject step by step and created a comprehensive set of research questions. These questions were presented to the user, who has now provided additional information. Use this information, found in the chat history to update the research plan. Don't entirely rewrite the plan unless the user asks you to, just tweak it.\n" + "\n---\n\n" + "The topic is: {topic}\n" + "\n---\n\n" + "The research questions we are updating:\n\n" + "{plan}\n\n" ) ), create_user_message( @@ -46,8 +54,8 @@ class Output(BaseModel): "response_format": Output, } - metadata = {} logger.debug("Completion call.", extra=extra_data(make_completion_args_serializable(completion_args))) + metadata = {} metadata["completion_args"] = make_completion_args_serializable(completion_args) try: completion = await language_model.beta.chat.completions.parse( @@ -61,9 +69,12 @@ class Output(BaseModel): metadata["completion_error"] = completion_error.message logger.error( completion_error.message, - extra=extra_data({"completion_error": completion_error.body, "metadata": metadata}), + extra=extra_data({"completion_error": completion_error.body}), ) raise completion_error from e else: research_questions = cast(Output, completion.choices[0].message.parsed).research_questions - return research_questions, metadata + metadata["research_questions"] = research_questions + return research_questions + finally: + context.log("update_research_plan", metadata) diff --git a/libraries/python/skills/skill-library/skill_library/skills/common/routines/web_research.py b/libraries/python/skills/skill-library/skill_library/skills/research/routines/web_research.py similarity index 65% rename from libraries/python/skills/skill-library/skill_library/skills/common/routines/web_research.py rename to libraries/python/skills/skill-library/skill_library/skills/research/routines/web_research.py index acaa10d4..0d15a261 100644 --- a/libraries/python/skills/skill-library/skill_library/skills/common/routines/web_research.py +++ b/libraries/python/skills/skill-library/skill_library/skills/research/routines/web_research.py @@ -18,7 +18,7 @@ async def main( topic: str, ) -> str: """Research a topic thoroughly and return a report.""" - plan, _ = await run("common.generate_research_plan", topic) + plan = await run("research.generate_research_plan", topic) await run("posix.write_file", f"{plan_name}.txt", json.dumps(plan, indent=2)) user_intent = "update" @@ -34,7 +34,8 @@ async def main( }, ) if user_intent == "update": - plan, _ = await run("common.update_research_plan", plan) + plan = json.loads(await run("posix.read_file", f"{plan_name}.txt")) + plan = await run("research.update_research_plan", topic, plan) await run("posix.write_file", f"{plan_name}.txt", json.dumps(plan, indent=2)) if user_intent == "exit": @@ -42,25 +43,26 @@ async def main( await run("posix.delete_file", f"{plan_name}.txt") return "" + plan = json.loads(await run("posix.read_file", f"{plan_name}.txt")) research_answers_filename = f"{plan_name}_research_answers.md" - await run("posix.touch", research_answers_filename) + await run("posix.write_file", research_answers_filename, f"# Research on {topic}\n\n") for question in plan: is_good_answer = False query = question previous_searches = [] while not is_good_answer: related_web_content = await run( - "common.web_search", search_description=query, previous_searches=previous_searches + "research.web_search", search_description=query, previous_searches=previous_searches ) - answer, _ = await run("common.answer_question_about_content", related_web_content, question) - is_good_answer, reasoning = await run("common.evaluate_answer", question, answer) + answer = await run("research.answer_question_about_content", related_web_content, question) + is_good_answer, reasoning = await run("research.evaluate_answer", question, answer) if is_good_answer: - await run("posix.append_file", research_answers_filename, f"##{question}\\n\\n{answer}\\n\\n") + await run("posix.append_file", research_answers_filename, f"## {question}\n\n{answer}\n\n") else: previous_searches.append((query, reasoning)) answers = await run("posix.read_file", research_answers_filename) - report = await run("common.summarize", answers, topic) - await run("posix.write_file", f"{plan_name}_research_report.txt", report) - print("Research complete.") - return report + conclusion = await run("common.summarize", answers, topic) + await run("posix.append_file", research_answers_filename, f"## Conclusion\n\n{conclusion}\n\n") + + return conclusion diff --git a/libraries/python/skills/skill-library/skill_library/skills/common/routines/web_search.py b/libraries/python/skills/skill-library/skill_library/skills/research/routines/web_search.py similarity index 76% rename from libraries/python/skills/skill-library/skill_library/skills/common/routines/web_search.py rename to libraries/python/skills/skill-library/skill_library/skills/research/routines/web_search.py index df2ca9fc..cbf94e27 100644 --- a/libraries/python/skills/skill-library/skill_library/skills/common/routines/web_search.py +++ b/libraries/python/skills/skill-library/skill_library/skills/research/routines/web_search.py @@ -31,24 +31,23 @@ async def main( """ # Generate search query. - search_query = await run("common.generate_search_query", search_description, previous_searches or []) + search_query = await run("research.generate_search_query", search_description, previous_searches or []) # Search Bing. urls = await run("common.bing_search", search_query) # Summarize page content from each search result. + metadata = {} results = {} - debug_i = 0 for url in urls: content = await run("common.get_content_from_url", url, 10000) - summary = await run("common.summarize", search_description, content) + summary = await run("common.summarize", content=content, aspect=search_description) results[url] = summary - context.log({f"summarize_url_content_{debug_i}": summary}) - - debug_i += 1 + metadata[url] = {"summary": summary} # Summarize all pages into a final result. - response = await run("common.summarize", search_description, json.dumps(results, indent=2)) - context.log({"summarize_all_results": response}) + response = await run("common.consolidate", json.dumps(results, indent=2)) + metadata["consolidated"] = response + context.log("web_search", metadata) return response diff --git a/libraries/python/skills/skill-library/skill_library/types.py b/libraries/python/skills/skill-library/skill_library/types.py index 8f2e564d..9034033e 100644 --- a/libraries/python/skills/skill-library/skill_library/types.py +++ b/libraries/python/skills/skill-library/skill_library/types.py @@ -9,6 +9,8 @@ from openai import AsyncAzureOpenAI, AsyncOpenAI from semantic_workbench_api_model.workbench_model import ConversationMessageList +from .usage import routines_usage as usage_routines_usage + if TYPE_CHECKING: from .skill import Skill @@ -17,9 +19,9 @@ class RunContext: """ - "Run context" is passed to parts of the system (skill routines and - actions, and chat driver functions) that need to be able to run routines or - actions, set assistant state, or emit messages from the assistant. + Every skill routine is executed with a "Run context". This is how we give + routines everything they need to interact one another and the "outside + world". """ def __init__( @@ -57,16 +59,30 @@ def __init__( # the current run. self.metadata_log: list[tuple[str, Metadata]] = [] - def log(self, metadata: Metadata) -> None: + def log(self, message: str, metadata: Metadata) -> None: + """ + Log a message with metadata. The metadata will be stored in the + `metadata_log` list and can be inspected to see all the things that + happened for a given run. + """ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] - self.metadata_log.append((ts, metadata)) + if "ts" not in metadata: + metadata["ts"] = ts + if "session_id" not in metadata: + metadata["session_id"] = self.session_id + if "run_id" not in metadata: + metadata["run_id"] = self.run_id + self.metadata_log.append((message, metadata)) + + def routine_usage(self) -> str: + return usage_routines_usage(self.skills) class RunContextProvider(Protocol): """ A provider of a run context must have this method. When called, it will - return a run context. This is used by skill routines and actions to have - access to all the things they need for running. + return a run context. This is used by skill routines to have access to all + the things they need for running. """ def create_run_context(self) -> RunContext: ... diff --git a/libraries/python/skills/skill-library/skill_library/usage.py b/libraries/python/skills/skill-library/skill_library/usage.py index 62cd8c3f..ab2c2b05 100644 --- a/libraries/python/skills/skill-library/skill_library/usage.py +++ b/libraries/python/skills/skill-library/skill_library/usage.py @@ -2,59 +2,61 @@ import inspect from dataclasses import dataclass -from typing import Any, Callable, cast +from typing import TYPE_CHECKING, Any, Callable, cast -from .skill import RoutineFn +if TYPE_CHECKING: + from .skill import RoutineFn, Skill # Standard routine parameters we want to skip in documentation -STANDARD_PARAMS = {"context", "ask_user", "run", "get_state", "set_state", "emit"} +STANDARD_PARAMS = {"context", "ask_user", "run", "routine_state", "emit"} -@dataclass -class Parameter: - """Describes a single parameter of a routine""" +def format_type(type_hint: Any) -> str: + """Format type hints into readable strings""" + import typing + from typing import _GenericAlias # type: ignore - name: str - type: Any - description: str | None - default_value: Any | None = None + # If it's a string representation of a type, clean it up + if isinstance(type_hint, str): + return type_hint.replace("typing.", "") - def _format_type(self, type_hint: Any) -> str: - """Format type hints into readable strings""" - import typing - from typing import _GenericAlias # type: ignore + # Handle built-in types + if isinstance(type_hint, type): + return type_hint.__name__ - # If it's a string representation of a type, clean it up - if isinstance(type_hint, str): - return type_hint.replace("typing.", "") + # Handle typing._GenericAlias (like list[str]) + if isinstance(type_hint, _GenericAlias): + origin = typing.get_origin(type_hint) + args = typing.get_args(type_hint) - # Handle built-in types - if isinstance(type_hint, type): - return type_hint.__name__ + if origin == typing.Union: + if type(None) in args: + # Get the non-None type + real_type = next(arg for arg in args if arg is not type(None)) + return f"Optional[{format_type(real_type)}]" + return " | ".join(format_type(arg) for arg in args) - # Handle typing._GenericAlias (like list[str]) - if isinstance(type_hint, _GenericAlias): - origin = typing.get_origin(type_hint) - args = typing.get_args(type_hint) + if origin: + formatted_args = [format_type(arg) for arg in args] + origin_name = origin.__name__ if hasattr(origin, "__name__") else str(origin) + return f"{origin_name}[{', '.join(formatted_args)}]" - if origin == typing.Union: - if type(None) in args: - # Get the non-None type - real_type = next(arg for arg in args if arg is not type(None)) - return f"Optional[{self._format_type(real_type)}]" - return " | ".join(self._format_type(arg) for arg in args) + # If we get here, just convert to string and clean it up + type_str = str(type_hint) + return type_str.replace("typing.", "").replace("NoneType", "None").replace("ForwardRef(", "").replace(")", "") - if origin: - formatted_args = [self._format_type(arg) for arg in args] - origin_name = origin.__name__ if hasattr(origin, "__name__") else str(origin) - return f"{origin_name}[{', '.join(formatted_args)}]" - # If we get here, just convert to string and clean it up - type_str = str(type_hint) - return type_str.replace("typing.", "").replace("NoneType", "None").replace("ForwardRef(", "").replace(")", "") +@dataclass +class Parameter: + """Describes a single parameter of a routine""" + + name: str + type: Any + description: str | None + default_value: Any | None = None def __str__(self) -> str: - param_type = self._format_type(self.type) + param_type = format_type(self.type) usage = f"{self.name}: {param_type}" if self.default_value is not inspect.Parameter.empty: @@ -117,7 +119,7 @@ def to_markdown(self) -> str: return routine -def get_routine_parameters(fn: RoutineFn) -> list[Parameter]: +def get_routine_parameters(fn: "RoutineFn") -> list[Parameter]: """Extract parameter information from a routine, excluding standard parameters""" # Cast to Callable to access function attributes func = cast(Callable, fn) @@ -134,9 +136,21 @@ def get_routine_parameters(fn: RoutineFn) -> list[Parameter]: ] -def get_routine_usage(fn: RoutineFn, name: str | None = None) -> RoutineUsage: +def get_routine_usage(fn: "RoutineFn", name: str | None = None) -> RoutineUsage: """Get the usage documentation for a routine""" func = cast(Callable, fn) routine_name = name if name is not None else getattr(func, "__name__", "unnamed_routine") routine_description = inspect.getdoc(func) or "" return RoutineUsage(name=routine_name, parameters=get_routine_parameters(fn), description=routine_description) + + +def routines_usage(skills: dict[str, "Skill"]) -> str: + routines: list[str] = [] + for skill_name, skill in skills.items(): + for routine_name in skill.list_routines(): + routine = skill.get_routine(routine_name) + if not routine: + continue + usage = get_routine_usage(routine, f"{skill_name}.{routine_name}") + routines.append(f"- {usage.to_markdown()}") + return "\n".join(routines)