From 12870498c6c4cab47d2e35da8a29cc20afc29c49 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 18 Nov 2024 11:30:39 -0800 Subject: [PATCH] papayne/guided conversation skill (#248) - Re-implements the guided conversation library as a skill. WIP... tests and examples and being able to compose in subroutines coming next. - Removes RunContext from config of skill library (should only be in request/response calls to existing assistants). - Changes Skill dependency management from class to instance... which allows us to use multiple instances of a skill, but configured as dependencies to other assistants. This allows (requires) a dev to define their skill dependency entirely, deterministically. --------- Co-authored-by: Paul Payne --- .../assistant/assistant_registry.py | 13 +- .../assistant/skill_assistant.py | 106 +++-- .../openai-client/openai_client/__init__.py | 39 +- .../openai_client/chat_driver/chat_driver.py | 7 +- .../skill-library/skill_library/__init__.py | 4 +- .../skill-library/skill_library/assistant.py | 134 ++++-- .../skill-library/skill_library/routine.py | 6 +- .../skill_library/routine_runners/__init__.py | 4 +- .../function_routine_runner.py | 20 - .../state_machine_routine_runner.py | 18 + .../skill_library/run_context.py | 55 ++- .../skill-library/skill_library/skill.py | 24 +- .../skill_library/skill_registry.py | 131 +++-- .../skill-library/skill_library/types.py | 6 + .../document_skill/document_skill.py | 2 +- .../form_filler_skill/agenda.py | 24 - .../form_filler_skill/artifact.py | 446 ------------------ .../chat_drivers/fix_agenda_error.py | 53 --- .../chat_drivers/fix_artifact_error.py | 63 --- .../chat_drivers/unneeded/choose_action.py | 237 ---------- .../unneeded/choose_action_template.py | 52 -- .../unneeded/execute_reasoning.py | 57 --- .../chat_drivers/update_agenda.py | 126 ----- .../chat_drivers/update_agenda_template.py | 31 -- .../chat_drivers/update_artifact.py | 204 -------- .../chat_drivers/update_artifact_template.py | 32 -- .../form_filler_skill/definition.py | 14 - .../form_filler_skill/form_filler_skill.py | 25 +- .../guided_conversation/__init__.py | 9 + .../guided_conversation/agenda.py | 5 +- .../guided_conversation/artifact.py | 377 ++++----------- .../chat_drivers/final_artifact_update.py} | 98 ++-- .../chat_drivers/fix_agenda_error.py | 79 ++++ .../chat_drivers/fix_artifact_error.py | 107 +++++ .../chat_drivers/gc_final_update.py | 82 ---- .../chat_drivers/gc_fix_agenda_error.py | 59 --- .../chat_drivers/gc_update_agenda.py | 239 ---------- .../chat_drivers/gc_update_artifact.py | 57 --- .../chat_drivers/generate_artifact_updates.py | 153 ++++++ .../chat_drivers/generate_message.py | 102 ++++ .../chat_drivers/unneeded}/base_model_llm.py | 0 .../chat_drivers/update_agenda.py | 296 ++++++++++++ .../guided_conversation/definition.py | 25 +- .../guided_conversation_skill.py | 222 +++++++++ .../{ => guided_conversation}/message.py | 5 +- .../guided_conversation/resources.py | 83 +++- .../tests/test_integration.py | 21 + .../guided_conversation_skill.py | 154 ------ .../form_filler_skill/resources.py | 275 ----------- .../posix-skill/posix_skill/posix_skill.py | 2 +- .../prospector_skill/skill.py | 2 +- .../skills/skill-template/your_skill/skill.py | 2 +- 52 files changed, 1558 insertions(+), 2829 deletions(-) delete mode 100644 libraries/python/skills/skill-library/skill_library/routine_runners/function_routine_runner.py create mode 100644 libraries/python/skills/skill-library/skill_library/routine_runners/state_machine_routine_runner.py create mode 100644 libraries/python/skills/skill-library/skill_library/types.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/agenda.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/artifact.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/fix_agenda_error.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/fix_artifact_error.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/unneeded/choose_action.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/unneeded/choose_action_template.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/unneeded/execute_reasoning.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_agenda.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_agenda_template.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_artifact.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_artifact_template.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/definition.py rename libraries/python/skills/skills/form-filler-skill/form_filler_skill/{chat_drivers/final_update.py => guided_conversation/chat_drivers/final_artifact_update.py} (63%) create mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/fix_agenda_error.py create mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/fix_artifact_error.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_final_update.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_fix_agenda_error.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_update_agenda.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_update_artifact.py create mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/generate_artifact_updates.py create mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/generate_message.py rename libraries/python/skills/skills/form-filler-skill/form_filler_skill/{ => guided_conversation/chat_drivers/unneeded}/base_model_llm.py (100%) create mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/update_agenda.py create mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/guided_conversation_skill.py rename libraries/python/skills/skills/form-filler-skill/form_filler_skill/{ => guided_conversation}/message.py (95%) create mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/tests/test_integration.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation_skill.py delete mode 100644 libraries/python/skills/skills/form-filler-skill/form_filler_skill/resources.py diff --git a/assistants/skill-assistant/assistant/assistant_registry.py b/assistants/skill-assistant/assistant/assistant_registry.py index 473646ef..10618f0a 100644 --- a/assistants/skill-assistant/assistant/assistant_registry.py +++ b/assistants/skill-assistant/assistant/assistant_registry.py @@ -1,7 +1,7 @@ import asyncio import logging from pathlib import Path -from typing import List +from typing import Optional from openai_client.chat_driver import ChatDriverConfig from skill_library import Assistant, Skill @@ -29,7 +29,7 @@ async def get_or_create_assistant( assistant_id: str, event_mapper: SkillEventMapperProtocol, chat_driver_config: ChatDriverConfig, - skills: List[Skill] = [], + skills: Optional[dict[str, Skill]] = None, ) -> Assistant: """ Get or create an assistant for the given conversation context. @@ -52,7 +52,7 @@ async def register_assistant( assistant_id: str, event_mapper: SkillEventMapperProtocol, chat_driver_config: ChatDriverConfig, - skills: List[Skill] = [], + skills: dict[str, Skill] | None = None, ) -> Assistant: """ Define the skill assistant that you want to have backing this assistant @@ -60,15 +60,18 @@ async def register_assistant( to include here. """ + # for skill in skills: + # FIXME: add emit here? + # Create the assistant. assistant = Assistant( name="Assistant", assistant_id=assistant_id, drive_root=Path(".data") / assistant_id / "assistant", - metadrive_drive_root=Path(".data") / assistant_id / ".assistant", + metadata_drive_root=Path(".data") / assistant_id / ".assistant", chat_driver_config=chat_driver_config, + skills=skills, ) - assistant.register_skills(skills) # Assistant event consumer. async def subscribe() -> None: diff --git a/assistants/skill-assistant/assistant/skill_assistant.py b/assistants/skill-assistant/assistant/skill_assistant.py index bd59aad4..f81a0c54 100644 --- a/assistants/skill-assistant/assistant/skill_assistant.py +++ b/assistants/skill-assistant/assistant/skill_assistant.py @@ -9,9 +9,12 @@ import logging from pathlib import Path +from typing import Any, Optional import openai_client from content_safety.evaluators import CombinedContentSafetyEvaluator +from form_filler_skill import FormFillerSkill +from form_filler_skill.guided_conversation import GuidedConversationSkill from openai_client.chat_driver import ChatDriverConfig # from form_filler_skill import FormFillerSkill @@ -30,6 +33,7 @@ ContentSafetyEvaluator, ConversationContext, ) +from skill_library.types import Metadata from assistant.skill_event_mapper import SkillEventMapper @@ -97,9 +101,28 @@ async def content_evaluator_factory(context: ConversationContext) -> ContentSafe assistant_registry = AssistantRegistry() +# Handle the event triggered when the assistant is added to a conversation. +@assistant.events.conversation.on_created +async def on_conversation_created(conversation_context: ConversationContext) -> None: + """ + Handle the event triggered when the assistant is added to a 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( + NewConversationMessage( + content=welcome_message, + message_type=MessageType.chat, + metadata={"generated_content": False}, + ) + ) + + @assistant.events.conversation.message.chat.on_created async def on_message_created( - context: ConversationContext, event: ConversationEvent, message: ConversationMessage + conversation_context: ConversationContext, event: ConversationEvent, message: ConversationMessage ) -> None: """ Handle the event triggered when a new chat message is created in the conversation. @@ -115,56 +138,38 @@ async def on_message_created( """ # pass the message to the core response logic - await respond_to_conversation(context, event, message) + async with conversation_context.set_status("thinking..."): + config = await assistant_config.get(conversation_context.assistant) + metadata: dict[str, Any] = {"debug": {"content_safety": event.data.get(content_safety.metadata_key, {})}} + await respond_to_conversation(conversation_context, config, message, metadata) -@assistant.events.conversation.message.command.on_created -async def on_command_message_created( - context: ConversationContext, event: ConversationEvent, message: ConversationMessage -) -> None: - """ - Handle the event triggered when a new command message is created in the conversation. - """ +# @assistant.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. +# """ - # pass the message to the core response logic - await respond_to_conversation(context, event, message) - - -# Handle the event triggered when the assistant is added to a conversation. -@assistant.events.conversation.on_created -async def on_conversation_created(context: ConversationContext) -> None: - """ - Handle the event triggered when the assistant is added to a conversation. - """ - - # send a welcome message to the conversation - config = await assistant_config.get(context.assistant) - welcome_message = config.welcome_message - await context.send_messages( - NewConversationMessage( - content=welcome_message, - message_type=MessageType.chat, - metadata={"generated_content": False}, - ) - ) +# # pass the message to the core response logic +# async with conversation_context.set_status("thinking..."): +# config = await assistant_config.get(conversation_context.assistant) +# metadata: dict[str, Any] = {"debug": {"content_safety": event.data.get(content_safety.metadata_key, {})}} +# await respond_to_conversation(conversation_context, config, message, metadata) # Core response logic for handling messages (chat or command) in the conversation. async def respond_to_conversation( conversation_context: ConversationContext, - event: ConversationEvent, + config: AssistantConfigModel, message: ConversationMessage, + metadata: Optional[Metadata] = None, ) -> None: """ Respond to a conversation message. """ - # Get the assistant configuration. - config = await assistant_config.get(conversation_context.assistant) - - # TODO: pass metadata to the assistant for at least adding the content safety metadata to debug. - # metadata = {"debug": {"content_safety": event.data.get(content_safety.metadata_key, {})}} - # Update the participant status to indicate the assistant is thinking. await conversation_context.update_participant_me(UpdateParticipant(status="thinking...")) @@ -174,27 +179,31 @@ async def respond_to_conversation( # Create and register an assistant if necessary. if not assistant: try: - async_client = openai_client.create_client(config.service_config) + language_model = openai_client.create_client(config.service_config) chat_driver_config = ChatDriverConfig( - openai_client=async_client, + openai_client=language_model, model=config.chat_driver_config.openai_model, instructions=config.chat_driver_config.instructions, - # context will be overwritten by the assistant when initialized. ) assistant = await assistant_registry.register_assistant( conversation_context.id, SkillEventMapper(conversation_context), chat_driver_config, - [ - PosixSkill( + { + "posix": PosixSkill( sandbox_dir=Path(".data") / conversation_context.id, chat_driver_config=chat_driver_config, mount_dir="/mnt/data", ), - # FormFillerSkill( - # chat_driver_config=chat_driver_config, - # ), - ], + "form_filler": FormFillerSkill( + chat_driver_config=chat_driver_config, + language_model=language_model, + ), + "guided_conversation": GuidedConversationSkill( + chat_driver_config=chat_driver_config, + language_model=language_model, + ), + }, ) except Exception as e: @@ -210,7 +219,7 @@ async def respond_to_conversation( await conversation_context.update_participant_me(UpdateParticipant(status=None)) try: - await assistant.put_message(message.content) + await assistant.put_message(message.content, metadata) except Exception as e: logging.exception("exception in on_message_created") await conversation_context.send_messages( @@ -219,6 +228,3 @@ async def respond_to_conversation( content=f"Unhandled error: {e}", ) ) - finally: - # update the participant status to indicate the assistant is done thinking - await conversation_context.update_participant_me(UpdateParticipant(status=None)) diff --git a/libraries/python/openai-client/openai_client/__init__.py b/libraries/python/openai-client/openai_client/__init__.py index 6543f748..534555f0 100644 --- a/libraries/python/openai-client/openai_client/__init__.py +++ b/libraries/python/openai-client/openai_client/__init__.py @@ -1,8 +1,9 @@ -import logging +import logging as _logging # Avoid name conflict with local logging module. from .client import ( create_client, ) +from .completion import message_content_from_completion, message_from_completion from .config import ( AzureOpenAIApiKeyAuthConfig, AzureOpenAIAzureIdentityAuthConfig, @@ -10,7 +11,20 @@ OpenAIServiceConfig, ServiceConfig, ) +from .errors import ( + CompletionError, + validate_completion, +) +from .logging import ( + add_serializable_data, + make_completion_args_serializable, +) from .messages import ( + create_assistant_message, + create_system_message, + create_user_message, + format_with_dict, + format_with_liquid, truncate_messages_for_logging, ) from .tokens import ( @@ -19,19 +33,28 @@ num_tokens_from_tools_and_messages, ) -logger = logging.getLogger(__name__) - +logger = _logging.getLogger(__name__) __all__ = [ + "add_serializable_data", + "AzureOpenAIApiKeyAuthConfig", + "AzureOpenAIAzureIdentityAuthConfig", + "AzureOpenAIServiceConfig", + "CompletionError", "create_client", - "truncate_messages_for_logging", + "create_assistant_message", + "create_system_message", + "create_user_message", + "format_with_dict", + "format_with_liquid", + "make_completion_args_serializable", + "message_content_from_completion", + "message_from_completion", "num_tokens_from_message", "num_tokens_from_messages", "num_tokens_from_tools_and_messages", - "AzureOpenAIApiKeyAuthConfig", - "AzureOpenAIAzureIdentityAuthConfig", - "AzureOpenAIServiceConfig", "OpenAIServiceConfig", "ServiceConfig", - "logger", + "truncate_messages_for_logging", + "validate_completion", ] diff --git a/libraries/python/openai-client/openai_client/chat_driver/chat_driver.py b/libraries/python/openai-client/openai_client/chat_driver/chat_driver.py index 22119ffc..f5d9a2e0 100644 --- a/libraries/python/openai-client/openai_client/chat_driver/chat_driver.py +++ b/libraries/python/openai-client/openai_client/chat_driver/chat_driver.py @@ -2,7 +2,7 @@ from typing import Any, Callable, Union from events import BaseEvent, ErrorEvent, MessageEvent -from openai import AsyncOpenAI +from openai import AsyncAzureOpenAI, AsyncOpenAI from openai.types.chat import ( ChatCompletionMessageParam, ChatCompletionSystemMessageParam, @@ -21,7 +21,7 @@ @dataclass class ChatDriverConfig: - openai_client: AsyncOpenAI + openai_client: AsyncOpenAI | AsyncAzureOpenAI model: str instructions: str | list[str] = "You are a helpful assistant." instruction_formatter: MessageFormatter | None = None @@ -130,6 +130,7 @@ async def respond( response_format: Union[ResponseFormat, type[BaseModel]] = TEXT_RESPONSE_FORMAT, function_choice: list[str] | None = None, instruction_parameters: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, ) -> BaseEvent: """ Respond to a user message. @@ -165,7 +166,7 @@ async def respond( await self.add_message(user_message) # Generate a response. - metadata = {} + metadata = metadata or {} completion_args = { "model": self.model, diff --git a/libraries/python/skills/skill-library/skill_library/__init__.py b/libraries/python/skills/skill-library/skill_library/__init__.py index afe9eecb..893a1b00 100644 --- a/libraries/python/skills/skill-library/skill_library/__init__.py +++ b/libraries/python/skills/skill-library/skill_library/__init__.py @@ -3,7 +3,7 @@ from context import Context from .assistant import Assistant -from .routine import FunctionRoutine, InstructionRoutine, ProgramRoutine, RoutineTypes +from .routine import InstructionRoutine, ProgramRoutine, RoutineTypes, StateMachineRoutine from .skill import EmitterType, Skill logger = logging.getLogger(__name__) @@ -12,7 +12,7 @@ "Assistant", "Context", "EmitterType", - "FunctionRoutine", + "StateMachineRoutine", "InstructionRoutine", "ProgramRoutine", "RoutineTypes", diff --git a/libraries/python/skills/skill-library/skill_library/assistant.py b/libraries/python/skills/skill-library/skill_library/assistant.py index 804d2017..7c3793ea 100644 --- a/libraries/python/skills/skill-library/skill_library/assistant.py +++ b/libraries/python/skills/skill-library/skill_library/assistant.py @@ -1,6 +1,6 @@ import asyncio from os import PathLike -from typing import Any, AsyncIterator +from typing import Any, AsyncIterator, Optional from uuid import uuid4 from assistant_drive import Drive, DriveConfig, IfDriveFileExistsBehavior @@ -17,9 +17,12 @@ from openai_client.completion import TEXT_RESPONSE_FORMAT from openai_client.messages import format_with_liquid +from skill_library.routine_stack import RoutineStack + from .run_context import RunContext from .skill import Skill from .skill_registry import SkillRegistry +from .types import Metadata class Assistant: @@ -29,66 +32,69 @@ def __init__( assistant_id: str | None, chat_driver_config: ChatDriverConfig, drive_root: PathLike | None = None, - metadrive_drive_root: PathLike | None = None, - skills: list[Skill] = [], + metadata_drive_root: PathLike | None = None, + skills: dict[str, Skill] | None = None, + startup_action: str | None = None, ) -> None: - self.skill_registry: SkillRegistry = SkillRegistry() - + # Do we ever use this? No. We don't. It just seems like it would be a + # good idea, though. self.name = name - if not assistant_id: - assistant_id = str(uuid4()) + # This, though, we use. + self.assistant_id = assistant_id or str(uuid4()) - # Configure the assistant chat interface. - if chat_driver_config.message_provider is None: - chat_driver_config.message_provider = LocalMessageHistoryProvider( - LocalMessageHistoryProviderConfig(session_id=assistant_id, formatter=format_with_liquid) - ) - self.chat_driver = self._register_chat_driver(chat_driver_config) + # The routine stack is used to keep track of the current routine being + # run by the assistant. + self.routine_stack = RoutineStack(self.metadrive) + + # Register all skills for the assistant. + self.skill_registry = SkillRegistry(skills, self.routine_stack) if skills else None # Set up the assistant event queue. self._event_queue = asyncio.Queue() # Async queue for events self._stopped = asyncio.Event() # Event to signal when the assistant has stopped - if skills: - self.register_skills(skills) - # The assistant drive can be used to read and write files to a - # particular location. The assistant drive should be used for - # assistant-specific data and not for general data storage. - self.drive: Drive = Drive( + # A metadrive to be used for managing assistant metadata. This can be + # useful for storing session data, logs, and other information that + # needs to be persisted across different calls to the assistant. This is + # not data intended to be accessed by users or skills. + self.metadrive = Drive( DriveConfig( - root=drive_root or f".data/{assistant_id}/assistant", + root=metadata_drive_root or f".data/{assistant_id}/.assistant", default_if_exists_behavior=IfDriveFileExistsBehavior.OVERWRITE, ) ) - # The assistant run context identifies the assistant session (session) - # and provides necessary utilities to be used for this particular - # assistant session. The run context is passed to the assistant's chat - # driver commands and functions and all skill actions and routines that - # are run by the assistant. - self.run_context = RunContext( - session_id=assistant_id or str(uuid4()), - assistant_drive=self.drive, - emit=self._emit, - run_routine=self.skill_registry.run_routine_by_name, - metadata_drive_root=metadrive_drive_root, + # The assistant drive is used as the storage bucket for all assistant + # and skill data. Skills will generally create a subdrive off of this + # drive. + self.drive = Drive( + DriveConfig( + root=drive_root or f".data/{assistant_id}/assistant", + default_if_exists_behavior=IfDriveFileExistsBehavior.OVERWRITE, + ) ) + # Configure the assistant chat interface. + if chat_driver_config.message_provider is None: + chat_driver_config.message_provider = LocalMessageHistoryProvider( + LocalMessageHistoryProviderConfig(session_id=self.assistant_id, formatter=format_with_liquid) + ) + self.chat_driver = self._register_chat_driver(chat_driver_config) + ###################################### # Lifecycle and event handling ###################################### - async def wait(self) -> RunContext: + async def wait(self) -> str: """ After initializing an assistant, call this method to wait for assistant events. While running, any events produced by the assistant can be accessed through the `events` property. When the assistant completes, - this method returns the session_id of the assistant session. + this method returns the assistant_id of the assistant. """ - await self._stopped.wait() - return self.run_context + return self.assistant_id def stop(self) -> None: self._stopped.set() # Signal that we are stopping @@ -108,10 +114,23 @@ async def events(self) -> AsyncIterator[EventProtocol]: await asyncio.sleep(0.005) def _emit(self, event: EventProtocol) -> None: - event.session_id = self.run_context.session_id + event.session_id = self.assistant_id self._event_queue.put_nowait(event) - async def put_message(self, message: str) -> None: + def create_run_context(self) -> RunContext: + # The 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. + return RunContext( + session_id=self.assistant_id, + assistant_drive=self.drive, + emit=self._emit, + run_routine=self.skill_registry.run_routine_by_designation if self.skill_registry else None, + routine_stack=self.routine_stack, + ) + + async def put_message(self, message: str, metadata: Optional[Metadata] = None) -> None: """ Exposed externally for sending messages to the assistant. @@ -131,11 +150,11 @@ async def put_message(self, message: str) -> None: send the message to the chat driver. """ # If a routine is running, send the message to the routine. - if await self.run_context.routine_stack.peek(): + if await self.routine_stack.peek(): await self.step_active_routine(message) else: # Otherwise, send the message to the chat driver. - response = await self.chat_driver.respond(message) + response = await self.chat_driver.respond(message, metadata=metadata) self._emit(response) ###################################### @@ -156,6 +175,10 @@ def _register_chat_driver(self, chat_driver_config: ChatDriverConfig) -> ChatDri chat_functions = ChatFunctions(self) functions = [chat_functions.list_routines, chat_functions.run_routine] + + # TODO: Allow optional adding of skill actions here. + # self.skill_registry is already available here. + config.commands = functions config.functions = functions return ChatDriver(config) @@ -175,8 +198,9 @@ async def generate_response( if not self.chat_driver: raise ValueError("No chat driver registered for this assistant.") - instruction_parameters["actions"] = ", ".join(self.skill_registry.list_actions()) - instruction_parameters["routines"] = ", ".join(self.skill_registry.list_routines()) + if self.skill_registry: + instruction_parameters["actions"] = ", ".join(self.skill_registry.list_actions()) + instruction_parameters["routines"] = ", ".join(self.skill_registry.list_routines()) return await self.chat_driver.respond( message, @@ -188,29 +212,39 @@ async def generate_response( # Skill interface ###################################### - def register_skills(self, skills: list[Skill]) -> None: - """Register a skill with the assistant. You need to register all skills - that an assistant uses at the same time so dependencies can be loaded in - the correct order.""" - self.skill_registry.register_all_skills(skills) - # def list_actions(self, context: Context) -> list[str]: # """Lists all the actions the assistant is able to perform.""" # return self.skill_registry.list_actions() def list_routines(self) -> list[str]: """Lists all the routines the assistant is able to perform.""" - return self.skill_registry.list_routines() + return self.skill_registry.list_routines() if self.skill_registry else [] async def run_routine(self, name: str, vars: dict[str, Any] | None = None) -> Any: """ Run an assistant routine by name (e.g. .). """ - await self.skill_registry.run_routine_by_name(self.run_context, name, vars) + if not self.skill_registry: + raise ValueError("No skill registry registered for this assistant.") + await self.skill_registry.run_routine_by_designation(self.create_run_context(), name, vars) + + def list_actions(self) -> list[str]: + """Lists all the actions the assistant is able to perform.""" + return self.skill_registry.list_actions() if self.skill_registry else [] + + def run_action(self, name: str, vars: dict[str, Any] | None = None) -> Any: + """ + Run an assistant action by name (e.g. .). + """ + if not self.skill_registry: + raise ValueError("No skill registry registered for this assistant.") + return self.skill_registry.run_action_by_designation(self.create_run_context(), name, vars) async def step_active_routine(self, message: str) -> None: """Run another step in the current routine.""" - await self.skill_registry.step_active_routine(self.run_context, message) + if not self.skill_registry: + raise ValueError("No skill registry registered for this assistant.") + await self.skill_registry.step_active_routine(self.create_run_context(), message) class ChatFunctions: diff --git a/libraries/python/skills/skill-library/skill_library/routine.py b/libraries/python/skills/skill-library/skill_library/routine.py index 2c5ad675..91fe9de0 100644 --- a/libraries/python/skills/skill-library/skill_library/routine.py +++ b/libraries/python/skills/skill-library/skill_library/routine.py @@ -73,13 +73,13 @@ def __str__(self) -> str: return f"{self.name}(vars: {template_vars}): {self.description}" -class FunctionRoutine(Routine): +class StateMachineRoutine(Routine): def __init__( self, name: str, description: str, init_function: Callable[[RunContext, Optional[Dict[str, Any]]], Awaitable[None]], - step_function: Callable[[RunContext, Optional[str]], Awaitable[Optional[str]]], + step_function: Callable[[RunContext, Optional[str]], Awaitable[Optional[Any]]], skill: "Skill", ) -> None: super().__init__( @@ -94,4 +94,4 @@ def __str__(self) -> str: return f"{self.name}: {self.description}" -RoutineTypes = Union[InstructionRoutine, ProgramRoutine, FunctionRoutine] +RoutineTypes = Union[InstructionRoutine, ProgramRoutine, StateMachineRoutine] diff --git a/libraries/python/skills/skill-library/skill_library/routine_runners/__init__.py b/libraries/python/skills/skill-library/skill_library/routine_runners/__init__.py index 72a64325..4a633b4c 100644 --- a/libraries/python/skills/skill-library/skill_library/routine_runners/__init__.py +++ b/libraries/python/skills/skill-library/skill_library/routine_runners/__init__.py @@ -1,9 +1,9 @@ from typing import Union -from .function_routine_runner import FunctionRoutineRunner from .instruction_routine_runner import InstructionRoutineRunner from .program_routine_runner import ProgramRoutineRunner +from .state_machine_routine_runner import StateMachineRoutineRunner -RunnerTypes = Union[InstructionRoutineRunner, ProgramRoutineRunner, FunctionRoutineRunner] +RunnerTypes = Union[InstructionRoutineRunner, ProgramRoutineRunner, StateMachineRoutineRunner] __all__ = ["InstructionRoutineRunner", "RunnerTypes"] diff --git a/libraries/python/skills/skill-library/skill_library/routine_runners/function_routine_runner.py b/libraries/python/skills/skill-library/skill_library/routine_runners/function_routine_runner.py deleted file mode 100644 index f57e09a1..00000000 --- a/libraries/python/skills/skill-library/skill_library/routine_runners/function_routine_runner.py +++ /dev/null @@ -1,20 +0,0 @@ -from typing import Any - -from ..routine import FunctionRoutine -from ..run_context import RunContext - - -class FunctionRoutineRunner: - def __init__(self) -> None: - pass - - async def run( - self, context: RunContext, routine: FunctionRoutine, vars: dict[str, Any] | None = None - ) -> Any: - routine.init_function(context, vars) - - async def next(self, context: RunContext, routine: FunctionRoutine, message: str) -> Any: - """ - Run the next step in the current routine. - """ - routine.step_function(context, message) diff --git a/libraries/python/skills/skill-library/skill_library/routine_runners/state_machine_routine_runner.py b/libraries/python/skills/skill-library/skill_library/routine_runners/state_machine_routine_runner.py new file mode 100644 index 00000000..76c47c5e --- /dev/null +++ b/libraries/python/skills/skill-library/skill_library/routine_runners/state_machine_routine_runner.py @@ -0,0 +1,18 @@ +from typing import Any + +from ..routine import StateMachineRoutine +from ..run_context import RunContext + + +class StateMachineRoutineRunner: + def __init__(self) -> None: + pass + + async def run(self, context: RunContext, routine: StateMachineRoutine, vars: dict[str, Any] | None = None) -> Any: + routine.init_function(context, vars) + + async def next(self, context: RunContext, routine: StateMachineRoutine, message: str) -> Any: + """ + Run the next step in the current routine. + """ + routine.step_function(context, message) diff --git a/libraries/python/skills/skill-library/skill_library/run_context.py b/libraries/python/skills/skill-library/skill_library/run_context.py index 4c14fbff..982a93c7 100644 --- a/libraries/python/skills/skill-library/skill_library/run_context.py +++ b/libraries/python/skills/skill-library/skill_library/run_context.py @@ -1,9 +1,9 @@ import logging -from os import PathLike -from typing import Any, Callable, Coroutine, Optional +from contextlib import asynccontextmanager +from typing import Any, AsyncGenerator, Callable, Coroutine, Optional from uuid import uuid4 -from assistant_drive import Drive, DriveConfig, IfDriveFileExistsBehavior +from assistant_drive import Drive from context import ContextProtocol from events.events import EventProtocol @@ -19,13 +19,19 @@ def emit(self, event: EventProtocol) -> None: class RunContext(ContextProtocol): + """ + "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. + """ + def __init__( self, session_id: str, assistant_drive: Drive, emit: Callable[[EventProtocol], None], + routine_stack: RoutineStack, run_routine: Callable[["RunContext", str, Optional[dict[str, Any]]], Coroutine[Any, Any, Any]], - metadata_drive_root: PathLike | None = None, ) -> None: # A session id is useful for maintaining consistent session state across all # consumers of this context. For example, a session id can be set in an @@ -48,25 +54,28 @@ def __init__( # event bus and handling the events sent to it with this function. self.emit = emit or LogEmitter().emit - # A metadrive to be used for managing assistant metadata. This can be - # useful for storing session data, logs, and other information that - # needs to be persisted across different calls to the assistant. - self.metadrive: Drive = Drive( - DriveConfig( - root=metadata_drive_root or f".data/{session_id}/.assistant", - default_if_exists_behavior=IfDriveFileExistsBehavior.OVERWRITE, - ) - ) - - # Functions for running routines. self.run_routine = run_routine - # The routine stack is used to keep track of the current routine being - # run by the assistant. - self.routine_stack: RoutineStack = RoutineStack(self.metadrive) - # Helper functions for managing state of the current routine being run. - self.state = self.routine_stack.get_current_state - self.state_key = self.routine_stack.get_current_state_key - self.update_state = self.routine_stack.set_current_state - self.update_state_key = self.routine_stack.set_current_state_key + self.get_state = routine_stack.get_current_state + self.get_state_key = routine_stack.get_current_state_key + self.set_state = routine_stack.set_current_state + self.set_state_key = routine_stack.set_current_state_key + + @asynccontextmanager + async def stack_frame_state(self) -> AsyncGenerator[dict[str, Any], None]: + """ + A context manager that allows you to get and set the state of the + current routine being run. This is useful for storing and retrieving + information that is needed across multiple steps of a routine. + + Example: + + ``` + async with context.stack_frame_state() as state: + state["key"] = "value" + ``` + """ + state = await self.get_state() + yield state + await self.set_state(state) diff --git a/libraries/python/skills/skill-library/skill_library/skill.py b/libraries/python/skills/skill-library/skill_library/skill.py index f9c4e99d..ef25e0c8 100644 --- a/libraries/python/skills/skill-library/skill_library/skill.py +++ b/libraries/python/skills/skill-library/skill_library/skill.py @@ -1,12 +1,12 @@ import logging -from typing import Any, Callable +from typing import Any, Callable, Type from events import BaseEvent, EventProtocol from openai.types.chat.completion_create_params import ResponseFormat from openai_client.chat_driver import ChatDriver, ChatDriverConfig from openai_client.completion import TEXT_RESPONSE_FORMAT -from .actions import Actions +from .actions import Action, Actions from .routine import RoutineTypes EmitterType = Callable[[EventProtocol], None] @@ -26,7 +26,7 @@ def __init__( self, name: str, description: str, - skill_actions: list[Callable] = [], # Functions to be registered as skill actions. + actions: list[Callable] = [], # Functions to be registered as skill actions. routines: list[RoutineTypes] = [], chat_driver_config: ChatDriverConfig | None = None, ) -> None: @@ -39,7 +39,7 @@ def __init__( # The routines in this skill might use actions from other skills. The dependency on # other skills should be declared here. The skill registry will ensure that all # dependencies are registered before this skill. - self.dependencies: list[str] = [] + self.dependencies: list[Type[Skill]] = [] # If a chat driver is provided, it will be used to respond to # conversational messages sent to the skill. Not all skills need to have @@ -49,17 +49,18 @@ def __init__( # skill subclass). self.chat_driver = ChatDriver(chat_driver_config) if chat_driver_config else None - # TODO: Configure up one of these separate from the chat driver. - self.openai_client = chat_driver_config.openai_client if chat_driver_config else None + # TODO: We maybe want to add actions to the skill's chat driver. If we + # do, strip the RunContext param. - # Register all provided actions with the action registry. + # Register all provided actions with the action registry so they can be executed by name. self.action_registry = Actions() - self.action_registry.add_functions(skill_actions) + self.action_registry.add_functions(actions) + # TODO: Is this helpful? # Also, register any commands provided by the chat driver. All # commands will be available to the skill. - if self.chat_driver: - self.action_registry.add_functions(self.chat_driver.get_commands()) + # if self.chat_driver: + # self.action_registry.add_functions(self.chat_driver.get_commands()) # Make actions available to be called as attributes from the skill # directly. @@ -85,6 +86,9 @@ async def respond( def get_actions(self) -> list[Callable]: return [function.fn for function in self.action_registry.get_actions()] + def get_action(self, name: str) -> Action | None: + return self.action_registry.get_action(name) + def list_actions(self) -> list[str]: return [action.name for action in self.action_registry.get_actions()] diff --git a/libraries/python/skills/skill-library/skill_library/skill_registry.py b/libraries/python/skills/skill-library/skill_library/skill_registry.py index d788989e..096ab1d9 100644 --- a/libraries/python/skills/skill-library/skill_library/skill_registry.py +++ b/libraries/python/skills/skill-library/skill_library/skill_registry.py @@ -1,69 +1,35 @@ import importlib import os -from typing import Any +from typing import Any, Optional -from .routine import FunctionRoutine, InstructionRoutine, ProgramRoutine, RoutineTypes -from .routine_runners import FunctionRoutineRunner, InstructionRoutineRunner, ProgramRoutineRunner +from .routine import InstructionRoutine, ProgramRoutine, RoutineTypes, StateMachineRoutine +from .routine_runners import InstructionRoutineRunner, ProgramRoutineRunner, StateMachineRoutineRunner +from .routine_stack import RoutineStack from .run_context import RunContext from .skill import Skill class SkillRegistry: """ - A skill registry is a collection of skills that an agent uses. When a skill - is added, its dependencies are also added. This allows an agent to have a - subset of the global skill registry. + A skill registry is a collection of skills that an assistant uses. When a + skill is added, its dependencies are also added. """ - def __init__(self, skills: list[Skill] = []) -> None: + def __init__(self, skills: dict[str, Skill], routine_stack: RoutineStack) -> None: # self.global_skill_registry = GlobalSkillRegistry() - self.required_skills = [] - self.registered_skills: dict[str, Skill] = {} - self.register_all_skills(skills) - - def register_all_skills(self, skills: list[Skill]) -> None: - """ - Register all skills and their dependencies. - """ - - # Topological sort of skills to ensure that dependencies are registered - # first. - sorted_skills: list[Skill] = [] - - def walk_skill(skill: Skill) -> None: - if skill.name in sorted_skills: - return - for dependency in skill.dependencies: - if dependency not in [skill.name for skill in self.required_skills]: - raise ValueError(f"Dependency {dependency} not found in global skill registry.") - - for required_skill in self.required_skills: - if required_skill.name == dependency: - walk_skill(required_skill) - break - - sorted_skills.append(skill) - - for skill in skills: - walk_skill(skill) - - # Register the sorted skills. - for skill in sorted_skills: - self.registered_skills[skill.name] = skill + self.skills = skills + self.routine_stack = routine_stack def get_skill(self, skill_name) -> Skill | None: - return self.registered_skills[skill_name] - - def get_skills(self) -> list[Skill]: - return list(self.registered_skills.values()) + return self.skills.get(skill_name) def list_actions(self) -> list[str]: """ List all namespaced function names available in the skill registry. """ actions = [] - for skill in self.get_skills(): - actions += [f"{skill.name}.{action}" for action in skill.list_actions()] + for skill_name, skill in self.skills.items(): + actions += [f"{skill_name}.{action}" for action in skill.list_actions()] return actions def list_routines(self) -> list[str]: @@ -71,52 +37,54 @@ def list_routines(self) -> list[str]: List all namespaced routine names available in the skill registry. """ routines = [] - for skill in self.get_skills(): - routines += [f"{skill.name}.{routine}" for routine in skill.list_routines()] + for skill_name, skill in self.skills.items(): + routines += [f"{skill_name}.{routine}" for routine in skill.list_routines()] return routines - def has_routine(self, routine_name: str) -> bool: + def has_routine(self, designation: str) -> bool: """ Check if a routine exists in the skill registry. """ - skill_name, routine = routine_name.split(".") + skill_name, routine = designation.split(".") skill = self.get_skill(skill_name) if not skill: return False return skill.has_routine(routine) - def get_routine(self, routine_name: str) -> RoutineTypes | None: + def get_routine(self, designation: str) -> RoutineTypes | None: """ - Get a routine by name. + Get a routine by . designation. """ - skill_name, routine = routine_name.split(".") + skill_name, routine = designation.split(".") skill = self.get_skill(skill_name) if not skill: return None return skill.get_routine(routine) - async def run_routine_by_name(self, context: RunContext, name: str, vars: dict[str, Any] | None = None) -> Any: + async def run_routine_by_designation( + self, context: RunContext, designation: str, vars: dict[str, Any] | None = None + ) -> Any: """ - Run an assistant routine by name (.). + Run an assistant routine by designation (.). """ - routine = self.get_routine(name) + routine = self.get_routine(designation) if not routine: - raise ValueError(f"Routine {name} not found.") + raise ValueError(f"Routine {designation} not found.") response = await self.run_routine(context, routine, vars) return response async def run_routine(self, context: RunContext, routine: RoutineTypes, vars: dict[str, Any] | None = None) -> Any: """ - Run an assistant routine. This is going to be much of the - magic of the assistant. Currently, is just runs through the - steps of a routine, but this will get much more sophisticated. - It will need to handle configuration, managing results of steps, - handling errors and retries, etc. ALso, this is where we will put - meta-cognitive functions such as having the assistant create a plan - from the routine and executing it dynamically while monitoring progress. - name = . + Run an assistant routine. This is going to be much of the magic of the + assistant. Currently, is just runs through the steps of a routine, but + this will get much more sophisticated. It will need to handle + configuration, managing results of steps, handling errors and retries, + etc. ALso, this is where we will put meta-cognitive functions such as + having the assistant create a plan from the routine and executing it + dynamically while monitoring progress. name = + . """ - await context.routine_stack.push(routine.fullname()) + await self.routine_stack.push(routine.fullname()) match routine: case InstructionRoutine(): runner = InstructionRoutineRunner() @@ -124,15 +92,32 @@ async def run_routine(self, context: RunContext, routine: RoutineTypes, vars: di case ProgramRoutine(): runner = ProgramRoutineRunner() done = await runner.run(context, routine, vars) - case FunctionRoutine(): - runner = FunctionRoutineRunner() + case StateMachineRoutine(): + runner = StateMachineRoutineRunner() done = await runner.run(context, routine, vars) if done: - _ = await context.routine_stack.pop() + _ = await self.routine_stack.pop() + + async def run_action_by_designation( + self, context: RunContext, designation: str, vars: Optional[dict[str, Any]] = None + ) -> Any: + """ + Run an action by designation (.). + """ + skill_name, action_name = designation.split(".") + skill = self.get_skill(skill_name) + if not skill: + raise ValueError(f"Skill {skill_name} not found.") + action = skill.get_action(action_name) + if not action: + raise ValueError(f"Action {action_name} not found in skill {skill_name}.") + vars = vars or {} + response = await action.execute(context, (), **vars) + return response async def step_active_routine(self, context: RunContext, message: str) -> None: """Run another step in the current routine.""" - routine_frame = await context.routine_stack.peek() + routine_frame = await self.routine_stack.peek() if not routine_frame: raise ValueError("No routine to run.") @@ -147,12 +132,12 @@ async def step_active_routine(self, context: RunContext, message: str) -> None: case ProgramRoutine(): runner = ProgramRoutineRunner() done = await runner.next(context, routine, message) - case FunctionRoutine(): - runner = FunctionRoutineRunner() + case StateMachineRoutine(): + runner = StateMachineRoutineRunner() done = await runner.next(context, routine, message) if done: - await context.routine_stack.pop() + await self.routine_stack.pop() # TODO: Manage return state for composition in parent steps. diff --git a/libraries/python/skills/skill-library/skill_library/types.py b/libraries/python/skills/skill-library/skill_library/types.py new file mode 100644 index 00000000..caf4f286 --- /dev/null +++ b/libraries/python/skills/skill-library/skill_library/types.py @@ -0,0 +1,6 @@ +from typing import Any + +from openai import AsyncAzureOpenAI, AsyncOpenAI + +LanguageModel = AsyncOpenAI | AsyncAzureOpenAI +Metadata = dict[str, Any] diff --git a/libraries/python/skills/skills/document-skill/document_skill/document_skill.py b/libraries/python/skills/skills/document-skill/document_skill/document_skill.py index b825c065..b0d873ca 100644 --- a/libraries/python/skills/skills/document-skill/document_skill/document_skill.py +++ b/libraries/python/skills/skills/document-skill/document_skill/document_skill.py @@ -69,7 +69,7 @@ def __init__( name=NAME, description=DESCRIPTION, chat_driver_config=chat_driver_config, - skill_actions=actions, + actions=actions, routines=routines, ) diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/agenda.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/agenda.py deleted file mode 100644 index 9d5623e9..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/agenda.py +++ /dev/null @@ -1,24 +0,0 @@ -import logging - -from pydantic import Field - -from form_filler_skill.base_model_llm import BaseModelLLM -from form_filler_skill.resources import ( - ResourceConstraintMode, -) - -logger = logging.getLogger(__name__) - - -class AgendaItem(BaseModelLLM): - title: str = Field(description="Brief description of the item") - resource: int = Field(description="Number of turns required for the item") - - -class Agenda(BaseModelLLM): - resource_constraint_mode: ResourceConstraintMode | None = Field(default=None) - max_agenda_retries: int = Field(default=2) - items: list[AgendaItem] = Field( - description="Ordered list of items to be completed in the remainder of the conversation", - default_factory=list, - ) diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/artifact.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/artifact.py deleted file mode 100644 index ab978ceb..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/artifact.py +++ /dev/null @@ -1,446 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - - -import json -import logging -from typing import Any, Literal, TypeVar, Union, get_args, get_origin, get_type_hints - -from openai import AsyncAzureOpenAI, AsyncOpenAI -from pydantic import BaseModel, ValidationError, create_model - -from form_filler_skill.base_model_llm import BaseModelLLM - -from .chat_drivers.fix_artifact_error import fix_artifact_error -from .message import Conversation, ConversationMessageType, Message - -logger = logging.getLogger(__name__) - - -class Artifact: - """The Artifact plugin takes in a Pydantic base model, and robustly handles updating the fields of the model - A typical use case is as a form an agent must complete throughout a conversation. - Another use case is as a working memory for the agent. - - The primary interface is update_artifact, which takes in the field_name to update and its new value. - Additionally, the chat_history is passed in to help the agent make informed decisions in case an error occurs. - - The Artifact also exposes several functions to access internal state: - get_artifact_for_prompt, get_schema_for_prompt, and get_failed_fields. - """ - - def __init__( - self, - openai_client: AsyncOpenAI | AsyncAzureOpenAI, - input_artifact: BaseModel, - max_artifact_field_retries: int = 2, - ) -> None: - """ - Initialize the Artifact plugin with the given Pydantic base model. - - Args: - input_artifact (BaseModel): The Pydantic base model to use as the artifact - max_artifact_field_retries (int): The maximum number of times to retry updating a field in the artifact - """ - - self.openai_client = openai_client - - self.max_artifact_field_retries = max_artifact_field_retries - - self.original_schema = input_artifact.model_json_schema() - - # Create a new artifact model based on the one provided by the user with - # "Unanswered" set for all fields. - modified_classes = self._modify_classes(input_artifact) - self.artifact = self._modify_base_artifact(input_artifact, modified_classes)() - - # failed_artifact_fields maps a field name to a list of the history of - # the failed attempts to update it. - # dict: key = field, value = list of tuple[attempt, error message] - self.failed_artifact_fields: dict[str, list[tuple[str, str]]] = {} - - async def update_artifact(self, field_name: str, field_value: Any) -> tuple[bool, Conversation]: - """ - The core interface for the Artifact plugin. This function will attempt - to update the given field_name to the given field_value. If the - field_value fails Pydantic validation, an LLM will determine one of two - actions to take. - - Given the conversation as additional context the two actions are: - - Retry the update the artifact by fixing the formatting using the - previous failed attempts as guidance - - Take no action or in other words, resume the conversation to ask - the user for more information because the user gave incomplete or - incorrect information - - Args: - field_name (str): The name of the field to update in the artifact - field_value (Any): The value to set the field to conversation - (Conversation): The conversation object that contains the history of - the conversation - - Returns: - A tuple with two fields: a boolean indicating - success and a list of conversation messages that may have been - generated. - - Several outcomes can happen: - - - The update may have failed due to: - - A field_name that is not valid in the artifact. - - The field_value failing Pydantic validation and all retries - failed. - - The model failed to correctly call a tool. - In this case, the boolean will be False and the list may contain - a message indicating the failure. - - - The agent may have successfully updated the artifact or fixed it. - In this case, the boolean will be True and the list will contain - a message indicating the update and possibly intermediate - messages. - - - The agent may have decided to resume the conversation. - In this case, the boolean will be True and the messages may only - contain messages indicated previous errors. - """ - - conversation = Conversation() - - # Check if the field name is valid, and return with a failure message if - # not. - is_valid_field, msg = self._is_valid_field(field_name) - if not is_valid_field: - if msg is not None: - conversation.messages.append(msg) - return False, conversation - - # Try to update the field, and handle any errors that occur until the - # field is successfully updated or skipped according to - # max_artifact_field_retries. - while True: - try: - # Check if there have been too many previous failed attempts to - # update the field. - if len(self.failed_artifact_fields.get(field_name, [])) >= self.max_artifact_field_retries: - logger.warning(f"Updating field {field_name} has failed too many times. Skipping.") - return False, conversation - - # Attempt to update the artifact. - self.artifact.__setattr__(field_name, field_value) - - # This will only be reached if there were no exceptions setting - # the artifact field. - msg = Message( - { - "role": "assistant", - "content": f"Assistant updated {field_name} to {field_value}", - }, - type=ConversationMessageType.ARTIFACT_UPDATE, - turn=None, - ) - conversation.messages.append(msg) - return True, conversation - - except Exception as e: - logger.warning(f"Error updating field {field_name}: {e}. Retrying...") - # Handle update error will increment failed_artifact_fields, once it has failed - # greater than self.max_artifact_field_retries the field will be skipped and the loop will break - success, new_field_value = await self._handle_update_error(field_name, field_value, conversation, e) - - # The agent has successfully fixed the field. - if success: - if new_field_value is not None: - logger.info(f"Agent successfully fixed field {field_name}. New value: {new_field_value}") - field_value = new_field_value - else: - # This is the case where the agent has decided to resume the conversation. - logger.info( - f"Agent could not fix the field itself & decided to resume conversation to fix field {field_name}" - ) - return True, conversation - - logger.warning(f"Agent failed to fix field {field_name}. Retrying...") - # Otherwise, the agent has failed and we will go through the loop again - - def get_artifact_for_prompt(self) -> str: - """ - Returns a formatted JSON-like representation of the current state of the - fields artifact. Any fields that were failed are completely omitted. - """ - failed_fields = self.get_failed_fields() - return json.dumps({k: v for k, v in self.artifact.model_dump().items() if k not in failed_fields}) - - def get_schema_for_prompt(self, filter_one_field: str | None = None) -> str: - """Gets a clean version of the original artifact schema, optimized for use in an LLM prompt. - - Args: - filter_one_field (str | None): If this is provided, only the schema for this one field will be returned. - - Returns: - str: The cleaned schema - """ - - def _clean_properties(schema: dict, failed_fields: list[str]) -> str: - properties = schema.get("properties", {}) - clean_properties = {} - for name, property_dict in properties.items(): - if name not in failed_fields: - cleaned_property = {} - for k, v in property_dict.items(): - if k in ["title", "default"]: - continue - cleaned_property[k] = v - clean_properties[name] = cleaned_property - - clean_properties_str = str(clean_properties) - clean_properties_str = clean_properties_str.replace("$ref", "type") - clean_properties_str = clean_properties_str.replace("#/$defs/", "") - return clean_properties_str - - # If filter_one_field is provided, only get the schema for that one field - if filter_one_field: - if not self._is_valid_field(filter_one_field): - logger.error(f'Field "{filter_one_field}" is not a valid field in the artifact.') - raise ValueError(f'Field "{filter_one_field}" is not a valid field in the artifact.') - filtered_schema = {"properties": {filter_one_field: self.original_schema["properties"][filter_one_field]}} - filtered_schema.update((k, v) for k, v in self.original_schema.items() if k != "properties") - schema = filtered_schema - else: - schema = self.original_schema - - failed_fields = self.get_failed_fields() - properties = _clean_properties(schema, failed_fields) - if not properties: - logger.error("No properties found in the schema.") - raise ValueError("No properties found in the schema.") - - types_schema = schema.get("$defs", {}) - custom_types = [] - for type_name, type_info in types_schema.items(): - if f"'type': '{type_name}'" in properties: - clean_schema = _clean_properties(type_info, []) - if clean_schema != "{}": - custom_types.append(f"{type_name} = {clean_schema}") - - if custom_types: - explanation = ( - f"If you wanted to create a {type_name} object, for example, you " - "would make a JSON object with the following keys: " - "{', '.join(types_schema[type_name]['properties'].keys())}." - ) - custom_types_str = "\n".join(custom_types) - return ( - f"{properties}\n\n" - "Here are the definitions for the custom types referenced in the artifact schema:\n" - f"{custom_types_str}\n\n" - f"{explanation}\n" - "Remember that when updating the artifact, the field will be the original " - "field name in the artifact and the JSON object(s) will be the value." - ) - else: - return properties - - def get_failed_fields(self) -> list[str]: - """Get a list of fields that have failed all attempts to update. - - Returns: - list[str]: A list of field names that have failed all attempts to update. - """ - fields = [] - for field, attempts in self.failed_artifact_fields.items(): - if len(attempts) >= self.max_artifact_field_retries: - fields.append(field) - return fields - - T = TypeVar("T") - - def _get_type_if_subtype(self, target_type: type[T], base_type: type[Any]) -> type[T] | None: - """ - Recursively checks the target_type to see if it is a subclass of - base_type or a generic including base_type. - - Args: - target_type: The type to check. - base_type: The type to check against. - - Returns: - The class type if target_type is base_type, a subclass of base_type, - or a generic including base_type; otherwise, None. - """ - origin = get_origin(target_type) - if origin is None: - if issubclass(target_type, base_type): - return target_type - else: - # Recursively check if any of the arguments are the target type. - for arg in get_args(target_type): - result = self._get_type_if_subtype(arg, base_type) - if result is not None: - return result - return None - - def _modify_classes(self, artifact_class: BaseModel) -> dict[str, type[BaseModelLLM]]: - """Find all classes used as type hints in the artifact, and modify them to set 'Unanswered' as a default and valid value for all fields.""" - modified_classes = {} - # Find any instances of BaseModel in the artifact class in the first "level" of type hints - for field_name, field_type in get_type_hints(artifact_class).items(): - is_base_model = self._get_type_if_subtype(field_type, BaseModel) - if is_base_model is not None: - modified_classes[field_name] = self._modify_base_artifact(is_base_model) - - return modified_classes - - def _replace_type_annotations( - self, field_annotation: type[Any] | None, modified_classes: dict[str, type[BaseModelLLM]] - ) -> type: - """ - Recursively replace type annotations with modified classes where - applicable. - """ - # Get the origin of the field annotation, which is the base type for - # generic types (e.g., List[str] -> list, Dict[str, int] -> dict) - origin = get_origin(field_annotation) - - # Get the type arguments of the generic type (e.g., List[str] -> str, - # Dict[str, int] -> str, int) - args = get_args(field_annotation) - - if origin is None: - # The type is not generic; check if it's a subclass that needs to be replaced - if isinstance(field_annotation, type) and issubclass(field_annotation, BaseModelLLM): - return modified_classes.get(field_annotation.__name__, field_annotation) - return field_annotation if field_annotation is not None else object - else: - # The type is generic; recursively replace the type annotations of the arguments - new_args = tuple(self._replace_type_annotations(arg, modified_classes) for arg in args) - return origin[new_args] - - def _modify_base_artifact( - self, - artifact_model: BaseModel, - modified_classes: dict[str, type[BaseModelLLM]] | None = None, - ) -> type[BaseModelLLM]: - """ - Create a new artifact model with 'Unanswered' as a default and valid - value for all fields. - """ - field_definitions = {} - for name, field_info in artifact_model.model_fields.items(): - # Replace original classes with modified version. - if modified_classes is not None: - field_info.annotation = self._replace_type_annotations(field_info.annotation, modified_classes) - - # This makes it possible to always set a field to "Unanswered". - annotation = Union[field_info.annotation, Literal["Unanswered"]] - - # This sets the default value to "Unanswered". - default = "Unanswered" - - # This adds "Unanswered" as a possible value to any regex patterns. - metadata = field_info.metadata - for m in metadata: - if hasattr(m, "pattern"): - m.pattern += "|Unanswered" - - field_definitions[name] = (annotation, default, *metadata) - - return create_model("Artifact", __base__=BaseModelLLM, **field_definitions) - - def _is_valid_field(self, field_name: str) -> tuple[bool, Message | None]: - """ - Check if the field_name is a valid field in the artifact. Returns True - if it is, False and an error message otherwise. - """ - if field_name not in self.artifact.model_fields: - error_message = f'Field "{field_name}" is not a valid field in the artifact.' - msg = Message( - {"role": "assistant", "content": error_message}, - type=ConversationMessageType.ARTIFACT_UPDATE, - turn=None, - ) - return False, msg - return True, None - - async def _handle_update_error( - self, field_name: str, field_value: Any, conversation: Conversation, error: Exception - ) -> tuple[bool, Any]: - """ - Handles the logic for when an error occurs while updating a field. - Creates the appropriate context for the model and calls the LLM to fix - the error. - - Args: - field_name (str): The name of the field to update in the artifact - field_value (Any): The value to set the field to conversation - (Conversation): The conversation object that contains the history of - the conversation error (Exception): The error that occurred while - updating the field - - Returns: - tuple[bool, Any]: A tuple containing a boolean indicating success - and the new field value if successful (if not, then None) - """ - - # Keep track of history of failed attempts for each field. - previous_attempts = self.failed_artifact_fields.get(field_name, []) - error_str = ( - str(error) - if not isinstance(error, ValidationError) - else "; ".join([e.get("msg") for e in error.errors()]).replace( - "; Input should be 'Unanswered'", " or input should be 'Unanswered'" - ) - ) - attempt = (str(field_value), error_str) - self.failed_artifact_fields[field_name] = previous_attempts + [attempt] - - result = await fix_artifact_error( - self.openai_client, - previous_attempts="\n".join([ - f"Attempt: {attempt}\nError: {error}" for attempt, error in previous_attempts - ]), - artifact_schema=self.get_schema_for_prompt(filter_one_field=field_name), - conversation=conversation, - field_name=field_name, - ) - - # Handling the result of the LLM call - if result.message not in ["UPDATE_ARTIFACT", "RESUME_CONVERSATION"]: - logger.warning( - f"Failed to fix the artifact error due to an invalid response from the LLM: {result.message}" - ) - return False, None - - if result.message == "RESUME_CONVERSATION": - return True, None - - if result.message.startswith("UPDATE_ARTIFACT("): - field_value = result.message.split("(")[1].split(")")[0] - return True, field_value - - logger.warning(f"Failed to fix the artifact error due to an invalid response from the LLM: {result.message}") - return False, None - - def to_json(self) -> dict: - artifact_fields = self.artifact.model_dump() - return { - "artifact": artifact_fields, - "failed_fields": self.failed_artifact_fields, - } - - @classmethod - def from_json( - cls, - openai_client: AsyncOpenAI | AsyncAzureOpenAI, - json_data: dict, - input_artifact: BaseModel, - max_artifact_field_retries: int = 2, - ) -> "Artifact": - artifact = cls(openai_client, input_artifact, max_artifact_field_retries) - - artifact.failed_artifact_fields = json_data["failed_fields"] - - # Iterate over artifact fields and set them to the values in the json data - # Skip any fields that are set as "Unanswered" - for field_name, field_value in json_data["artifact"].items(): - if field_value != "Unanswered": - setattr(artifact.artifact, field_name, field_value) - return artifact diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/fix_agenda_error.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/fix_agenda_error.py deleted file mode 100644 index 084762f6..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/fix_agenda_error.py +++ /dev/null @@ -1,53 +0,0 @@ -import logging - -from form_filler_skill.message import Conversation, ConversationMessageType -from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai_client.chat_driver import ChatDriver, ChatDriverConfig, InMemoryMessageHistoryProvider - -logger = logging.getLogger(__name__) - -AGENDA_ERROR_CORRECTION_SYSTEM_TEMPLATE = """You are a helpful, thoughtful, and meticulous assistant. -You are conducting a conversation with a user. You tried to update the agenda, but the update was invalid. - -You will be provided the history of your conversation with the user, your previous attempt(s) at updating the agenda, and the error message(s) that resulted from your attempt(s). -Your task is to correct the update so that it is valid. - -Your changes should be as minimal as possible - you are focused on fixing the error(s) that caused the update to be invalid. - -Note that if the resource allocation is invalid, you must follow these rules: - -1. You should not change the description of the first item (since it has already been executed), but you can change its resource allocation. -2. For all other items, you can combine or split them, or assign them fewer or more resources, but the content they cover collectively should not change (i.e. don't eliminate or add new topics). -For example, the invalid attempt was "item 1 = ask for date of birth (1 turn), item 2 = ask for phone number (1 turn), item 3 = ask for phone type (1 turn), item 4 = explore treatment history (6 turns)", and the error says you need to correct the total resource allocation to 7 turns. A bad solution is "item 1 = ask for date of birth (1 turn), item 2 = explore treatment history (6 turns)" because it eliminates the phone number and phone type topics. A good solution is "item 1 = ask for date of birth (2 turns), item 2 = ask for phone number, phone type, and treatment history (2 turns), item 3 = explore treatment history (3 turns)." -""" - - -async def fix_agenda_error( - openai_client: AsyncOpenAI | AsyncAzureOpenAI, - previous_attempts: str, - conversation: Conversation, -): - history = InMemoryMessageHistoryProvider() - - history.append_system_message(AGENDA_ERROR_CORRECTION_SYSTEM_TEMPLATE) - history.append_user_message( - ( - "Conversation history:\n" - "{{ conversation_history }}\n\n" - "Previous attempts to update the agenda:\n" - "{{ previous_attempts }}" - ), - { - "conversation_history": str(conversation.exclude([ConversationMessageType.REASONING])), - "previous_attempts": previous_attempts, - }, - ) - - config = ChatDriverConfig( - openai_client=openai_client, - model="gpt-3.5-turbo", - message_provider=history, - ) - - chat_driver = ChatDriver(config) - return await chat_driver.respond() diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/fix_artifact_error.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/fix_artifact_error.py deleted file mode 100644 index 0dea8d17..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/fix_artifact_error.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging - -from events import BaseEvent -from form_filler_skill.message import Conversation, ConversationMessageType -from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai_client.chat_driver import ChatDriver, ChatDriverConfig, InMemoryMessageHistoryProvider - -logger = logging.getLogger(__name__) - -ARTIFACT_ERROR_CORRECTION_SYSTEM_TEMPLATE = """You are a helpful, thoughtful, and meticulous assistant. - -You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the end of the conversation. - -You have tried to update a field in the artifact, but the value you provided did not adhere to the constraints of the field as specified in the artifact schema. - -You will be provided the history of your conversation with the user, the schema for the field, your previous attempt(s) at updating the field, and the error message(s) that resulted from your attempt(s). - -Your task is to return the best possible action to take next: - -1. UPDATE_FIELD(value) -- You should pick this action if you have a valid value to submit for the field in question. Replace "value" with the correct value. - -2. RESUME_CONVERSATION -- You should pick this action if: (a) you do NOT have a valid value to submit for the field in question, and (b) you need to ask the user for more information in order to obtain a valid value. For example, if the user stated that their date of birth is June 2000, but the artifact field asks for the date of birth in the format "YYYY-MM-DD", you should resume the conversation and ask the user for the day. - -Return only the action, either UPDATE_ARTIFACT(value) or RESUME_CONVERSATION, as your response. If you selected, UPDATE_ARTIFACT, make sure to replace "value" with the correct value. -""" - - -async def fix_artifact_error( - openai_client: AsyncOpenAI | AsyncAzureOpenAI, - previous_attempts: str, - artifact_schema: str, - conversation: Conversation, - field_name: str, -) -> BaseEvent: - history = InMemoryMessageHistoryProvider() - history.append_system_message(ARTIFACT_ERROR_CORRECTION_SYSTEM_TEMPLATE) - history.append_user_message( - ( - "Conversation history:\n" - "{{ conversation_history }}\n\n" - "Schema:\n" - "{{ artifact_schema }}\n\n" - 'Previous attempts to update the field "{{ field_name }}" in the artifact:\n' - "{{ previous_attempts }}" - ), - { - "conversation_history": str(conversation.exclude([ConversationMessageType.REASONING])), - "artifact_schema": artifact_schema, - "field_name": field_name, - "previous_attempts": previous_attempts, - }, - ) - - config = ChatDriverConfig( - openai_client=openai_client, - model="gpt-3.5-turbo", - message_provider=history, - ) - - chat_driver = ChatDriver(config) - return await chat_driver.respond() diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/unneeded/choose_action.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/unneeded/choose_action.py deleted file mode 100644 index e03cc6e7..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/unneeded/choose_action.py +++ /dev/null @@ -1,237 +0,0 @@ -import logging - -from form_filler_skill.agenda import Agenda, AgendaItem -from form_filler_skill.definition import GCDefinition -from form_filler_skill.message import Conversation -from form_filler_skill.resources import ( - GCResource, - ResourceConstraintMode, - ResourceConstraintUnit, - format_resource, -) -from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai_client.chat_driver import ChatDriver, ChatDriverConfig, InMemoryMessageHistoryProvider -from pydantic import ValidationError -from skill_library.run_context import RunContext - -from ...artifact import Artifact -from ..fix_agenda_error import fix_agenda_error -from ..update_agenda_template import update_agenda_template - -logger = logging.getLogger(__name__) - - -def _get_termination_instructions(resource: GCResource): - """ - Get the termination instructions for the conversation. This is contingent on - the resources mode, if any, that is available. Assumes we're always using - turns as the resource unit. - """ - # Termination condition under no resource constraints - if resource.resource_constraint is None: - return ( - "- You should pick this action as soon as you have completed the artifact to the best of your ability, the" - " conversation has come to a natural conclusion, or the user is not cooperating so you cannot continue the" - " conversation." - ) - - # Termination condition under exact resource constraints - if resource.resource_constraint.mode == ResourceConstraintMode.EXACT: - return ( - "- You should only pick this action if the user is not cooperating so you cannot continue the conversation." - ) - - # Termination condition under maximum resource constraints - elif resource.resource_constraint.mode == ResourceConstraintMode.MAXIMUM: - return ( - "- You should pick this action as soon as you have completed the artifact to the best of your ability, the" - " conversation has come to a natural conclusion, or the user is not cooperating so you cannot continue the" - " conversation." - ) - - else: - logger.error("Invalid resource mode provided.") - return "" - - -async def update_agenda( - context: RunContext, - openai_client: AsyncOpenAI | AsyncAzureOpenAI, - definition: GCDefinition, - chat_history: Conversation, - agenda: Agenda, - artifact: Artifact, - resource: GCResource, -) -> bool: - # STEP 1: Generate an updated agenda. - - # If there is a resource constraint and there's more than one turn left, - # include additional constraint instructions. - remaining_resource = resource.remaining_units if resource.remaining_units else 0 - resource_instructions = resource.get_resource_instructions() - if (resource_instructions != "") and (remaining_resource > 1): - match resource.get_resource_mode(): - case ResourceConstraintMode.MAXIMUM: - total_resource_str = f"does not exceed the remaining turns ({remaining_resource})." - ample_time_str = "" - case ResourceConstraintMode.EXACT: - total_resource_str = ( - f"is equal to the remaining turns ({remaining_resource}). Do not leave any turns unallocated." - ) - ample_time_str = ( - "If you have many turns remaining, instead of including wrap-up items or repeating " - "topics, you should include items that increase the breadth and/or depth of the conversation " - 'in a way that\'s directly relevant to the artifact (e.g. "collect additional details about X", ' - '"ask for clarification about Y", "explore related topic Z", etc.).' - ) - case _: - logger.error("Invalid resource mode.") - else: - total_resource_str = "" - ample_time_str = "" - - history = InMemoryMessageHistoryProvider() - history.append_system_message( - update_agenda_template, - { - "context": definition.conversation_context, - "artifact_schema": definition.artifact_schema, - "rules": definition.rules, - "current_state_description": definition.conversation_flow, - "show_agenda": True, - "remaining_resource": remaining_resource, - "total_resource_str": total_resource_str, - "ample_time_str": ample_time_str, - "termination_instructions": _get_termination_instructions(resource), - "resource_instructions": resource_instructions, - }, - ) - history.append_user_message( - ( - "Conversation history:\n" - "{{ chat_history }}\n\n" - "Latest agenda:\n" - "{{ agenda_state }}\n\n" - "Current state of the artifact:\n" - "{{ artifact_state }}" - ), - { - "chat_history": str(chat_history), - "agenda_state": get_agenda_for_prompt(agenda), - "artifact_state": artifact.get_artifact_for_prompt(), - }, - ) - - config = ChatDriverConfig( - openai_client=openai_client, - model="gpt-4o", - message_provider=history, - ) - - chat_driver = ChatDriver(config) - response = await chat_driver.respond() - items = response.message - - # STEP 2: Validate/fix the updated agenda. - - previous_attempts = [] - while True: - try: - # Pydantic type validation. - agenda.items = items # type: ignore - - # Check resource constraints. - if agenda.resource_constraint_mode is not None: - check_item_constraints( - agenda.resource_constraint_mode, - agenda.items, - resource.estimate_remaining_turns(), - ) - - logger.info(f"Agenda updated successfully: {get_agenda_for_prompt(agenda)}") - return True - - except (ValidationError, ValueError) as e: - # If we have reached the maximum number of retries return a failure. - if len(previous_attempts) >= agenda.max_agenda_retries: - logger.warning(f"Failed to update agenda after {agenda.max_agenda_retries} attempts.") - return False - - # Otherwise, get an error string. - if isinstance(e, ValidationError): - error_str = "; ".join([e.get("msg") for e in e.errors()]) - error_str = error_str.replace("; Input should be 'Unanswered'", " or input should be 'Unanswered'") - else: - error_str = str(e) - - # Add it to our list of previous attempts. - previous_attempts.append((str(items), error_str)) - - # And try again. - logger.info(f"Attempting to fix the agenda error. Attempt {len(previous_attempts)}.") - llm_formatted_attempts = "\n".join([ - f"Attempt: {attempt}\nError: {error}" for attempt, error in previous_attempts - ]) - response = await fix_agenda_error(openai_client, llm_formatted_attempts, chat_history) - - # Now, update the items with the corrected agenda and try to - # validate again. - items = response.message - - -def check_item_constraints( - resource_constraint_mode: ResourceConstraintMode, - items: list[AgendaItem], - remaining_turns: int, -) -> None: - """ - Validates if any constraints were violated while performing the agenda - update. - """ - # The total, proposed allocation of resources. - total_resources = sum([item.resource for item in items]) - - violations = [] - # In maximum mode, the total resources should not exceed the remaining - # turns. - if (resource_constraint_mode == ResourceConstraintMode.MAXIMUM) and (total_resources > remaining_turns): - violations.append( - "The total turns allocated in the agenda " - f"must not exceed the remaining amount ({remaining_turns}); " - f"but the current total is {total_resources}." - ) - - # In exact mode if the total resources were not exactly equal to the - # remaining turns. - if (resource_constraint_mode == ResourceConstraintMode.EXACT) and (total_resources != remaining_turns): - violations.append( - "The total turns allocated in the agenda " - f"must equal the remaining amount ({remaining_turns}); " - f"but the current total is {total_resources}." - ) - - # Check if any item has a resource value of 0. - if any(item.resource <= 0 for item in items): - violations.append("All items must have a resource value greater than 0.") - - # Raise an error if any violations were found. - if len(violations) > 0: - logger.debug(f"Agenda update failed due to the following violations: {violations}.") - raise ValueError(" ".join(violations)) - - -def get_agenda_for_prompt(agenda: Agenda) -> str: - """ - Gets a string representation of the agenda for use in an LLM prompt. - """ - agenda_json = agenda.model_dump() - agenda_items = agenda_json.get("items", []) - if len(agenda_items) == 0: - return "None" - agenda_str = "\n".join([ - f"{i + 1}. [{format_resource(item['resource'], ResourceConstraintUnit.TURNS)}] {item['title']}" - for i, item in enumerate(agenda_items) - ]) - total_resource = format_resource(sum([item["resource"] for item in agenda_items]), ResourceConstraintUnit.TURNS) - agenda_str += f"\nTotal = {total_resource}" - return agenda_str diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/unneeded/choose_action_template.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/unneeded/choose_action_template.py deleted file mode 100644 index 3e533f63..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/unneeded/choose_action_template.py +++ /dev/null @@ -1,52 +0,0 @@ -update_agenda_template = """You are a helpful, thoughtful, and meticulous assistant. You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the end of the conversation, and to ensure a smooth experience for the user. - -This is the schema of the artifact you are completing: -{{ artifact_schema }}{% if context %} - -Here is some additional context about the conversation: -{{ context }}{% endif %} - -Throughout the conversation, you must abide by these rules: -{{ rules }}{% if current_state_description %} - -Here's a description of the conversation flow: -{{ current_state_description }} -Follow this description, and exercise good judgment about when it is appropriate to deviate.{% endif %} - -You will be provided the history of your conversation with the user up until now and the current state of the artifact. -Note that if the value for a field in the artifact is 'Unanswered', it means that the field has not been completed. -You need to select the best possible action(s), given the state of the conversation and the artifact. - -These are the possible actions you can take: - -{% if show_agenda %}Update agenda (required parameters: items) -- If the latest agenda is set to "None", you should always pick this action. -- You should pick this action if you need to change your plan for the conversation to make the best use of the remaining turns available to you. Consider how long it usually takes to get the information you need (which is a function of the quality and pace of the user's responses), the number, complexity, and importance of the remaining fields in the artifact, and the number of turns remaining ({{ remaining_resource }}). Based on these factors, you might need to accelerate (e.g. combine several topics) or slow down the conversation (e.g. spread out a topic), in which case you should update the agenda accordingly. Note that skipping an artifact field is NOT a valid way to accelerate the conversation. -- You must provide an ordered list of items to be completed sequentially, where the first item contains everything you will do in the current turn of the conversation (in addition to updating the agenda). For example, if you choose to send a message to the user asking for their name and medical history, then you would write "ask for name and medical history" as the first item. If you think medical history will take longer than asking for the name, then you would write "complete medical history" as the second item, with an estimate of how many turns you think it will take. Do NOT include items that have already been completed. Items must always represent a conversation topic (corresponding to the "Send message to user" action). Updating the artifact (e.g. "update field X based on the discussion") or terminating the conversation is NOT a valid item. -- The latest agenda was created in the previous turn of the conversation. Even if the total turns in the latest agenda equals the remaining turns, you should still update the agenda if you -think the current plan is suboptimal (e.g. the first item was completed, the order of items is not ideal, an item is too broad or not a conversation topic, etc.). -- Each item must have a description and and your best guess for the number of turns required to complete it. Do not provide a range of turns. It is EXTREMELY important that the total turns allocated across all items in the updated agenda (including the first item for the current turn) {{ total_resource_str }} Everything in the agenda should be something you expect to complete in the remaining turns - there shouldn't be any optional "buffer" items. It can be helpful to include the cumulative turns allocated for each item in the agenda to ensure you adhere to this rule, e.g. item 1 = 2 turns (cumulative total = 2), item 2 = 4 turns (cumulative total = 6), etc. -- Avoid high-level items like "ask follow-up questions" - be specific about what you need to do. -- Do NOT include wrap-up items such as "review and confirm all information with the user" (you should be doing this throughout the conversation) or "thank the user for their time". Do NOT repeat topics that have already been sufficiently addressed. {{ ample_time_str }}{% endif %} - -Send message to user (required parameters: message) -- If there is no conversation history, you should always pick this action. -- You should pick this action if (a) the user asked a question or made a statement that you need to respond to, or (b) you need to follow-up with the user because the information they provided is incomplete, invalid, ambiguous, or in some way insufficient to complete the artifact. For example, if the artifact schema indicates that the "date of birth" field must be in the format "YYYY-MM-DD", but the user has only provided the month and year, you should send a message to the user asking for the day. Likewise, if the user claims that their date of birth is February 30, you should send a message to the user asking for a valid date. If the artifact schema is open-ended (e.g. it asks you to rate how pressing the user's issue is, without specifying rules for doing so), use your best judgment to determine whether you have enough information or you need to continue probing the user. It's important to be thorough, but also to avoid asking the user for unnecessary information. - -Update artifact fields (required parameters: field, value) -- You should pick this action as soon as (a) the user provides new information that is not already reflected in the current state of the artifact and (b) you are able to submit a valid value for a field in the artifact using this new information. If you have already updated a field in the artifact and there is no new information to update the field with, you should not pick this action. -- Make sure the value adheres to the constraints of the field as specified in the artifact schema. -- If the user has provided all required information to complete a field (i.e. the criteria for "Send message to user" are not satisfied) but the information is in the wrong format, you should not ask the user to reformat their response. Instead, you should simply update the field with the correctly formatted value. For example, if the artifact asks for the date of birth in the format "YYYY-MM-DD", and the user provides their date of birth as "June 15, 2000", you should update the field with the value "2000-06-15". -- Prioritize accuracy over completion. You should never make up information or make assumptions in order to complete a field. For example, if the field asks for a 10-digit phone number, and the user provided a 9-digit phone number, you should not add a digit to the phone number in order to complete the field. Instead, you should follow-up with the user to ask for the correct phone number. If they still aren't able to provide one, you should leave the field unanswered. -- If the user isn't able to provide all of the information needed to complete a field, use your best judgment to determine if a partial answer is appropriate (assuming it adheres to the formatting requirements of the field). For example, if the field asks for a description of symptoms along with details about when the symptoms started, but the user isn't sure when their symptoms started, it's better to record the information they do have rather than to leave the field unanswered (and to indicate that the user was unsure about the start date). -- If it's possible to update multiple fields at once (assuming you're adhering to the above rules in all cases), you should do so. For example, if the user provides their full name and date of birth in the same message, you should select the "update artifact fields" action twice, once for each field. - -End conversation (required parameters: None) -{{ termination_instructions }} -{{ resource_instructions }} - -If you select the "Update artifact field" action or the "Update agenda" action, you should also select one of the "Send message to user" or "End conversation" actions. Note that artifact and updates updates will always be executed before a message is sent to the user or the -conversation is terminated. Also note that only one message can be sent to the user at a time. - -Your task is to state your step-by-step reasoning for the best possible action(s), followed by a final recommendation of which action(s) to take, including all required parameters. Someone else will be responsible for executing the action(s) you select and they will only have access to your output (not any of the conversation history, artifact schema, or other context) so it is EXTREMELY important that you clearly specify the value of all required parameters for each action you select. -""" diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/unneeded/execute_reasoning.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/unneeded/execute_reasoning.py deleted file mode 100644 index c81448b7..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/unneeded/execute_reasoning.py +++ /dev/null @@ -1,57 +0,0 @@ -import logging - -from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai_client.chat_driver import ChatDriver, ChatDriverConfig, InMemoryMessageHistoryProvider - -logger = logging.getLogger(__name__) - -SYSTEM_TEMPLATE = """You are a helpful, thoughtful, and meticulous assistant. You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the end of the conversation. - -You will be given some reasoning about the best possible action(s) to take next given the state of the conversation as well as the artifact schema. - -The reasoning is supposed to state the recommended action(s) to take next, along with all required parameters for each action. - -Your task is to execute ALL actions recommended in the reasoning in the order they are listed. -If the reasoning's specification of an action is incomplete (e.g. it doesn't include all required parameters for the action, or some parameters are specified implicitly, such as "send a message that contains a greeting" instead of explicitly providing the value of the "message" parameter), do not execute the action. You should never fill in missing or imprecise -parameters yourself. - -If the reasoning is not clear about which actions to take, or all actions are specified in an incomplete way, return 'None' without selecting any action.""" - -USER_TEMPLATE = """Artifact schema: -{{ artifact_schema }} - -If the type in the schema is str, the "field_value" parameter in the action should be also be a string. -These are example parameters for the update_artifact action: {"field_name": "company_name", "field_value": "Contoso"} -DO NOT write JSON in the "field_value" parameter in this case. -{"field_name": "company_name", "field_value": "{"value": "Contoso"}"} is INCORRECT. - -Reasoning: -{{ reasoning }}""" - - -async def execute_reasoning( - open_ai_client: AsyncOpenAI | AsyncAzureOpenAI, - reasoning: str, - artifact_schema: str, -): - history = InMemoryMessageHistoryProvider() - - history.append_system_message( - SYSTEM_TEMPLATE, - ) - history.append_user_message( - USER_TEMPLATE, - { - "artifact_schema": artifact_schema, - "reasoning": reasoning, - }, - ) - - config = ChatDriverConfig( - openai_client=open_ai_client, - model="gpt-3.5-turbo", - message_provider=history, - ) - - chat_driver = ChatDriver(config) - return await chat_driver.respond() diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_agenda.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_agenda.py deleted file mode 100644 index 22cafa50..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_agenda.py +++ /dev/null @@ -1,126 +0,0 @@ -import logging - -from form_filler_skill.agenda import Agenda, AgendaItem -from form_filler_skill.message import Conversation -from form_filler_skill.resources import ( - GCResource, - ResourceConstraintMode, - ResourceConstraintUnit, - format_resource, -) -from openai import AsyncAzureOpenAI, AsyncOpenAI -from pydantic import ValidationError - -from .fix_agenda_error import fix_agenda_error - -logger = logging.getLogger(__name__) - - -async def update_agenda( - openai_client: AsyncOpenAI | AsyncAzureOpenAI, - items: str, - chat_history: Conversation, - agenda: Agenda, - resource: GCResource, -) -> tuple[Agenda, bool]: - previous_attempts = [] - while True: - try: - # Pydantic type validation. - agenda.items = items # type: ignore - - # Check resource constraints. - if agenda.resource_constraint_mode is not None: - check_item_constraints( - agenda.resource_constraint_mode, - agenda.items, - resource.estimate_remaining_turns(), - ) - - logger.info(f"Agenda updated successfully: {get_agenda_for_prompt(agenda)}") - return (agenda, True) - - except (ValidationError, ValueError) as e: - # If we have reached the maximum number of retries return a failure. - if len(previous_attempts) >= agenda.max_agenda_retries: - logger.warning(f"Failed to update agenda after {agenda.max_agenda_retries} attempts.") - return (agenda, False) - - # Otherwise, get an error string. - if isinstance(e, ValidationError): - error_str = "; ".join([e.get("msg") for e in e.errors()]) - error_str = error_str.replace("; Input should be 'Unanswered'", " or input should be 'Unanswered'") - else: - error_str = str(e) - - # Add it to our list of previous attempts. - previous_attempts.append((str(items), error_str)) - - # And try again. - logger.info(f"Attempting to fix the agenda error. Attempt {len(previous_attempts)}.") - llm_formatted_attempts = "\n".join([ - f"Attempt: {attempt}\nError: {error}" for attempt, error in previous_attempts - ]) - response = await fix_agenda_error(openai_client, llm_formatted_attempts, chat_history) - - # Now, update the items with the corrected agenda and try to - # validate again. - items = response.message or "" - - -def check_item_constraints( - resource_constraint_mode: ResourceConstraintMode, - items: list[AgendaItem], - remaining_turns: int, -) -> None: - """ - Validates if any constraints were violated while performing the agenda - update. - """ - # The total, proposed allocation of resources. - total_resources = sum([item.resource for item in items]) - - violations = [] - # In maximum mode, the total resources should not exceed the remaining - # turns. - if (resource_constraint_mode == ResourceConstraintMode.MAXIMUM) and (total_resources > remaining_turns): - violations.append( - "The total turns allocated in the agenda " - f"must not exceed the remaining amount ({remaining_turns}); " - f"but the current total is {total_resources}." - ) - - # In exact mode if the total resources were not exactly equal to the - # remaining turns. - if (resource_constraint_mode == ResourceConstraintMode.EXACT) and (total_resources != remaining_turns): - violations.append( - "The total turns allocated in the agenda " - f"must equal the remaining amount ({remaining_turns}); " - f"but the current total is {total_resources}." - ) - - # Check if any item has a resource value of 0. - if any(item.resource <= 0 for item in items): - violations.append("All items must have a resource value greater than 0.") - - # Raise an error if any violations were found. - if len(violations) > 0: - logger.debug(f"Agenda update failed due to the following violations: {violations}.") - raise ValueError(" ".join(violations)) - - -def get_agenda_for_prompt(agenda: Agenda) -> str: - """ - Gets a string representation of the agenda for use in an LLM prompt. - """ - agenda_json = agenda.model_dump() - agenda_items = agenda_json.get("items", []) - if len(agenda_items) == 0: - return "None" - agenda_str = "\n".join([ - f"{i + 1}. [{format_resource(item['resource'], ResourceConstraintUnit.TURNS)}] {item['title']}" - for i, item in enumerate(agenda_items) - ]) - total_resource = format_resource(sum([item["resource"] for item in agenda_items]), ResourceConstraintUnit.TURNS) - agenda_str += f"\nTotal = {total_resource}" - return agenda_str diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_agenda_template.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_agenda_template.py deleted file mode 100644 index 5bf468ce..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_agenda_template.py +++ /dev/null @@ -1,31 +0,0 @@ -update_agenda_template = """You are a helpful, thoughtful, and meticulous assistant. You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the end of the conversation, and to ensure a smooth experience for the user. - -This is the schema of the artifact you are completing: -{{ artifact_schema }}{% if context %} - -Here is some additional context about the conversation: -{{ context }}{% endif %} - -Throughout the conversation, you must abide by these rules: -{{ rules }}{% if current_state_description %} - -Here's a description of the conversation flow: -{{ current_state_description }} -Follow this description, and exercise good judgment about when it is appropriate to deviate.{% endif %} - -You will be provided the history of your conversation with the user up until now and the current state of the artifact. -Note that if the value for a field in the artifact is 'Unanswered', it means that the field has not been completed. -You need to select the best possible action(s), given the state of the conversation and the artifact. - -Here is the action to take: - -- If the latest agenda is set to "None", you should always pick this action. -- You should pick this action if you need to change your plan for the conversation to make the best use of the remaining turns available to you. Consider how long it usually takes to get the information you need (which is a function of the quality and pace of the user's responses), the number, complexity, and importance of the remaining fields in the artifact, and the number of turns remaining ({{ remaining_resource }}). Based on these factors, you might need to accelerate (e.g. combine several topics) or slow down the conversation (e.g. spread out a topic), in which case you should update the agenda accordingly. Note that skipping an artifact field is NOT a valid way to accelerate the conversation. -- You must provide an ordered list of items to be completed sequentially, where the first item contains everything you will do in the current turn of the conversation (in addition to updating the agenda). For example, if you choose to send a message to the user asking for their name and medical history, then you would write "ask for name and medical history" as the first item. If you think medical history will take longer than asking for the name, then you would write "complete medical history" as the second item, with an estimate of how many turns you think it will take. Do NOT include items that have already been completed. Items must always represent a conversation topic (corresponding to the "Send message to user" action). Updating the artifact (e.g. "update field X based on the discussion") or terminating the conversation is NOT a valid item. -- The latest agenda was created in the previous turn of the conversation. Even if the total turns in the latest agenda equals the remaining turns, you should still update the agenda if you -think the current plan is suboptimal (e.g. the first item was completed, the order of items is not ideal, an item is too broad or not a conversation topic, etc.). -- Each item must have a description and and your best guess for the number of turns required to complete it. Do not provide a range of turns. It is EXTREMELY important that the total turns allocated across all items in the updated agenda (including the first item for the current turn) {{ total_resource_str }} Everything in the agenda should be something you expect to complete in the remaining turns - there shouldn't be any optional "buffer" items. It can be helpful to include the cumulative turns allocated for each item in the agenda to ensure you adhere to this rule, e.g. item 1 = 2 turns (cumulative total = 2), item 2 = 4 turns (cumulative total = 6), etc. -- Avoid high-level items like "ask follow-up questions" - be specific about what you need to do. -- Do NOT include wrap-up items such as "review and confirm all information with the user" (you should be doing this throughout the conversation) or "thank the user for their time". Do NOT repeat topics that have already been sufficiently addressed. {{ ample_time_str }}{% endif %} - -""" diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_artifact.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_artifact.py deleted file mode 100644 index 8f1051ec..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_artifact.py +++ /dev/null @@ -1,204 +0,0 @@ -import logging - -from form_filler_skill.agenda import Agenda, AgendaItem -from form_filler_skill.definition import GCDefinition -from form_filler_skill.message import Conversation -from form_filler_skill.resources import ( - GCResource, - ResourceConstraintMode, - ResourceConstraintUnit, - format_resource, -) -from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai_client.chat_driver import ChatDriver, ChatDriverConfig, InMemoryMessageHistoryProvider -from pydantic import ValidationError - -from ..artifact import Artifact -from .fix_agenda_error import fix_agenda_error -from .update_artifact_template import update_artifact_template - -logger = logging.getLogger(__name__) - - -def _get_termination_instructions(resource: GCResource): - """ - Get the termination instructions for the conversation. This is contingent on - the resources mode, if any, that is available. Assumes we're always using - turns as the resource unit. - """ - # Termination condition under no resource constraints - if resource.resource_constraint is None: - return ( - "- You should pick this action as soon as you have completed the artifact to the best of your ability, the" - " conversation has come to a natural conclusion, or the user is not cooperating so you cannot continue the" - " conversation." - ) - - # Termination condition under exact resource constraints - if resource.resource_constraint.mode == ResourceConstraintMode.EXACT: - return ( - "- You should only pick this action if the user is not cooperating so you cannot continue the conversation." - ) - - # Termination condition under maximum resource constraints - elif resource.resource_constraint.mode == ResourceConstraintMode.MAXIMUM: - return ( - "- You should pick this action as soon as you have completed the artifact to the best of your ability, the" - " conversation has come to a natural conclusion, or the user is not cooperating so you cannot continue the" - " conversation." - ) - - else: - logger.error("Invalid resource mode provided.") - return "" - - -async def update_agenda( - openai_client: AsyncOpenAI | AsyncAzureOpenAI, - definition: GCDefinition, - chat_history: Conversation, - agenda: Agenda, - artifact: Artifact, - resource: GCResource, -) -> bool: - # STEP 1: Generate an updated agenda. - - history = InMemoryMessageHistoryProvider() - history.append_system_message( - update_artifact_template, - { - "context": definition.conversation_context, - "artifact_schema": definition.artifact_schema, - "rules": definition.rules, - "current_state_description": definition.conversation_flow, - }, - ) - history.append_user_message( - ( - "Conversation history:\n" - "{{ chat_history }}\n\n" - "Latest agenda:\n" - "{{ agenda_state }}\n\n" - "Current state of the artifact:\n" - "{{ artifact_state }}" - ), - { - "chat_history": str(chat_history), - "agenda_state": get_agenda_for_prompt(agenda), - "artifact_state": artifact.get_artifact_for_prompt(), - }, - ) - - config = ChatDriverConfig( - openai_client=openai_client, - model="gpt-4o", - message_provider=history, - ) - - chat_driver = ChatDriver(config) - response = await chat_driver.respond() - items = response.message - - # STEP 2: Validate/fix the updated agenda. - - previous_attempts = [] - while True: - try: - # Pydantic type validation. - agenda.items = items # type: ignore - - # Check resource constraints. - if agenda.resource_constraint_mode is not None: - check_item_constraints( - agenda.resource_constraint_mode, - agenda.items, - resource.estimate_remaining_turns(), - ) - - logger.info(f"Agenda updated successfully: {get_agenda_for_prompt(agenda)}") - return True - - except (ValidationError, ValueError) as e: - # If we have reached the maximum number of retries return a failure. - if len(previous_attempts) >= agenda.max_agenda_retries: - logger.warning(f"Failed to update agenda after {agenda.max_agenda_retries} attempts.") - return False - - # Otherwise, get an error string. - if isinstance(e, ValidationError): - error_str = "; ".join([e.get("msg") for e in e.errors()]) - error_str = error_str.replace("; Input should be 'Unanswered'", " or input should be 'Unanswered'") - else: - error_str = str(e) - - # Add it to our list of previous attempts. - previous_attempts.append((str(items), error_str)) - - # And try again. - logger.info(f"Attempting to fix the agenda error. Attempt {len(previous_attempts)}.") - llm_formatted_attempts = "\n".join([ - f"Attempt: {attempt}\nError: {error}" for attempt, error in previous_attempts - ]) - response = await fix_agenda_error(openai_client, llm_formatted_attempts, chat_history) - - # Now, update the items with the corrected agenda and try to - # validate again. - items = response.message - - -def check_item_constraints( - resource_constraint_mode: ResourceConstraintMode, - items: list[AgendaItem], - remaining_turns: int, -) -> None: - """ - Validates if any constraints were violated while performing the agenda - update. - """ - # The total, proposed allocation of resources. - total_resources = sum([item.resource for item in items]) - - violations = [] - # In maximum mode, the total resources should not exceed the remaining - # turns. - if (resource_constraint_mode == ResourceConstraintMode.MAXIMUM) and (total_resources > remaining_turns): - violations.append( - "The total turns allocated in the agenda " - f"must not exceed the remaining amount ({remaining_turns}); " - f"but the current total is {total_resources}." - ) - - # In exact mode if the total resources were not exactly equal to the - # remaining turns. - if (resource_constraint_mode == ResourceConstraintMode.EXACT) and (total_resources != remaining_turns): - violations.append( - "The total turns allocated in the agenda " - f"must equal the remaining amount ({remaining_turns}); " - f"but the current total is {total_resources}." - ) - - # Check if any item has a resource value of 0. - if any(item.resource <= 0 for item in items): - violations.append("All items must have a resource value greater than 0.") - - # Raise an error if any violations were found. - if len(violations) > 0: - logger.debug(f"Agenda update failed due to the following violations: {violations}.") - raise ValueError(" ".join(violations)) - - -def get_agenda_for_prompt(agenda: Agenda) -> str: - """ - Gets a string representation of the agenda for use in an LLM prompt. - """ - agenda_json = agenda.model_dump() - agenda_items = agenda_json.get("items", []) - if len(agenda_items) == 0: - return "None" - agenda_str = "\n".join([ - f"{i + 1}. [{format_resource(item['resource'], ResourceConstraintUnit.TURNS)}] {item['title']}" - for i, item in enumerate(agenda_items) - ]) - total_resource = format_resource(sum([item["resource"] for item in agenda_items]), ResourceConstraintUnit.TURNS) - agenda_str += f"\nTotal = {total_resource}" - return agenda_str diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_artifact_template.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_artifact_template.py deleted file mode 100644 index 33d8e454..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/update_artifact_template.py +++ /dev/null @@ -1,32 +0,0 @@ -update_artifact_template = """You are a helpful, thoughtful, and meticulous assistant. You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the end of the conversation, and to ensure a smooth experience for the user. - -This is the schema of the artifact you are completing: -{{ artifact_schema }}{% if context %} - -Here is some additional context about the conversation: -{{ context }}{% endif %} - -Throughout the conversation, you must abide by these rules: -{{ rules }}{% if current_state_description %} - -Here's a description of the conversation flow: -{{ current_state_description }} -Follow this description, and exercise good judgment about when it is appropriate to deviate.{% endif %} - -You will be provided the history of your conversation with the user up until now and the current state of the artifact. -Note that if the value for a field in the artifact is 'Unanswered', it means that the field has not been completed. -You need to select the best possible action(s), given the state of the conversation and the artifact. - -Your job is to create a list of field updates to update the artifact. Each update should be listed as: - -update_artifact_field(required parameters: field, value) - -- You should pick this action as soon as (a) the user provides new information that is not already reflected in the current state of the artifact and (b) you are able to submit a valid value for a field in the artifact using this new information. If you have already updated a field in the artifact and there is no new information to update the field with, you should not pick this action. -- Make sure the value adheres to the constraints of the field as specified in the artifact schema. -- If the user has provided all required information to complete a field (i.e. the criteria for "Send message to user" are not satisfied) but the information is in the wrong format, you should not ask the user to reformat their response. Instead, you should simply update the field with the correctly formatted value. For example, if the artifact asks for the date of birth in the format "YYYY-MM-DD", and the user provides their date of birth as "June 15, 2000", you should update the field with the value "2000-06-15". -- Prioritize accuracy over completion. You should never make up information or make assumptions in order to complete a field. For example, if the field asks for a 10-digit phone number, and the user provided a 9-digit phone number, you should not add a digit to the phone number in order to complete the field. Instead, you should follow-up with the user to ask for the correct phone number. If they still aren't able to provide one, you should leave the field unanswered. -- If the user isn't able to provide all of the information needed to complete a field, use your best judgment to determine if a partial answer is appropriate (assuming it adheres to the formatting requirements of the field). For example, if the field asks for a description of symptoms along with details about when the symptoms started, but the user isn't sure when their symptoms started, it's better to record the information they do have rather than to leave the field unanswered (and to indicate that the user was unsure about the start date). -- If it's possible to update multiple fields at once (assuming you're adhering to the above rules in all cases), you should do so. For example, if the user provides their full name and date of birth in the same message, you should select the "update artifact fields" action twice, once for each field. - -Your task is to state your step-by-step reasoning for the best possible action(s), followed by a final recommendation of which update(s) to make, including all required parameters. Someone else will be responsible for executing the update(s) you select and they will only have access to your output (not any of the conversation history, artifact schema, or other context) so it is EXTREMELY important that you clearly specify the value of all required parameters for each update you make. -""" diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/definition.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/definition.py deleted file mode 100644 index b2480218..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/definition.py +++ /dev/null @@ -1,14 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from form_filler_skill.resources import ResourceConstraint - - -@dataclass -class GCDefinition: - artifact_schema: str - rules: str - conversation_flow: Optional[str] - conversation_context: str - resource_constraint: Optional[ResourceConstraint] - service_id: str = "gc_main" diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/form_filler_skill.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/form_filler_skill.py index 1e901c18..5c563d29 100644 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/form_filler_skill.py +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/form_filler_skill.py @@ -1,11 +1,9 @@ -# flake8: noqa -# ruff: noqa - from typing import Any, Optional from openai_client.chat_driver import ChatDriverConfig -from skill_library import FunctionRoutine, RoutineTypes, Skill +from skill_library import RoutineTypes, Skill, StateMachineRoutine from skill_library.run_context import RunContext +from skill_library.types import LanguageModel NAME = "form-filler" CLASS_NAME = "FormFillerSkill" @@ -18,6 +16,7 @@ class FormFillerSkill(Skill): def __init__( self, chat_driver_config: ChatDriverConfig, + language_model: LanguageModel, ) -> None: # Put all functions in a group. We are going to use all these as (1) # skill actions, but also as (2) chat functions and (3) chat commands. @@ -43,7 +42,7 @@ def __init__( name=NAME, description=DESCRIPTION, chat_driver_config=chat_driver_config, - skill_actions=functions, + actions=functions, routines=routines, ) @@ -51,8 +50,8 @@ def __init__( # Routines ################################## - def form_filler_routine(self) -> FunctionRoutine: - return FunctionRoutine( + def form_filler_routine(self) -> StateMachineRoutine: + return StateMachineRoutine( name="form_filler", description="Run a form-filler routine.", init_function=self.form_fill_init, @@ -70,7 +69,7 @@ async def form_fill_step( message: Optional[str] = None, ) -> str | None: FormFiller = self - state = await context.state() + state = await context.get_state() while True: match state.get("mode"): case None: @@ -88,12 +87,12 @@ async def form_fill_step( context, "guided_conversation.doc_upload", guided_conversation_vars ) state["gc_id"] = gc_id - # FIXME: This is not implemented yet. + # TODO: What is the best way to subroutine? # artifact = GuidedConversation.run(state["gce_id"], message) # if artifact: # state["artifact"] = artifact # else: - # await context.update_state(state) + # await context.set_state(state) # return agenda, is_done = FormFiller.update_agenda(context) @@ -101,7 +100,7 @@ async def form_fill_step( if is_done: state["mode"] = "done" state["mode"] = "conversation" - await context.update_state(state) + await context.set_state(state) return agenda case "conversation": state["form"] = FormFiller.update_form(context) @@ -109,12 +108,12 @@ async def form_fill_step( state["agenda"] = agenda if is_done: state["mode"] = "finalize" - await context.update_state(state) + await context.set_state(state) return agenda case "finalize": message = FormFiller.generate_filled_form(context) state["mode"] = "done" - await context.update_state(state) + await context.set_state(state) return message case "done": return None diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/__init__.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/__init__.py index e69de29b..55ab367e 100644 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/__init__.py +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/__init__.py @@ -0,0 +1,9 @@ +import logging + +from .guided_conversation_skill import GuidedConversationSkill + +logger = logging.getLogger(__name__) + +__all__ = [ + "GuidedConversationSkill", +] diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/agenda.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/agenda.py index 9d5623e9..64e0189b 100644 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/agenda.py +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/agenda.py @@ -2,8 +2,8 @@ from pydantic import Field -from form_filler_skill.base_model_llm import BaseModelLLM -from form_filler_skill.resources import ( +from form_filler_skill.guided_conversation.chat_drivers.unneeded.base_model_llm import BaseModelLLM +from form_filler_skill.guided_conversation.resources import ( ResourceConstraintMode, ) @@ -17,7 +17,6 @@ class AgendaItem(BaseModelLLM): class Agenda(BaseModelLLM): resource_constraint_mode: ResourceConstraintMode | None = Field(default=None) - max_agenda_retries: int = Field(default=2) items: list[AgendaItem] = Field( description="Ordered list of items to be completed in the remainder of the conversation", default_factory=list, diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/artifact.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/artifact.py index b4f29717..a565d082 100644 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/artifact.py +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/artifact.py @@ -1,48 +1,17 @@ # Copyright (c) Microsoft. All rights reserved. -# FIXME: Copied code from Semantic Kernel repo, using as-is despite type errors -# type: ignore +import json import logging -from typing import Annotated, Any, Literal, get_args, get_origin, get_type_hints +from typing import Any, Literal, TypeVar, Union, get_args, get_origin, get_type_hints -from guided_conversation.utils.base_model_llm import BaseModelLLM -from guided_conversation.utils.conversation_helpers import Conversation, ConversationMessageType -from guided_conversation.utils.openai_tool_calling import ToolValidationResult -from guided_conversation.utils.plugin_helpers import PluginOutput, fix_error, update_attempts from pydantic import BaseModel, create_model -from semantic_kernel import Kernel -from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior -from semantic_kernel.contents import AuthorRole, ChatMessageContent -from semantic_kernel.functions import KernelArguments -from semantic_kernel.functions.kernel_function_decorator import kernel_function - -ARTIFACT_ERROR_CORRECTION_SYSTEM_TEMPLATE = """You are a helpful, thoughtful, and meticulous assistant. -You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the end of the conversation. -You have tried to update a field in the artifact, but the value you provided did not adhere \ -to the constraints of the field as specified in the artifact schema. -You will be provided the history of your conversation with the user, the schema for the field, \ -your previous attempt(s) at updating the field, and the error message(s) that resulted from your attempt(s). -Your task is to select the best possible action to take next: -1. Update artifact -- You should pick this action if you have a valid value to submit for the field in question. -2. Resume conversation -- You should pick this action if: (a) you do NOT have a valid value to submit for the field in question, and \ -(b) you need to ask the user for more information in order to obtain a valid value. \ -For example, if the user stated that their date of birth is June 2000, but the artifact field asks for the date of birth in the format \ -"YYYY-MM-DD", you should resume the conversation and ask the user for the day. - -Conversation history: -{{ conversation_history }} - -Schema: -{{ artifact_schema }} - -Previous attempts to update the field "{{ field_name }}" in the artifact: -{{ previous_attempts }}""" - -UPDATE_ARTIFACT_TOOL = "update_artifact_field" -RESUME_CONV_TOOL = "resume_conversation" + +from form_filler_skill.guided_conversation.chat_drivers.unneeded.base_model_llm import BaseModelLLM + +from .message import ConversationMessageType, Message + +logger = logging.getLogger(__name__) class Artifact: @@ -57,132 +26,40 @@ class Artifact: get_artifact_for_prompt, get_schema_for_prompt, and get_failed_fields. """ - def __init__(self, service_id: str, input_artifact: BaseModel, max_artifact_field_retries: int = 2) -> None: + def __init__( + self, + input_artifact: BaseModel, + max_artifact_field_retries: int = 2, + ) -> None: """ Initialize the Artifact plugin with the given Pydantic base model. Args: - kernel (Kernel): The Semantic Kernel instance to use for calling the LLM. Don't forget to set your - req_settings since this class uses tool calling functionality from the Semantic Kernel. - service_id (str): The service ID to use for the Semantic Kernel tool calling. One kernel can have multiple - services. The service ID is used to identify which service to use for LLM calls. The Artifact object - assumes that the service has tool calling capabilities and is some flavor of chat completion. input_artifact (BaseModel): The Pydantic base model to use as the artifact max_artifact_field_retries (int): The maximum number of times to retry updating a field in the artifact """ - logger = logging.getLogger(__name__) - self.logger = logger - self.id = "artifact_plugin" - self.service_id = service_id self.max_artifact_field_retries = max_artifact_field_retries self.original_schema = input_artifact.model_json_schema() - self.artifact = self._initialize_artifact(input_artifact) - # failed_artifact_fields maps a field name to a list of the history of the failed attempts to update it + # Create a new artifact model based on the one provided by the user with + # "Unanswered" set for all fields. + modified_classes = self._modify_classes(input_artifact) + self.artifact = self._modify_base_artifact(input_artifact, modified_classes)() + + # failed_artifact_fields maps a field name to a list of the history of + # the failed attempts to update it. # dict: key = field, value = list of tuple[attempt, error message] self.failed_artifact_fields: dict[str, list[tuple[str, str]]] = {} - # The following are the kernel functions that will be provided to the LLM call - @kernel_function( - name=UPDATE_ARTIFACT_TOOL, - description="Sets the value of a field in the artifact", - ) - def update_artifact_field( - self, - field: Annotated[str, "The name of the field to update in the artifact"], - value: Annotated[str, "The value to set the field to"], - ) -> None: - pass - - @kernel_function( - name=RESUME_CONV_TOOL, - description="Resumes conversation to get more information from the user ", - ) - def resume_conversation(self): - pass - - async def update_artifact(self, field_name: str, field_value: Any, conversation: Conversation) -> PluginOutput: - """The core interface for the Artifact plugin. - This function will attempt to update the given field_name to the given field_value. - If the field_value fails Pydantic validation, an LLM will determine one of two actions to take. - Given the conversation as additional context the two actions are: - - Retry the update the artifact by fixing the formatting using the previous failed attempts as guidance - - Take no action or in other words, resume the conversation to ask the user for more information because the user gave incomplete or incorrect information - - Args: - field_name (str): The name of the field to update in the artifact - field_value (Any): The value to set the field to - conversation (Conversation): The conversation object that contains the history of the conversation - - Returns: - PluginOutput: An object with two fields: a boolean indicating success - and a list of conversation messages that may have been generated. - - Several outcomes can happen: - - The update may have failed due to - - A field_name that is not valid in the artifact. - - The field_value failing Pydantic validation and all retries failed. - - The model failed to correctly call a tool. - In this case, the boolean will be False and the list may contain a message indicating the failure. - - - The agent may have successfully updated the artifact or fixed it. - In this case, the boolean will be True and the list will contain a message indicating the update and possibly intermediate messages. - - - The agent may have decided to resume the conversation. - In this case, the boolean will be True and the messages may only contain messages indicated previous errors. - """ - - conversation_messages: list[ChatMessageContent] = [] - - # Check if the field name is valid, and return with a failure message if not - is_valid_field, msg = self._is_valid_field(field_name) - if not is_valid_field: - conversation_messages.append(msg) - return PluginOutput(update_successful=False, messages=conversation_messages) - - # Try to update the field, and handle any errors that occur until the field is - # successfully updated or skipped according to max_artifact_field_retries - while True: - try: - # Check if there have been too many previous failed attempts to update the field - if len(self.failed_artifact_fields.get(field_name, [])) >= self.max_artifact_field_retries: - self.logger.warning(f"Updating field {field_name} has failed too many times. Skipping.") - return PluginOutput(False, conversation_messages) - - # Attempt to update the artifact - msg = self._execute_update_artifact(field_name, field_value) - conversation_messages.append(msg) - return PluginOutput(True, conversation_messages) - except Exception as e: - self.logger.warning(f"Error updating field {field_name}: {e}. Retrying...") - # Handle update error will increment failed_artifact_fields, once it has failed - # greater than self.max_artifact_field_retries the field will be skipped and the loop will break - success, new_field_value = await self._handle_update_error(field_name, field_value, conversation, e) - - # The agent has successfully fixed the field. - if success and new_field_value is not None: - self.logger.info(f"Agent successfully fixed field {field_name}. New value: {new_field_value}") - field_value = new_field_value - # This is the case where the agent has decided to resume the conversation. - elif success: - self.logger.info( - f"Agent could not fix the field itself & decided to resume conversation to fix field {field_name}" - ) - return PluginOutput(True, conversation_messages) - self.logger.warning(f"Agent failed to fix field {field_name}. Retrying...") - # Otherwise, the agent has failed and we will go through the loop again - def get_artifact_for_prompt(self) -> str: - """Returns a formatted JSON-like representation of the current state of the fields artifact. - Any fields that were failed are completely omitted. - - Returns: - str: The string representation of the artifact. + """ + Returns a formatted JSON-like representation of the current state of the + fields artifact. Any fields that were failed are completely omitted. """ failed_fields = self.get_failed_fields() - return {k: v for k, v in self.artifact.model_dump().items() if k not in failed_fields} + return json.dumps({k: v for k, v in self.artifact.model_dump().items() if k not in failed_fields}) def get_schema_for_prompt(self, filter_one_field: str | None = None) -> str: """Gets a clean version of the original artifact schema, optimized for use in an LLM prompt. @@ -214,7 +91,7 @@ def _clean_properties(schema: dict, failed_fields: list[str]) -> str: # If filter_one_field is provided, only get the schema for that one field if filter_one_field: if not self._is_valid_field(filter_one_field): - self.logger.error(f'Field "{filter_one_field}" is not a valid field in the artifact.') + logger.error(f'Field "{filter_one_field}" is not a valid field in the artifact.') raise ValueError(f'Field "{filter_one_field}" is not a valid field in the artifact.') filtered_schema = {"properties": {filter_one_field: self.original_schema["properties"][filter_one_field]}} filtered_schema.update((k, v) for k, v in self.original_schema.items() if k != "properties") @@ -225,7 +102,7 @@ def _clean_properties(schema: dict, failed_fields: list[str]) -> str: failed_fields = self.get_failed_fields() properties = _clean_properties(schema, failed_fields) if not properties: - self.logger.error("No properties found in the schema.") + logger.error("No properties found in the schema.") raise ValueError("No properties found in the schema.") types_schema = schema.get("$defs", {}) @@ -237,16 +114,20 @@ def _clean_properties(schema: dict, failed_fields: list[str]) -> str: custom_types.append(f"{type_name} = {clean_schema}") if custom_types: - explanation = f"If you wanted to create a {type_name} object, for example, you would make a JSON object \ -with the following keys: {', '.join(types_schema[type_name]['properties'].keys())}." + explanation = ( + f"If you wanted to create a {type_name} object, for example, you " + "would make a JSON object with the following keys: " + "{', '.join(types_schema[type_name]['properties'].keys())}." + ) custom_types_str = "\n".join(custom_types) - return f"""{properties} - -Here are the definitions for the custom types referenced in the artifact schema: -{custom_types_str} - -{explanation} -Remember that when updating the artifact, the field will be the original field name in the artifact and the JSON object(s) will be the value.""" + return ( + f"{properties}\n\n" + "Here are the definitions for the custom types referenced in the artifact schema:\n" + f"{custom_types_str}\n\n" + f"{explanation}\n" + "Remember that when updating the artifact, the field will be the original " + "field name in the artifact and the JSON object(s) will be the value." + ) else: return properties @@ -262,36 +143,27 @@ def get_failed_fields(self) -> list[str]: fields.append(field) return fields - def _initialize_artifact(self, artifact_model: BaseModel) -> BaseModelLLM: - """Create a new artifact model based on the one provided by the user - with "Unanswered" set for all fields. + T = TypeVar("T") - Args: - artifact_model (BaseModel): The Pydantic class provided by the user - - Returns: - BaseModelLLM: The new artifact model with "Unanswered" set for all fields + def _get_type_if_subtype(self, target_type: type[T], base_type: type[Any]) -> type[T] | None: """ - modified_classes = self._modify_classes(artifact_model) - artifact = self._modify_base_artifact(artifact_model, modified_classes) - return artifact() - - def _get_type_if_subtype(self, target_type: type[Any], base_type: type[Any]) -> type[Any] | None: - """Recursively checks the target_type to see if it is a subclass of base_type or a generic including base_type. + Recursively checks the target_type to see if it is a subclass of + base_type or a generic including base_type. Args: target_type: The type to check. base_type: The type to check against. Returns: - The class type if target_type is base_type, a subclass of base_type, or a generic including base_type; otherwise, None. + The class type if target_type is base_type, a subclass of base_type, + or a generic including base_type; otherwise, None. """ origin = get_origin(target_type) if origin is None: if issubclass(target_type, base_type): return target_type else: - # Recursively check if any of the arguments are the target type + # Recursively check if any of the arguments are the target type. for arg in get_args(target_type): result = self._get_type_if_subtype(arg, base_type) if result is not None: @@ -312,145 +184,74 @@ def _modify_classes(self, artifact_class: BaseModel) -> dict[str, type[BaseModel def _replace_type_annotations( self, field_annotation: type[Any] | None, modified_classes: dict[str, type[BaseModelLLM]] ) -> type: - """Recursively replace type annotations with modified classes where applicable.""" - # Get the origin of the field annotation, which is the base type for generic types (e.g., List[str] -> list, Dict[str, int] -> dict) + """ + Recursively replace type annotations with modified classes where + applicable. + """ + # Get the origin of the field annotation, which is the base type for + # generic types (e.g., List[str] -> list, Dict[str, int] -> dict) origin = get_origin(field_annotation) - # Get the type arguments of the generic type (e.g., List[str] -> str, Dict[str, int] -> str, int) + + # Get the type arguments of the generic type (e.g., List[str] -> str, + # Dict[str, int] -> str, int) args = get_args(field_annotation) if origin is None: # The type is not generic; check if it's a subclass that needs to be replaced if isinstance(field_annotation, type) and issubclass(field_annotation, BaseModelLLM): return modified_classes.get(field_annotation.__name__, field_annotation) - return field_annotation + return field_annotation if field_annotation is not None else object else: # The type is generic; recursively replace the type annotations of the arguments new_args = tuple(self._replace_type_annotations(arg, modified_classes) for arg in args) return origin[new_args] def _modify_base_artifact( - self, artifact_model: type[BaseModelLLM], modified_classes: dict[str, type[BaseModelLLM]] | None = None + self, + artifact_model: BaseModel, + modified_classes: dict[str, type[BaseModelLLM]] | None = None, ) -> type[BaseModelLLM]: - """Create a new artifact model with 'Unanswered' as a default and valid value for all fields.""" - for _, field_info in artifact_model.model_fields.items(): - # Replace original classes with modified version + """ + Create a new artifact model with 'Unanswered' as a default and valid + value for all fields. + """ + field_definitions = {} + for name, field_info in artifact_model.model_fields.items(): + # Replace original classes with modified version. if modified_classes is not None: field_info.annotation = self._replace_type_annotations(field_info.annotation, modified_classes) - # This makes it possible to always set a field to "Unanswered" - field_info.annotation = field_info.annotation | Literal["Unanswered"] - # This sets the default value to "Unanswered" - field_info.default = "Unanswered" - # This adds "Unanswered" as a possible value to any regex patterns + + # This makes it possible to always set a field to "Unanswered". + annotation = Union[field_info.annotation, Literal["Unanswered"]] + + # This sets the default value to "Unanswered". + default = "Unanswered" + + # This adds "Unanswered" as a possible value to any regex patterns. metadata = field_info.metadata for m in metadata: if hasattr(m, "pattern"): m.pattern += "|Unanswered" - field_definitions = { - name: (field_info.annotation, field_info) for name, field_info in artifact_model.model_fields.items() - } - artifact_model = create_model("Artifact", __base__=BaseModelLLM, **field_definitions) - return artifact_model - def _is_valid_field(self, field_name: str) -> tuple[bool, ChatMessageContent]: - """Check if the field_name is a valid field in the artifact. Returns True if it is, False and an error message otherwise.""" + field_definitions[name] = (annotation, default, *metadata) + + return create_model("Artifact", __base__=BaseModelLLM, **field_definitions) + + def _is_valid_field(self, field_name: str) -> tuple[bool, Message | None]: + """ + Check if the field_name is a valid field in the artifact. Returns True + if it is, False and an error message otherwise. + """ if field_name not in self.artifact.model_fields: error_message = f'Field "{field_name}" is not a valid field in the artifact.' - msg = ChatMessageContent( - role=AuthorRole.ASSISTANT, - content=error_message, - metadata={"type": ConversationMessageType.ARTIFACT_UPDATE, "turn_number": None}, + msg = Message( + {"role": "assistant", "content": error_message}, + type=ConversationMessageType.ARTIFACT_UPDATE, + turn=None, ) return False, msg return True, None - async def _fix_artifact_error( - self, - field_name: str, - previous_attempts: str, - conversation_repr: str, - artifact_schema_repr: str, - ) -> dict[str, Any]: - """Calls the LLM to fix an error in the artifact using Semantic Kernel kernel.""" - - req_settings = self.kernel.get_prompt_execution_settings_from_service_id(self.service_id) - req_settings.max_tokens = 2000 - - self.kernel.add_function(plugin_name=self.id, function=self.update_artifact_field) - self.kernel.add_function(plugin_name=self.id, function=self.resume_conversation) - filter = {"included_plugins": [self.id]} - req_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(auto_invoke=False, filters=filter) - - arguments = KernelArguments( - field_name=field_name, - conversation_history=conversation_repr, - previous_attempts=previous_attempts, - artifact_schema=artifact_schema_repr, - settings=req_settings, - ) - - return await fix_error( - kernel=self.kernel, - prompt_template=ARTIFACT_ERROR_CORRECTION_SYSTEM_TEMPLATE, - req_settings=req_settings, - arguments=arguments, - ) - - def _execute_update_artifact( - self, - field_name: Annotated[str, "The name of the field to update in the artifact"], - field_value: Annotated[Any, "The value to set the field to"], - ) -> ChatMessageContent: - """Update a field in the artifact with a new value. This will raise an error if the field_value is invalid.""" - setattr(self.artifact, field_name, field_value) - msg = ChatMessageContent( - role=AuthorRole.ASSISTANT, - content=f"Assistant updated {field_name} to {field_value}", - metadata={"type": ConversationMessageType.ARTIFACT_UPDATE, "turn_number": None}, - ) - return msg - - async def _handle_update_error( - self, field_name: str, field_value: Any, conversation: Conversation, error: Exception - ) -> tuple[bool, Any]: - """ - Handles the logic for when an error occurs while updating a field. - Creates the appropriate context for the model and calls the LLM to fix the error. - - Args: - field_name (str): The name of the field to update in the artifact - field_value (Any): The value to set the field to - conversation (Conversation): The conversation object that contains the history of the conversation - error (Exception): The error that occurred while updating the field - - Returns: - tuple[bool, Any]: A tuple containing a boolean indicating success and the new field value if successful (if not, then None) - """ - # Update the failed attempts for the field - previous_attempts = self.failed_artifact_fields.get(field_name, []) - previous_attempts, llm_formatted_attempts = update_attempts( - error=error, attempt_id=str(field_value), previous_attempts=previous_attempts - ) - self.failed_artifact_fields[field_name] = previous_attempts - - # Call the LLM to fix the error - conversation_history_repr = conversation.get_repr_for_prompt(exclude_types=[ConversationMessageType.REASONING]) - artifact_schema_repr = self.get_schema_for_prompt(filter_one_field=field_name) - result = await self._fix_artifact_error( - field_name, llm_formatted_attempts, conversation_history_repr, artifact_schema_repr - ) - - # Handling the result of the LLM call - if result["validation_result"] != ToolValidationResult.SUCCESS: - return False, None - # Only consider the first tool call - tool_name = result["tool_names"][0] - tool_args = result["tool_args_list"][0] - if tool_name == f"{self.id}-{UPDATE_ARTIFACT_TOOL}": - field_value = tool_args["value"] - return True, field_value - elif tool_name == f"{self.id}-{RESUME_CONV_TOOL}": - return True, None - def to_json(self) -> dict: artifact_fields = self.artifact.model_dump() return { @@ -462,12 +263,10 @@ def to_json(self) -> dict: def from_json( cls, json_data: dict, - kernel: Kernel, - service_id: str, input_artifact: BaseModel, max_artifact_field_retries: int = 2, ) -> "Artifact": - artifact = cls(kernel, service_id, input_artifact, max_artifact_field_retries) + artifact = cls(input_artifact, max_artifact_field_retries) artifact.failed_artifact_fields = json_data["failed_fields"] diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/final_update.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/final_artifact_update.py similarity index 63% rename from libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/final_update.py rename to libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/final_artifact_update.py index 1d3690fe..5fed5046 100644 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/chat_drivers/final_update.py +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/final_artifact_update.py @@ -1,15 +1,23 @@ import logging - -from form_filler_skill.artifact import Artifact -from form_filler_skill.definition import GCDefinition -from form_filler_skill.message import Conversation -from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai_client.chat_driver import ChatDriver, ChatDriverConfig, InMemoryMessageHistoryProvider +from typing import cast + +from openai_client import ( + CompletionError, + add_serializable_data, + create_system_message, + create_user_message, + make_completion_args_serializable, + validate_completion, +) +from skill_library.types import LanguageModel + +from ..artifact import Artifact +from ..definition import GCDefinition +from ..message import Conversation logger = logging.getLogger(__name__) - -final_update_template = """You are a helpful, thoughtful, and meticulous assistant. +FINAL_UPDATE_TEMPLATE = """You are a helpful, thoughtful, and meticulous assistant. You just finished a conversation with a user.{% if context %} Here is some additional context about the conversation: {{ context }}{% endif %} @@ -54,34 +62,52 @@ {{ artifact_state }}""" -async def final_update( - open_ai_client: AsyncOpenAI | AsyncAzureOpenAI, +async def final_artifact_update( + language_model: LanguageModel, definition: GCDefinition, chat_history: Conversation, artifact: Artifact, -): - history = InMemoryMessageHistoryProvider() - - history.append_system_message( - final_update_template, - { - "context": definition.conversation_context, - "artifact_schema": artifact.get_schema_for_prompt(), - }, - ) - history.append_user_message( - USER_MESSAGE_TEMPLATE, - { - "conversation_history": str(chat_history), - "artifact_state": artifact.get_artifact_for_prompt(), - }, - ) - - config = ChatDriverConfig( - openai_client=open_ai_client, - model="gpt-3.5-turbo", - message_provider=history, - ) - - chat_driver = ChatDriver(config) - return await chat_driver.respond() +) -> Artifact: + # TODO: Change out the chat driver. + + completion_args = { + "model": "gpt-3.5-turbo", + "messages": [ + create_system_message( + FINAL_UPDATE_TEMPLATE, + { + "context": definition.conversation_context, + "artifact_schema": artifact.get_schema_for_prompt(), + }, + ), + create_user_message( + USER_MESSAGE_TEMPLATE, + { + "conversation_history": str(chat_history), + "artifact_state": artifact.get_artifact_for_prompt(), + }, + ), + ], + "response_format": Artifact, + } + + metadata = {} + logger.debug("Completion call.", extra=add_serializable_data(make_completion_args_serializable(completion_args))) + 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=add_serializable_data({"completion": completion.model_dump()})) + metadata["completion"] = completion.model_dump() + except CompletionError as e: + completion_error = CompletionError(e) + metadata["completion_error"] = completion_error.message + logger.error( + e.message, extra=add_serializable_data({"completion_error": completion_error.body, "metadata": metadata}) + ) + raise completion_error from e + else: + agenda = cast(Artifact, completion.choices[0].message.parsed) + return agenda diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/fix_agenda_error.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/fix_agenda_error.py new file mode 100644 index 00000000..cb7ee4ea --- /dev/null +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/fix_agenda_error.py @@ -0,0 +1,79 @@ +import logging +from typing import cast + +from openai_client import ( + CompletionError, + add_serializable_data, + create_system_message, + create_user_message, + make_completion_args_serializable, + validate_completion, +) +from skill_library.types import LanguageModel + +from ..agenda import Agenda +from ..message import Conversation, ConversationMessageType + +logger = logging.getLogger(__name__) + +AGENDA_ERROR_CORRECTION_SYSTEM_TEMPLATE = """You are a helpful, thoughtful, and meticulous assistant. +You are conducting a conversation with a user. You tried to update the agenda, but the update was invalid. + +You will be provided the history of your conversation with the user, your previous attempt(s) at updating the agenda, and the error message(s) that resulted from your attempt(s). +Your task is to correct the update so that it is valid. + +Your changes should be as minimal as possible - you are focused on fixing the error(s) that caused the update to be invalid. + +Note that if the resource allocation is invalid, you must follow these rules: + +1. You should not change the description of the first item (since it has already been executed), but you can change its resource allocation. +2. For all other items, you can combine or split them, or assign them fewer or more resources, but the content they cover collectively should not change (i.e. don't eliminate or add new topics). +For example, the invalid attempt was "item 1 = ask for date of birth (1 turn), item 2 = ask for phone number (1 turn), item 3 = ask for phone type (1 turn), item 4 = explore treatment history (6 turns)", and the error says you need to correct the total resource allocation to 7 turns. A bad solution is "item 1 = ask for date of birth (1 turn), item 2 = explore treatment history (6 turns)" because it eliminates the phone number and phone type topics. A good solution is "item 1 = ask for date of birth (2 turns), item 2 = ask for phone number, phone type, and treatment history (2 turns), item 3 = explore treatment history (3 turns)." +""" + + +async def fix_agenda_error( + language_model: LanguageModel, + previous_attempts: str, + conversation: Conversation, +) -> Agenda: + completion_args = { + "model": "gpt-3.5-turbo", + "messages": [ + create_system_message(AGENDA_ERROR_CORRECTION_SYSTEM_TEMPLATE), + create_user_message( + ( + "Conversation history:\n" + "{{ conversation_history }}\n\n" + "Previous attempts to update the agenda:\n" + "{{ previous_attempts }}" + ), + { + "conversation_history": str(conversation.exclude([ConversationMessageType.REASONING])), + "previous_attempts": previous_attempts, + }, + ), + ], + "response_format": Agenda, + } + + metadata = {} + logger.debug("Completion call.", extra=add_serializable_data(make_completion_args_serializable(completion_args))) + 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=add_serializable_data({"completion": completion.model_dump()})) + metadata["completion"] = completion.model_dump() + except CompletionError as e: + completion_error = CompletionError(e) + metadata["completion_error"] = completion_error.message + logger.error( + e.message, extra=add_serializable_data({"completion_error": completion_error.body, "metadata": metadata}) + ) + raise completion_error from e + else: + agenda = cast(Agenda, completion.choices[0].message.parsed) + return agenda diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/fix_artifact_error.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/fix_artifact_error.py new file mode 100644 index 00000000..7fef2c38 --- /dev/null +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/fix_artifact_error.py @@ -0,0 +1,107 @@ +from typing import Any + +from form_filler_skill.guided_conversation.message import Conversation, ConversationMessageType +from openai_client import ( + CompletionError, + add_serializable_data, + create_system_message, + create_user_message, + make_completion_args_serializable, + message_from_completion, + validate_completion, +) +from skill_library.types import LanguageModel + +from .. import logger +from ..artifact import Artifact +from .generate_artifact_updates import UpdateAttempt + +ARTIFACT_ERROR_CORRECTION_SYSTEM_TEMPLATE = """You are a helpful, thoughtful, and meticulous assistant. + +You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the end of the conversation. + +You have tried to update a field in the artifact, but the value you provided did not adhere to the constraints of the field as specified in the artifact schema. + +You will be provided the history of your conversation with the user, the schema for the field, your previous attempt(s) at updating the field, and the error message(s) that resulted from your attempt(s). + +Your task is to return the best possible action to take next: + +1. UPDATE_FIELD(value) +- You should pick this action if you have a valid value to submit for the field in question. Replace "value" with the correct value. + +2. RESUME_CONVERSATION +- You should pick this action if: (a) you do NOT have a valid value to submit for the field in question, and (b) you need to ask the user for more information in order to obtain a valid value. For example, if the user stated that their date of birth is June 2000, but the artifact field asks for the date of birth in the format "YYYY-MM-DD", you should resume the conversation and ask the user for the day. + +Return only the action, either UPDATE_ARTIFACT(value) or RESUME_CONVERSATION, as your response. If you selected, UPDATE_ARTIFACT, make sure to replace "value" with the correct value. +""" + + +async def generate_artifact_field_update_error_fix( + language_model: LanguageModel, + artifact: Artifact, + field_name: str, + field_value: Any, + conversation: Conversation, + previous_attempts: list[UpdateAttempt], +) -> Any: + previous_attempts_string = "\n".join([ + f"Attempt: {attempt.field_value}\nError: {attempt.error}" for attempt in previous_attempts + ]) + + # Use the language model to generate a fix for the artifact field update + # error. + + completion_args = { + "model": "gpt-3.5-turbo", + "messages": [ + create_system_message(ARTIFACT_ERROR_CORRECTION_SYSTEM_TEMPLATE), + create_user_message( + ( + "Conversation history:\n" + "{{ conversation_history }}\n\n" + "Schema:\n" + "{{ artifact_schema }}\n\n" + 'Previous attempts to update the field "{{ field_name }}" in the artifact:\n' + "{{ previous_attempts }}" + ), + { + "conversation_history": str(conversation.exclude([ConversationMessageType.REASONING])), + "artifact_schema": artifact.get_schema_for_prompt(filter_one_field=field_name), + "field_name": field_name, + "previous_attempts": previous_attempts_string, + }, + ), + ], + } + + metadata = {} + logger.debug("Completion call.", extra=add_serializable_data(make_completion_args_serializable(completion_args))) + 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=add_serializable_data({"completion": completion.model_dump()})) + metadata["completion"] = completion.model_dump() + except CompletionError as e: + completion_error = CompletionError(e) + metadata["completion_error"] = completion_error.message + logger.error( + e.message, extra=add_serializable_data({"completion_error": completion_error.body, "metadata": metadata}) + ) + raise completion_error from e + else: + message = message_from_completion(completion) + if message not in ["UPDATE_ARTIFACT", "RESUME_CONVERSATION"]: + raise ValueError(f"Failed to fix the artifact error due to an invalid response from the LLM: {message}") + + # TODO: This doesn't seem like the right thing to return. + if message == "RESUME_CONVERSATION": + return None + + if message.startswith("UPDATE_ARTIFACT("): + field_value = message.split("(")[1].split(")")[0] + return field_value + + raise ValueError(f"Failed to fix the artifact error due to an invalid response from the LLM: {message}") diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_final_update.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_final_update.py deleted file mode 100644 index 46150c99..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_final_update.py +++ /dev/null @@ -1,82 +0,0 @@ -import logging - -from form_filler_skill.guided_conversation.artifact import Artifact -from form_filler_skill.guided_conversation.conversation_helpers import Conversation -from form_filler_skill.guided_conversation.definition import GCDefinition -from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai_client.chat_driver import ChatDriver, ChatDriverConfig, InMemoryMessageHistoryProvider - -logger = logging.getLogger(__name__) - - -final_update_template = """You are a helpful, thoughtful, and meticulous assistant. -You just finished a conversation with a user.{% if context %} Here is some additional context about the conversation: -{{ context }}{% endif %} - -Your goal is to complete an artifact as thoroughly and accurately as possible based on the conversation. - -This is the schema of the artifact: -{{ artifact_schema }} - -You will be given the current state of the artifact as well as the conversation history. -Note that if the value for a field in the artifact is 'Unanswered', it means that the field was not completed. \ -Some fields may have already been completed during the conversation. - -Your need to determine whether there are any fields that need to be updated, and if so, update them. -- You should only update a field if both of the following conditions are met: (a) the current state does NOT adequately reflect the conversation \ -and (b) you are able to submit a valid value for a field. \ -You are allowed to update completed fields, but you should only do so if the current state is inadequate, \ -e.g. the user corrected a mistake in their date of birth, but the artifact does not show the corrected version. \ -Remember that it's always an option to reset a field to "Unanswered" - this is often the best choice if the artifact contains incorrect information that cannot be corrected. \ -Do not submit a value that is identical to the current state of the field (e.g. if the field is already "Unanswered" and the user didn't provide any new information about it, you should not submit "Unanswered"). \ -- Make sure the value adheres to the constraints of the field as specified in the artifact schema. \ -If it's not possible to update a field with a valid value (e.g., the user provided an invalid date of birth), you should not update the field. -- If the artifact schema is open-ended (e.g. it asks you to rate how pressing the user's issue is, without specifying rules for doing so), \ -use your best judgment to determine whether you have enough information to complete the field based on the conversation. -- Prioritize accuracy over completion. You should never make up information or make assumptions in order to complete a field. \ -For example, if the field asks for a 10-digit phone number, and the user provided a 9-digit phone number, you should not add a digit to the phone number in order to complete the field. -- If the user wasn't able to provide all of the information needed to complete a field, \ -use your best judgment to determine if a partial answer is appropriate (assuming it adheres to the formatting requirements of the field). \ -For example, if the field asks for a description of symptoms along with details about when the symptoms started, but the user wasn't sure when their symptoms started, \ -it's better to record the information they do have rather than to leave the field unanswered (and to indicate that the user was unsure about the start date). -- It's possible to update multiple fields at once (assuming you're adhering to the above rules in all cases). It's also possible that no fields need to be updated. - -Your task is to state your step-by-step reasoning about what to update, followed by a final recommendation. -Someone else will be responsible for executing the updates and they will only have access to your output \ -(not any of the conversation history, artifact schema, or other context) so make sure to specify exactly which \ -fields to update and the values to update them with, or to state that no fields need to be updated. - - -Conversation history: -{{ conversation_history }} - -Current state of the artifact: -{{ artifact_state }}""" - - -async def final_update( - open_ai_client: AsyncOpenAI | AsyncAzureOpenAI, - definition: GCDefinition, - chat_history: Conversation, - artifact: Artifact, -): - history = InMemoryMessageHistoryProvider() - - history.append_system_message( - final_update_template, - { - "conversation_history": chat_history.get_repr_for_prompt(), - "context": definition.conversation_context, - "artifact_schema": artifact.get_schema_for_prompt(), - "artifact_state": artifact.get_artifact_for_prompt(), - }, - ) - - config = ChatDriverConfig( - openai_client=open_ai_client, - model="gpt-3.5-turbo", - message_provider=history, - ) - - chat_driver = ChatDriver(config) - return await chat_driver.respond() diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_fix_agenda_error.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_fix_agenda_error.py deleted file mode 100644 index 8498d854..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_fix_agenda_error.py +++ /dev/null @@ -1,59 +0,0 @@ -import logging - -from form_filler_skill.guided_conversation.conversation_helpers import ( - Conversation, - ConversationMessageType, -) -from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai_client.chat_driver import ChatDriver, ChatDriverConfig, InMemoryMessageHistoryProvider - -logger = logging.getLogger(__name__) - -AGENDA_ERROR_CORRECTION_SYSTEM_TEMPLATE = """You are a helpful, thoughtful, and meticulous assistant. -You are conducting a conversation with a user. You tried to update the agenda, but the update was invalid. -You will be provided the history of your conversation with the user, \ -your previous attempt(s) at updating the agenda, and the error message(s) that resulted from your attempt(s). -Your task is to correct the update so that it is valid. \ -Your changes should be as minimal as possible - you are focused on fixing the error(s) that caused the update to be invalid. -Note that if the resource allocation is invalid, you must follow these rules: -1. You should not change the description of the first item (since it has already been executed), but you can change its resource allocation -2. For all other items, you can combine or split them, or assign them fewer or more resources, \ -but the content they cover collectively should not change (i.e. don't eliminate or add new topics). -For example, the invalid attempt was "item 1 = ask for date of birth (1 turn), item 2 = ask for phone number (1 turn), \ -item 3 = ask for phone type (1 turn), item 4 = explore treatment history (6 turns)", \ -and the error says you need to correct the total resource allocation to 7 turns. \ -A bad solution is "item 1 = ask for date of birth (1 turn), \ -item 2 = explore treatment history (6 turns)" because it eliminates the phone number and phone type topics. \ -A good solution is "item 1 = ask for date of birth (2 turns), item 2 = ask for phone number, phone type, -and treatment history (2 turns), item 3 = explore treatment history (3 turns)." - -Conversation history: -{{ conversation_history }} - -Previous attempts to update the agenda: -{{ previous_attempts }}""" - - -async def fix_agenda_error( - openai_client: AsyncOpenAI | AsyncAzureOpenAI, - previous_attempts: str, - conversation: Conversation, -): - history = InMemoryMessageHistoryProvider() - - history.append_system_message( - AGENDA_ERROR_CORRECTION_SYSTEM_TEMPLATE, - { - "conversation_history": conversation.get_repr_for_prompt(exclude_types=[ConversationMessageType.REASONING]), - "previous_attempts": previous_attempts, - }, - ) - - config = ChatDriverConfig( - openai_client=openai_client, - model="gpt-3.5-turbo", - message_provider=history, - ) - - chat_driver = ChatDriver(config) - return await chat_driver.respond() diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_update_agenda.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_update_agenda.py deleted file mode 100644 index 805af525..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_update_agenda.py +++ /dev/null @@ -1,239 +0,0 @@ -import logging - -from form_filler_skill.agenda import Agenda, AgendaItem -from form_filler_skill.guided_conversation.definition import GCDefinition -from form_filler_skill.message import Conversation -from form_filler_skill.resources import ( - GCResource, - ResourceConstraintMode, - ResourceConstraintUnit, - format_resource, -) -from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai_client.chat_driver import ChatDriver, ChatDriverConfig, InMemoryMessageHistoryProvider -from pydantic import ValidationError - -from ...artifact import Artifact -from ...chat_drivers.fix_agenda_error import fix_agenda_error -from ...chat_drivers.update_agenda_template import update_agenda_template - -logger = logging.getLogger(__name__) - - -def _get_termination_instructions(resource: GCResource): - """ - Get the termination instructions for the conversation. This is contingent on the resources mode, - if any, that is available. - - Assumes we're always using turns as the resource unit. - - Args: - resource (GCResource): The resource object. - - Returns: - str: the termination instructions - """ - # Termination condition under no resource constraints - if resource.resource_constraint is None: - return ( - "- You should pick this action as soon as you have completed the artifact to the best of your ability, the" - " conversation has come to a natural conclusion, or the user is not cooperating so you cannot continue the" - " conversation." - ) - - # Termination condition under exact resource constraints - if resource.resource_constraint.mode == ResourceConstraintMode.EXACT: - return ( - "- You should only pick this action if the user is not cooperating so you cannot continue the conversation." - ) - - # Termination condition under maximum resource constraints - elif resource.resource_constraint.mode == ResourceConstraintMode.MAXIMUM: - return ( - "- You should pick this action as soon as you have completed the artifact to the best of your ability, the" - " conversation has come to a natural conclusion, or the user is not cooperating so you cannot continue the" - " conversation." - ) - - else: - logger.error("Invalid resource mode provided.") - return "" - - -async def update_agenda( - openai_client: AsyncOpenAI | AsyncAzureOpenAI, - definition: GCDefinition, - chat_history: Conversation, - agenda: Agenda, - artifact: Artifact, - resource: GCResource, -) -> bool: - # STEP 1: Generate an updated agenda. - - # If there is a resource constraint and there's more than one turn left, - # include additional constraint instructions. - remaining_resource = resource.remaining_units if resource.remaining_units else 0 - resource_instructions = resource.get_resource_instructions() - if (resource_instructions != "") and (remaining_resource > 1): - match resource.get_resource_mode(): - case ResourceConstraintMode.MAXIMUM: - total_resource_str = f"does not exceed the remaining turns ({remaining_resource})." - ample_time_str = "" - case ResourceConstraintMode.EXACT: - total_resource_str = ( - f"is equal to the remaining turns ({remaining_resource}). Do not leave any turns unallocated." - ) - ample_time_str = ( - "If you have many turns remaining, instead of including wrap-up items or repeating " - "topics, you should include items that increase the breadth and/or depth of the conversation " - 'in a way that\'s directly relevant to the artifact (e.g. "collect additional details about X", ' - '"ask for clarification about Y", "explore related topic Z", etc.).' - ) - case _: - logger.error("Invalid resource mode.") - else: - total_resource_str = "" - ample_time_str = "" - - history = InMemoryMessageHistoryProvider() - history.append_system_message( - update_agenda_template, - { - "context": definition.conversation_context, - "artifact_schema": definition.artifact_schema, - "rules": definition.rules, - "current_state_description": definition.conversation_flow, - "show_agenda": True, - "remaining_resource": remaining_resource, - "total_resource_str": total_resource_str, - "ample_time_str": ample_time_str, - "termination_instructions": _get_termination_instructions(resource), - "resource_instructions": resource_instructions, - "chat_history": chat_history, - "agenda_state": get_agenda_for_prompt(agenda), - "artifact_state": artifact.get_artifact_for_prompt(), - }, - ) - - config = ChatDriverConfig( - openai_client=openai_client, - model="gpt-4o", - message_provider=history, - ) - - chat_driver = ChatDriver(config) - response = await chat_driver.respond() - items = response.message - - # STEP 2: Validate/fix the updated agenda. - - previous_attempts = [] - while True: - try: - # Pydantic type validation. - agenda.items = items # type: ignore - - # Check resource constraints. - if agenda.resource_constraint_mode is not None: - check_item_constraints( - agenda.resource_constraint_mode, - agenda.items, - resource.estimate_remaining_turns(), - ) - - logger.info(f"Agenda updated successfully: {get_agenda_for_prompt(agenda)}") - return True - - except (ValidationError, ValueError) as e: - # If we have reached the maximum number of retries return a failure. - if len(previous_attempts) >= agenda.max_agenda_retries: - logger.warning(f"Failed to update agenda after {agenda.max_agenda_retries} attempts.") - return False - - # Otherwise, get an error string. - if isinstance(e, ValidationError): - error_str = "; ".join([e.get("msg") for e in e.errors()]) - error_str = error_str.replace("; Input should be 'Unanswered'", " or input should be 'Unanswered'") - else: - error_str = str(e) - - # Add it to our list of previous attempts. - previous_attempts.append((str(items), error_str)) - - # And try again. - logger.info(f"Attempting to fix the agenda error. Attempt {len(previous_attempts)}.") - llm_formatted_attempts = "\n".join([ - f"Attempt: {attempt}\nError: {error}" for attempt, error in previous_attempts - ]) - response = await fix_agenda_error(openai_client, llm_formatted_attempts, chat_history) - - if response is None: - raise ValueError("Invalid response from the LLM.") - - # if response["validation_result"] != "success": # ToolValidationResult.SUCCESS: - # logger.warning( - # f"Failed to fix the agenda error due to a failure in the LLM tool call: {response['validation_result']}" - # ) - # return False - # else: - # # Use the result of the first tool call to try the update again - # items = response - items = response - - -def check_item_constraints( - resource_constraint_mode: ResourceConstraintMode, - items: list[AgendaItem], - remaining_turns: int, -) -> None: - """ - Validates if any constraints were violated while performing the agenda - update. - """ - # The total, proposed allocation of resources. - total_resources = sum([item.resource for item in items]) - - violations = [] - # In maximum mode, the total resources should not exceed the remaining - # turns. - if (resource_constraint_mode == ResourceConstraintMode.MAXIMUM) and (total_resources > remaining_turns): - violations.append( - "The total turns allocated in the agenda " - f"must not exceed the remaining amount ({remaining_turns}); " - f"but the current total is {total_resources}." - ) - - # In exact mode if the total resources were not exactly equal to the - # remaining turns. - if (resource_constraint_mode == ResourceConstraintMode.EXACT) and (total_resources != remaining_turns): - violations.append( - "The total turns allocated in the agenda " - f"must equal the remaining amount ({remaining_turns}); " - f"but the current total is {total_resources}." - ) - - # Check if any item has a resource value of 0. - if any(item.resource <= 0 for item in items): - violations.append("All items must have a resource value greater than 0.") - - # Raise an error if any violations were found. - if len(violations) > 0: - logger.debug(f"Agenda update failed due to the following violations: {violations}.") - raise ValueError(" ".join(violations)) - - -def get_agenda_for_prompt(agenda: Agenda) -> str: - """ - Gets a string representation of the agenda for use in an LLM prompt. - """ - agenda_json = agenda.model_dump() - agenda_items = agenda_json.get("items", []) - if len(agenda_items) == 0: - return "None" - agenda_str = "\n".join([ - f"{i + 1}. [{format_resource(item['resource'], ResourceConstraintUnit.TURNS)}] {item['title']}" - for i, item in enumerate(agenda_items) - ]) - total_resource = format_resource(sum([item["resource"] for item in agenda_items]), ResourceConstraintUnit.TURNS) - agenda_str += f"\nTotal = {total_resource}" - return agenda_str diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_update_artifact.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_update_artifact.py deleted file mode 100644 index c43f9e85..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/gc_update_artifact.py +++ /dev/null @@ -1,57 +0,0 @@ -import logging - -from openai import AsyncAzureOpenAI, AsyncOpenAI -from openai_client.chat_driver import ChatDriver, ChatDriverConfig, InMemoryMessageHistoryProvider - -logger = logging.getLogger(__name__) - -execution_template = """You are a helpful, thoughtful, and meticulous assistant. -You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the -end of the conversation. -You will be given some reasoning about the best possible action(s) to take next given the state of the conversation -as well as the artifact schema. -The reasoning is supposed to state the recommended action(s) to take next, along with all required parameters for each action. -Your task is to execute ALL actions recommended in the reasoning in the order they are listed. -If the reasoning's specification of an action is incomplete (e.g. it doesn't include all required parameters for the -action, \ -or some parameters are specified implicitly, such as "send a message that contains a greeting" instead of explicitly -providing \ -the value of the "message" parameter), do not execute the action. You should never fill in missing or imprecise -parameters yourself. -If the reasoning is not clear about which actions to take, or all actions are specified in an incomplete way, \ -return 'None' without selecting any action. - -Artifact schema: -{{ artifact_schema }} - -If the type in the schema is str, the "field_value" parameter in the action should be also be a string. -These are example parameters for the update_artifact action: {"field_name": "company_name", "field_value": "Contoso"} -DO NOT write JSON in the "field_value" parameter in this case. {"field_name": "company_name", "field_value": "{"value": "Contoso"}"} is INCORRECT. - -Reasoning: -{{ reasoning }}""" - - -async def update_artifact( - open_ai_client: AsyncOpenAI | AsyncAzureOpenAI, - reasoning: str, - artifact_schema: str, -): - history = InMemoryMessageHistoryProvider() - - history.append_system_message( - execution_template, - { - "artifact_schema": artifact_schema, - "reasoning": reasoning, - }, - ) - - config = ChatDriverConfig( - openai_client=open_ai_client, - model="gpt-3.5-turbo", - message_provider=history, - ) - - chat_driver = ChatDriver(config) - return await chat_driver.respond() diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/generate_artifact_updates.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/generate_artifact_updates.py new file mode 100644 index 00000000..a9e08a5d --- /dev/null +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/generate_artifact_updates.py @@ -0,0 +1,153 @@ +import logging +from typing import Any, cast + +from form_filler_skill.guided_conversation.definition import GCDefinition +from openai_client import ( + CompletionError, + add_serializable_data, + create_system_message, + make_completion_args_serializable, + validate_completion, +) +from pydantic import BaseModel +from skill_library.types import LanguageModel + +from ..artifact import Artifact +from ..message import Conversation +from .fix_artifact_error import generate_artifact_field_update_error_fix + +logger = logging.getLogger(__name__) + +UPDATE_ARTIFACT_TEMPLATE = """You are a helpful, thoughtful, and meticulous assistant. You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the end of the conversation, and to ensure a smooth experience for the user. + +This is the schema of the artifact you are completing: +{{ artifact_schema }}{% if context %} + +Here is some additional context about the conversation: +{{ context }}{% endif %} + +Throughout the conversation, you must abide by these rules: +{{ rules }}{% if current_state_description %} + +Here's a description of the conversation flow: +{{ current_state_description }} +Follow this description, and exercise good judgment about when it is appropriate to deviate.{% endif %} + +You will be provided the history of your conversation with the user up until now and the current state of the artifact. +Note that if the value for a field in the artifact is 'Unanswered', it means that the field has not been completed. +You need to select the best possible action(s), given the state of the conversation and the artifact. + +Your job is to create a list of field updates to update the artifact. Each update should be listed as: + +update_artifact_field(required parameters: field, value) + +- You should pick this action as soon as (a) the user provides new information that is not already reflected in the current state of the artifact and (b) you are able to submit a valid value for a field in the artifact using this new information. If you have already updated a field in the artifact and there is no new information to update the field with, you should not pick this action. +- Make sure the value adheres to the constraints of the field as specified in the artifact schema. +- If the user has provided all required information to complete a field (i.e. the criteria for "Send message to user" are not satisfied) but the information is in the wrong format, you should not ask the user to reformat their response. Instead, you should simply update the field with the correctly formatted value. For example, if the artifact asks for the date of birth in the format "YYYY-MM-DD", and the user provides their date of birth as "June 15, 2000", you should update the field with the value "2000-06-15". +- Prioritize accuracy over completion. You should never make up information or make assumptions in order to complete a field. For example, if the field asks for a 10-digit phone number, and the user provided a 9-digit phone number, you should not add a digit to the phone number in order to complete the field. Instead, you should follow-up with the user to ask for the correct phone number. If they still aren't able to provide one, you should leave the field unanswered. +- If the user isn't able to provide all of the information needed to complete a field, use your best judgment to determine if a partial answer is appropriate (assuming it adheres to the formatting requirements of the field). For example, if the field asks for a description of symptoms along with details about when the symptoms started, but the user isn't sure when their symptoms started, it's better to record the information they do have rather than to leave the field unanswered (and to indicate that the user was unsure about the start date). +- If it's possible to update multiple fields at once (assuming you're adhering to the above rules in all cases), you should do so. For example, if the user provides their full name and date of birth in the same message, you should select the "update artifact fields" action twice, once for each field. + +Your task is to state your step-by-step reasoning for the best possible action(s), followed by a final recommendation of which update(s) to make, including all required parameters. Someone else will be responsible for executing the update(s) you select and they will only have access to your output (not any of the conversation history, artifact schema, or other context) so it is EXTREMELY important that you clearly specify the value of all required parameters for each update you make. +""" + + +class ArtifactUpdate(BaseModel): + field: str + value: Any + + +class ArtifactUpdates(BaseModel): + updates: list[ArtifactUpdate] + + +class UpdateAttempt(BaseModel): + field_value: str + error: str + + +async def generate_artifact_updates( + language_model: LanguageModel, + definition: GCDefinition, + artifact: Artifact, + conversation: Conversation, + max_retries: int = 2, +) -> list[ArtifactUpdate]: + # Use the language model to generate artifact updates. + + completion_args = { + "model": "gpt-4o", + "messages": [ + create_system_message( + UPDATE_ARTIFACT_TEMPLATE, + { + "artifact_schema": artifact.get_schema_for_prompt(), + "context": definition.conversation_context, + "rules": definition.rules, + "current_state_description": definition.conversation_flow, + }, + ), + ], + "response_format": ArtifactUpdates, + } + + metadata = {} + logger.debug("Completion call.", extra=add_serializable_data(make_completion_args_serializable(completion_args))) + 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=add_serializable_data({"completion": completion.model_dump()})) + metadata["completion"] = completion.model_dump() + except CompletionError as e: + completion_error = CompletionError(e) + metadata["completion_error"] = completion_error.message + logger.error( + e.message, extra=add_serializable_data({"completion_error": completion_error.body, "metadata": metadata}) + ) + raise completion_error from e + else: + artifact_updates = cast(ArtifactUpdates, completion.choices[0].message.parsed) + + # Check if each update is valid. If not, try to fix it a few times. + + good_updates: list[ArtifactUpdate] = [] + for update in artifact_updates.updates: + attempts: list[UpdateAttempt] = [] + while len(attempts) < max_retries: + # Don't try again if the attribute doesn't exist. Just skip this update. + if not hasattr(artifact, update.field): + logger.warning( + f"Field {update.field} is not a valid field for this artifact.", extra=add_serializable_data(update) + ) + continue + + # If the update value isn't the right type, though, try to fix it. + if not isinstance(getattr(artifact, update.field), type(update.value)): + attempts.append( + UpdateAttempt( + field_value=update.value, + error=f"Value is not the right type. Got {type(update.value)} but expected {type(getattr(artifact, update.field))}.", + ) + ) + try: + new_field_value = await generate_artifact_field_update_error_fix( + language_model, artifact, update.field, update.value, conversation, attempts + ) + except Exception: + # Do something here if it errored out. + pass + else: + update = ArtifactUpdate(field=update.field, value=new_field_value) + + # If it's the right type, we're good to go. + else: + good_updates.append(update) + break + + # Failed. + logger.warning(f"Updating field {update.field} has failed too many times. Skipping.") + + return good_updates diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/generate_message.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/generate_message.py new file mode 100644 index 00000000..325f372c --- /dev/null +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/generate_message.py @@ -0,0 +1,102 @@ +import logging +from typing import Any + +from form_filler_skill.guided_conversation.definition import GCDefinition +from openai_client import ( + CompletionError, + add_serializable_data, + create_system_message, + make_completion_args_serializable, + message_content_from_completion, + validate_completion, +) +from pydantic import BaseModel +from skill_library.types import LanguageModel + +from ..artifact import Artifact +from ..message import Conversation + +logger = logging.getLogger(__name__) + +USER_MESSAGE_TEMPLATE = """You are a helpful, thoughtful, and meticulous assistant. You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the end of the conversation, and to ensure a smooth experience for the user. + +This is the schema of the artifact you are completing: +{{ artifact_schema }}{% if context %} + +Here is some additional context about the conversation: +{{ context }}{% endif %} + +Throughout the conversation, you must abide by these rules: +{{ rules }}{% if current_state_description %} + +Here's a description of the conversation flow: +{{ current_state_description }} + +Follow this description, and exercise good judgment about when it is appropriate to deviate.{% endif %} + +You will be provided the history of your conversation with the user up until now and the current state of the artifact. Note that if the value for a field in the artifact is 'Unanswered', it means that the field has not been completed. + +Your job is to respond to the user if they ask a question or make a statement that you need to respond to or if you need to follow-up with the user because the information they provided is incomplete, invalid, ambiguous, or in some way insufficient to complete the artifact. + +For example, if the artifact schema indicates that the "date of birth" field must be in the format "YYYY-MM-DD", but the user has only provided the month and year, you should send a message to the user asking for the day. Likewise, if the user claims that their date of birth is February 30, you should send a message to the user asking for a valid date. If the artifact schema is open-ended (e.g. it asks you to rate how pressing the user's issue is, without specifying rules for doing so), use your best judgment to determine whether you have enough information or you need to continue +probing the user. It's important to be thorough, but also to avoid asking the user for unnecessary information. +""" + + +class ArtifactUpdate(BaseModel): + field: str + value: Any + + +class ArtifactUpdates(BaseModel): + updates: list[ArtifactUpdate] + + +class UpdateAttempt(BaseModel): + field_value: str + error: str + + +async def generate_message( + language_model: LanguageModel, + definition: GCDefinition, + artifact: Artifact, + conversation: Conversation, + max_retries: int = 2, +) -> str: + # Use the language model to generate a response to the user. + + completion_args = { + "model": "gpt-4o", + "messages": [ + create_system_message( + USER_MESSAGE_TEMPLATE, + { + "artifact_schema": artifact.get_schema_for_prompt(), + "context": definition.conversation_context, + "rules": definition.rules, + "current_state_description": definition.conversation_flow, + }, + ), + ], + } + + metadata = {} + logger.debug("Completion call.", extra=add_serializable_data(make_completion_args_serializable(completion_args))) + 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=add_serializable_data({"completion": completion.model_dump()})) + metadata["completion"] = completion.model_dump() + except CompletionError as e: + completion_error = CompletionError(e) + metadata["completion_error"] = completion_error.message + logger.error( + e.message, extra=add_serializable_data({"completion_error": completion_error.body, "metadata": metadata}) + ) + raise completion_error from e + else: + return message_content_from_completion(completion) diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/base_model_llm.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/unneeded/base_model_llm.py similarity index 100% rename from libraries/python/skills/skills/form-filler-skill/form_filler_skill/base_model_llm.py rename to libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/unneeded/base_model_llm.py diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/update_agenda.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/update_agenda.py new file mode 100644 index 00000000..bef8d1b0 --- /dev/null +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/chat_drivers/update_agenda.py @@ -0,0 +1,296 @@ +import logging +from typing import cast + +from form_filler_skill.guided_conversation.agenda import Agenda, AgendaItem +from form_filler_skill.guided_conversation.definition import GCDefinition +from form_filler_skill.guided_conversation.message import Conversation +from form_filler_skill.guided_conversation.resources import ( + GCResource, + ResourceConstraintMode, + ResourceConstraintUnit, + format_resource, +) +from openai_client import ( + CompletionError, + add_serializable_data, + create_system_message, + create_user_message, + make_completion_args_serializable, + validate_completion, +) +from pydantic import ValidationError +from skill_library.types import LanguageModel + +from ..artifact import Artifact +from .fix_agenda_error import fix_agenda_error + +logger = logging.getLogger(__name__) + +GENERATE_AGENDA_TEMPLATE = """You are a helpful, thoughtful, and meticulous assistant. You are conducting a conversation with a user. Your goal is to complete an artifact as thoroughly as possible by the end of the conversation, and to ensure a smooth experience for the user. + +This is the schema of the artifact you are completing: +{{ artifact_schema }}{% if context %} + +Here is some additional context about the conversation: +{{ context }}{% endif %} + +Throughout the conversation, you must abide by these rules: +{{ rules }}{% if current_state_description %} + +Here's a description of the conversation flow: +{{ current_state_description }} + +Follow this description, and exercise good judgment about when it is appropriate to deviate.{% endif %} + +You will be provided the history of your conversation with the user up until now and the current state of the artifact. +Note that if the value for a field in the artifact is 'Unanswered', it means that the field has not been completed. +You need to select the best possible action(s), given the state of the conversation and the artifact. + +Update agenda (required parameters: items) + +- If you need to change your plan for the conversation to make the best use of the remaining turns available to you. Consider how long it usually takes to get the information you need (which is a function of the quality and pace of the user's responses), the number, complexity, and importance of the remaining fields in the artifact, and the number of turns remaining ({{ remaining_resource }}). Based on these factors, you might need to accelerate (e.g. combine several topics) or slow down the conversation (e.g. spread out a topic), in which case you should update the agenda accordingly. Note that skipping an artifact field is NOT a valid way to accelerate the conversation. +- If you do not need to change your plan, just return the list of agenda items as is. +- You must provide an ordered list of items to be completed sequentially, where the first item contains everything you will do in the current turn of the conversation (in addition to updating the agenda). For example, if you choose to send a message to the user asking for their name and medical history, then you would write "ask for name and medical history" as the first item. If you think medical history will take longer than asking for the name, then you would write "complete medical history" as the second item, with an estimate of how many turns you think it will take. Do NOT include items that have already been completed. Items must always represent a conversation topic (corresponding to the "Send message to user" action). Updating the artifact (e.g. "update field X based on the discussion") or terminating the conversation is NOT a valid item. +- The latest agenda was created in the previous turn of the conversation. Even if the total turns in the latest agenda equals the remaining turns, you should still update the agenda if you think the current plan is suboptimal (e.g. the first item was completed, the order of items is not ideal, an item is too broad or not a conversation topic, etc.). +- Each item must have a description and and your best guess for the number of turns required to complete it. Do not provide a range of turns. It is EXTREMELY important that the total turns allocated across all items in the updated agenda (including the first item for the current turn) {{ total_resource_str }} Everything in the agenda should be something you expect to complete in the remaining turns - there shouldn't be any optional "buffer" items. It can be helpful to include the cumulative turns allocated for each item in the agenda to ensure you adhere to this rule, e.g. item 1 = 2 turns (cumulative total = 2), item 2 = 4 turns (cumulative total = 6), etc. +- Avoid high-level items like "ask follow-up questions" - be specific about what you need to do. +- Do NOT include wrap-up items such as "review and confirm all information with the user" (you should be doing this throughout the conversation) or "thank the user for their time". Do NOT repeat topics that have already been sufficiently addressed. {{ ample_time_str }}{% endif %} +""" + + +def _get_termination_instructions(resource: GCResource): + """ + Get the termination instructions for the conversation. This is contingent on the resources mode, + if any, that is available. + + Assumes we're always using turns as the resource unit. + + Args: + resource (GCResource): The resource object. + + Returns: + str: the termination instructions + """ + # Termination condition under no resource constraints + if resource.resource_constraint is None: + return ( + "- You should pick this action as soon as you have completed the artifact to the best of your ability, the" + " conversation has come to a natural conclusion, or the user is not cooperating so you cannot continue the" + " conversation." + ) + + # Termination condition under exact resource constraints + if resource.resource_constraint.mode == ResourceConstraintMode.EXACT: + return ( + "- You should only pick this action if the user is not cooperating so you cannot continue the conversation." + ) + + # Termination condition under maximum resource constraints + elif resource.resource_constraint.mode == ResourceConstraintMode.MAXIMUM: + return ( + "- You should pick this action as soon as you have completed the artifact to the best of your ability, the" + " conversation has come to a natural conclusion, or the user is not cooperating so you cannot continue the" + " conversation." + ) + + else: + logger.error("Invalid resource mode provided.") + return "" + + +async def generate_agenda( + language_model: LanguageModel, + definition: GCDefinition, + chat_history: Conversation, + current_agenda: Agenda, + artifact: Artifact, + resource: GCResource, + max_retries: int = 2, +) -> tuple[Agenda, bool]: + # STEP 1: Generate an updated agenda. + + # If there is a resource constraint and there's more than one turn left, + # include additional constraint instructions. + remaining_resource = resource.remaining_units if resource.remaining_units else 0 + resource_instructions = resource.get_resource_instructions() + if (resource_instructions != "") and (remaining_resource > 1): + match resource.get_resource_mode(): + case ResourceConstraintMode.MAXIMUM: + total_resource_str = f"does not exceed the remaining turns ({remaining_resource})." + ample_time_str = "" + case ResourceConstraintMode.EXACT: + total_resource_str = ( + f"is equal to the remaining turns ({remaining_resource}). Do not leave any turns unallocated." + ) + ample_time_str = ( + "If you have many turns remaining, instead of including wrap-up items or repeating " + "topics, you should include items that increase the breadth and/or depth of the conversation " + 'in a way that\'s directly relevant to the artifact (e.g. "collect additional details about X", ' + '"ask for clarification about Y", "explore related topic Z", etc.).' + ) + case _: + logger.error("Invalid resource mode.") + else: + total_resource_str = "" + ample_time_str = "" + + completion_args = { + "model": "gpt-4o", + "messages": [ + create_system_message( + GENERATE_AGENDA_TEMPLATE, + { + "context": definition.conversation_context, + "artifact_schema": definition.artifact_schema, + "rules": definition.rules, + "current_state_description": definition.conversation_flow, + "show_agenda": True, + "remaining_resource": remaining_resource, + "total_resource_str": total_resource_str, + "ample_time_str": ample_time_str, + "termination_instructions": _get_termination_instructions(resource), + "resource_instructions": resource_instructions, + }, + ), + create_user_message( + ( + "Conversation history:\n" + "{{ chat_history }}\n\n" + "Latest agenda:\n" + "{{ agenda_state }}\n\n" + "Current state of the artifact:\n" + "{{ artifact_state }}" + ), + { + "chat_history": str(chat_history), + "agenda_state": get_agenda_for_prompt(current_agenda), + "artifact_state": artifact.get_artifact_for_prompt(), + }, + ), + ], + "response_format": Agenda, + } + + metadata = {} + logger.debug("Completion call.", extra=add_serializable_data(make_completion_args_serializable(completion_args))) + 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=add_serializable_data({"completion": completion.model_dump()})) + metadata["completion"] = completion.model_dump() + except CompletionError as e: + completion_error = CompletionError(e) + metadata["completion_error"] = completion_error.message + logger.error( + e.message, extra=add_serializable_data({"completion_error": completion_error.body, "metadata": metadata}) + ) + raise completion_error from e + else: + new_agenda = cast(Agenda, completion.choices[0].message.parsed) + new_agenda.resource_constraint_mode = current_agenda.resource_constraint_mode + + # STEP 2: Validate/fix the updated agenda if necessary. + + previous_attempts = [] + while len(previous_attempts) < max_retries: + try: + # Check resource constraints (will raise an error if violated). + if new_agenda.resource_constraint_mode is not None: + check_item_constraints( + new_agenda.resource_constraint_mode, + new_agenda.items, + resource.estimate_remaining_turns(), + ) + + except (ValidationError, ValueError) as e: + # Try again. + if isinstance(e, ValidationError): + error_str = "; ".join([e.get("msg") for e in e.errors()]) + error_str = error_str.replace("; Input should be 'Unanswered'", " or input should be 'Unanswered'") + else: + error_str = str(e) + + # Add it to our list of previous attempts. + previous_attempts.append((str(new_agenda.items), error_str)) + + # Generate a new agenda. + logger.info(f"Attempting to fix the agenda error. Attempt {len(previous_attempts)}.") + llm_formatted_attempts = "\n".join([ + f"Attempt: {attempt}\nError: {error}" for attempt, error in previous_attempts + ]) + possibly_fixed_agenda = await fix_agenda_error(language_model, llm_formatted_attempts, chat_history) + if possibly_fixed_agenda is None: + raise ValueError("Invalid response from the LLM.") + new_agenda = possibly_fixed_agenda + continue + else: + # FIXME: this should be determinded from the LLM response. + is_done = False + logger.info(f"Agenda updated successfully: {get_agenda_for_prompt(new_agenda)}") + return new_agenda, is_done + + logger.error(f"Failed to update agenda after {max_retries} attempts.") + + # Let's keep going anyway. + return current_agenda, False + + +def check_item_constraints( + resource_constraint_mode: ResourceConstraintMode, + items: list[AgendaItem], + remaining_turns: int, +) -> None: + """ + Validates if any constraints were violated while performing the agenda + update. + """ + # The total, proposed allocation of resources. + total_resources = sum([item.resource for item in items]) + + violations = [] + # In maximum mode, the total resources should not exceed the remaining + # turns. + if (resource_constraint_mode == ResourceConstraintMode.MAXIMUM) and (total_resources > remaining_turns): + violations.append( + "The total turns allocated in the agenda " + f"must not exceed the remaining amount ({remaining_turns}); " + f"but the current total is {total_resources}." + ) + + # In exact mode if the total resources were not exactly equal to the + # remaining turns. + if (resource_constraint_mode == ResourceConstraintMode.EXACT) and (total_resources != remaining_turns): + violations.append( + "The total turns allocated in the agenda " + f"must equal the remaining amount ({remaining_turns}); " + f"but the current total is {total_resources}." + ) + + # Check if any item has a resource value of 0. + if any(item.resource <= 0 for item in items): + violations.append("All items must have a resource value greater than 0.") + + # Raise an error if any violations were found. + if len(violations) > 0: + logger.debug(f"Agenda update failed due to the following violations: {violations}.") + raise ValueError(" ".join(violations)) + + +def get_agenda_for_prompt(agenda: Agenda) -> str: + """ + Gets a string representation of the agenda for use in an LLM prompt. + """ + agenda_json = agenda.model_dump() + agenda_items = agenda_json.get("items", []) + if len(agenda_items) == 0: + return "None" + agenda_str = "\n".join([ + f"{i + 1}. [{format_resource(item['resource'], ResourceConstraintUnit.TURNS)}] {item['title']}" + for i, item in enumerate(agenda_items) + ]) + total_resource = format_resource(sum([item["resource"] for item in agenda_items]), ResourceConstraintUnit.TURNS) + agenda_str += f"\nTotal = {total_resource}" + return agenda_str diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/definition.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/definition.py index a8befa22..53933a22 100644 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/definition.py +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/definition.py @@ -1,14 +1,33 @@ from dataclasses import dataclass from typing import Optional +from pydantic import BaseModel + from form_filler_skill.guided_conversation.resources import ResourceConstraint @dataclass -class GCDefinition: - artifact_schema: str +class GCDefinition(BaseModel): + artifact_schema: BaseModel rules: str conversation_flow: Optional[str] conversation_context: str resource_constraint: Optional[ResourceConstraint] - service_id: str = "gc_main" + + +class DefaultArtifact(BaseModel): + # TODO: Implement a guided simple guided conversation that just solicits + # user info. + pass + + +def default_gc_definition(): + # TODO: Implement a guided simple guided conversation that just solicits + # user info. + return GCDefinition( + artifact_schema=DefaultArtifact(), + rules="", + conversation_flow="", + conversation_context="", + resource_constraint=None, + ) diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/guided_conversation_skill.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/guided_conversation_skill.py new file mode 100644 index 00000000..2d384702 --- /dev/null +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/guided_conversation_skill.py @@ -0,0 +1,222 @@ +from typing import Any, Optional + +from events import MessageEvent +from openai_client.chat_driver import ChatDriverConfig +from skill_library import RoutineTypes, Skill, StateMachineRoutine +from skill_library.run_context import RunContext +from skill_library.types import LanguageModel + +from .agenda import Agenda +from .artifact import Artifact +from .chat_drivers.final_artifact_update import final_artifact_update +from .chat_drivers.generate_artifact_updates import generate_artifact_updates +from .chat_drivers.generate_message import generate_message +from .chat_drivers.update_agenda import generate_agenda +from .definition import GCDefinition, default_gc_definition +from .message import Conversation +from .resources import GCResource + +NAME = "guided-conversation" +CLASS_NAME = "GuidedConversationSkill" +DESCRIPTION = "Walks the user through a conversation about gathering info for the creation of an artifact." +DEFAULT_MAX_RETRIES = 3 +INSTRUCTIONS = "You are an assistant." + + +class GuidedConversationSkill(Skill): + def __init__( + self, + chat_driver_config: ChatDriverConfig, + language_model: LanguageModel, + definition: GCDefinition | None = None, + agenda: Optional[Agenda] = None, + artifact: Optional[Artifact] = None, + resource: Optional[GCResource] = None, + ) -> None: + self.language_model = language_model + + if definition is None: + definition = default_gc_definition() + + # Persis these in a drive used just for the skill. + self.definition = definition + self.agenda = agenda + self.artifact = artifact + self.resource = resource + self.chat_history = Conversation() + + # Add the skill routines. + routines: list[RoutineTypes] = [ + self.conversation_routine(), + ] + + # Configure the skill's chat driver. This is just used for testing the + # skill out directly, but won't be exposed in the assistant. + chat_driver_config.instructions = INSTRUCTIONS + + # Initialize the skill! + super().__init__( + name=NAME, + description=DESCRIPTION, + chat_driver_config=chat_driver_config, + actions=[], + routines=routines, + ) + + ################################## + # Routines + ################################## + + def conversation_routine(self) -> StateMachineRoutine: + return StateMachineRoutine( + name="conversation", + description="Run a guided conversation.", + init_function=self.conversation_init_function, + step_function=self.conversation_step_function, + skill=self, + ) + + async def conversation_init_function(self, context: RunContext, vars: dict[str, Any] | None = None): + if vars is None: + return + + definition = GCDefinition(**vars["definition"]) + + # We can put all this data in the routine frame, or we could also put it + # in the skill drive. All of this intermediate state can just go in the + # frame. Only the final artifact needs to be saved to the drive. + async with context.stack_frame_state() as state: + state["definition"] = definition + state["resource"] = GCResource(definition.resource_constraint).to_data() + state["conversation"] = Conversation() + state["agenda"] = Agenda() + state["artifact"] = Artifact(**definition.artifact_schema.model_dump()) + + # For guided conversation, we want to go ahead and run the first step. + await self.conversation_step_function(context) + + async def conversation_step_function( + self, + context: RunContext, + message: Optional[str] = None, + ) -> tuple[bool, Artifact]: + """ + The original GC code is a bit more complex than this, but this is a + simplified version of the code. + + Original: + + ``` while not max_decision_retries: + plan = kernel_function_generate_plan success, plugins, + terminal_plugins = execute_plan(plan) if not + success: + max_decision_retries += 1 continue + + # run each tool: update_artifact, update_agenda, send_msg, end_conv + ``` + + Note that in this flow, the "plan" is like an action chooser and the + plugins are like actions. We don't need any of that part because we + actually just want to run an artifact update and an agenda update on + every step. + + However, we do need to have the model generate which agenda items to + update and which artifact items to update. + + The flow: + - add user message to conversation + - while not max_decision_retries: + - run function/conversation_plan template to produce a conversation plan (aka reasoning): + - Update agenda (required parameters: items) + - Update artifact fields (required parameters: field, value) + - Send message to user (required parameters: message) + - End conversation (required parameters: None) + + - run function/execution template to produce the tool calls w/ args for all the things. + - Parse result with gc.execute_plan method into sucess, plugins, terminal_plugins + - try again if not a success + + - update artifact with tool calls (update the actual data object) + - if error, try to fix with plugins/artifact/_fix_artifact_error (ARTIFACT_ERROR_CORRECTION_SYSTEM_TEMPLATE) + - update agenda with tool calls (update the actual data object) + - if error, try to fix with plugins/agenda/_fix_agenda_error (AGENDA_ERROR_CORRECTION_SYSTEM_TEMPLATE) + - if user message: return user message with tool calls and increment resource (end turn) + - if end conversation: + - run functions/final_update_plan to produce a final conversation plan that only does update artifact tool calls + - run function/execution template to with only agenda update tools calls to produce tool calls w/ args + - run tool calls to update artifact, update all messages + - increment resource and return final message (end turn, end convo) + - increment resource and return error message (inc resource) + + Revised flow will be: + if not first time: + - updates = generate_artifact_updates + - apply_updates(updates) + - agenda, done = generate_new_agenda + - if done: + - artifact = generate_final_artifact + - else: + - generate_message + """ + + async with context.stack_frame_state() as state: + definition = GCDefinition(**state["definition"]) + resource = GCResource(**state["resource"]) + conversation = Conversation(**state["conversation"]) + agenda = Agenda(**state["agenda"]) + artifact: Artifact | None = Artifact(**state["artifact"]) + + state["chat_history"] += message + + # Update artifact, if we have one (we won't on first run). + if artifact: + try: + # This function should generate VALID updates. + artifact_updates = await generate_artifact_updates( + self.language_model, definition, artifact, conversation, max_retries=DEFAULT_MAX_RETRIES + ) + except Exception: + # DO something with this error. + pass + else: + # Apply the updates to the artifact. + for update in artifact_updates: + artifact.__setattr__(update.field, update.value) + state["artifact"] = artifact + + # Update agenda. + try: + agenda, is_done = await generate_agenda( + self.language_model, + definition, + conversation, + agenda, + artifact, + resource, + max_retries=DEFAULT_MAX_RETRIES, + ) + state["agenda"] = agenda + context.emit(MessageEvent(message="Agenda updated")) + except Exception: + # TODO: DO something with this error. + return False, artifact + else: + # If the agenda generation says we are done, generate the final artifact. + if is_done: + artifact = await final_artifact_update(self.language_model, definition, conversation, artifact) + context.emit(MessageEvent(session_id=context.session_id, message="Conversation complete!")) + return True, artifact + + # If we are not done, use the agenda to ask the user for whatever is next. + else: + message = await generate_message( + self.language_model, definition, artifact, conversation, max_retries=DEFAULT_MAX_RETRIES + ) + context.emit(MessageEvent(session_id=context.session_id, message=message)) + return False, artifact + + ################################## + # Actions + ################################## + + # None, yet. diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/message.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/message.py similarity index 95% rename from libraries/python/skills/skills/form-filler-skill/form_filler_skill/message.py rename to libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/message.py index 4b128656..a59728d0 100644 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/message.py +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/message.py @@ -2,6 +2,7 @@ from attr import dataclass from openai.types.chat import ChatCompletionMessageParam +from pydantic import BaseModel class ConversationMessageType(StrEnum): @@ -10,7 +11,7 @@ class ConversationMessageType(StrEnum): REASONING = "reasoning" -class Message: +class Message(BaseModel): def __init__( self, chat_completion_message_param: ChatCompletionMessageParam, @@ -23,7 +24,7 @@ def __init__( @dataclass -class Conversation: +class Conversation(BaseModel): messages: list[Message] = [] def exclude(self, types: list[ConversationMessageType]) -> list[Message]: diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/resources.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/resources.py index f39d0253..26cccfdd 100644 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/resources.py +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/resources.py @@ -5,6 +5,7 @@ import math import time from enum import StrEnum +from typing import Optional from pydantic import BaseModel @@ -63,6 +64,23 @@ def format_resource(quantity: float, unit: ResourceConstraintUnit) -> str: return f"{quantity} {unit.value}" +class GCResourceData(BaseModel): + """ + Data class for GCResource. This class is used to store the data of the + GCResource class. + + Args: + turn_number (int): The number of turns that have elapsed. + remaining_units (float): The remaining units of the resource constraint. + elapsed_units (float): The elapsed units of the resource constraint. + """ + + resource_constraint: Optional[ResourceConstraint] + turn_number: int + elapsed_units: float + remaining_units: float + + class GCResource: """ Resource constraints for the GuidedConversation agent. This class is used to @@ -80,21 +98,50 @@ class GCResource: def __init__( self, - resource_constraint: ResourceConstraint | None, + resource_constraint: ResourceConstraint | None = None, + turn_number: int = 0, + elapsed_units: float = 0, + remaining_units: float | None = None, initial_seconds_per_turn: int = 120, ): self.resource_constraint: ResourceConstraint | None = resource_constraint - self.initial_seconds_per_turn: int = initial_seconds_per_turn + self.turn_number = turn_number - self.turn_number: int = 0 + # This is only used on the first turn. + self.initial_seconds_per_turn: int = initial_seconds_per_turn if resource_constraint is not None: - self.elapsed_units = 0 - self.remaining_units = resource_constraint.quantity + # If a resource constraint is given, then the initial remaining_units + # should be the quantity of the resource constraint. + + self.elapsed_units = elapsed_units + if remaining_units is None: + self.remaining_units = resource_constraint.quantity + else: + self.remaining_units = remaining_units else: - self.elapsed_units = 0 + # If there is no resource constraint, then these should all be zero. + self.elapsed_units = elapsed_units self.remaining_units = 0 + @classmethod + def from_data(cls, data: GCResourceData, initial_seconds_per_turn: int = 120) -> "GCResource": + return cls( + resource_constraint=data.resource_constraint, + turn_number=data.turn_number, + elapsed_units=data.elapsed_units, + remaining_units=data.remaining_units, + initial_seconds_per_turn=120, + ) + + def to_data(self) -> GCResourceData: + return GCResourceData( + resource_constraint=self.resource_constraint, + turn_number=self.turn_number, + elapsed_units=self.elapsed_units, + remaining_units=self.remaining_units, + ) + def start_resource(self) -> None: """To be called at the start of a conversation turn""" if self.resource_constraint is not None and ( @@ -145,21 +192,11 @@ def get_elapsed_turns(self, formatted_repr: bool = False) -> str | int: else: return self.turn_number - def get_remaining_turns(self, formatted_repr: bool = False) -> str | int: + def estimate_remaining_turns_formatted(self) -> str: """ - Get the number of remaining turns. - - Args: - formatted_repr (bool): If true, return a formatted string - representation of the remaining turns. - - Returns: - str | int: The description/number of remaining turns. + Get the number of remaining turns in a formatted string. """ - if formatted_repr: - return format_resource(self.estimate_remaining_turns(), ResourceConstraintUnit.TURNS) - else: - return self.estimate_remaining_turns() + return format_resource(self.estimate_remaining_turns(), ResourceConstraintUnit.TURNS) def estimate_remaining_turns(self) -> int: """ @@ -215,12 +252,8 @@ def get_resource_instructions(self) -> str: if self.resource_constraint is None: return "" - formatted_elapsed_resource = format_resource( - self.elapsed_units, ResourceConstraintUnit.TURNS - ) - formatted_remaining_resource = format_resource( - self.remaining_units, ResourceConstraintUnit.TURNS - ) + formatted_elapsed_resource = format_resource(self.elapsed_units, ResourceConstraintUnit.TURNS) + formatted_remaining_resource = format_resource(self.remaining_units, ResourceConstraintUnit.TURNS) # if the resource quantity is anything other than 1, the resource unit should be plural (e.g. "minutes" instead of "minute") is_plural_elapsed = self.elapsed_units != 1 diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/tests/test_integration.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/tests/test_integration.py new file mode 100644 index 00000000..0ba8c489 --- /dev/null +++ b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation/tests/test_integration.py @@ -0,0 +1,21 @@ +def test_integration(): + # TODO: This is the next piece of work... getting this all working. + + # language_model = "gpt-3.5-turbo" + + # definition = GCDefinition( + # artifact_schema="schema", + # rules="rules", + # conversation_flow="flow", + # conversation_context="context", + # resource_constraint=None, + # ) + + # skill = GuidedConversationSkill(language_model=language_model, definition=definition) + + # runContext = RunContext() + + # skill.conversation_init_function(context=runContext) + # finished, artifact = skill.conversation_step_function(runContext, "Hello!") + + pass diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation_skill.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation_skill.py deleted file mode 100644 index f17aa673..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/guided_conversation_skill.py +++ /dev/null @@ -1,154 +0,0 @@ -# flake8: noqa -# ruff: noqa - -from typing import Any, Optional - -from openai_client.chat_driver import ChatDriverConfig -from events import BaseEvent, MessageEvent -from skill_library import EmitterType, FunctionRoutine, RoutineTypes, Skill -from skill_library.run_context import RunContext - -from form_filler_skill.agenda import Agenda -from form_filler_skill.artifact import Artifact -from form_filler_skill.definition import GCDefinition -from form_filler_skill.message import Conversation -from form_filler_skill.resources import GCResource - -from .chat_drivers.final_update import final_update -from .chat_drivers.unneeded.execute_reasoning import execute_reasoning -from .chat_drivers.update_agenda import update_agenda - -NAME = "guided-conversation" -CLASS_NAME = "GuidedConversationSkill" -DESCRIPTION = "Walks the user through a conversation about gathering info for the creation of an artifact." -DEFAULT_MAX_RETRIES = 3 -INSTRUCTIONS = "You are an assistant." - - -class GuidedConversationSkill(Skill): - def __init__( - self, - chat_driver_config: ChatDriverConfig, - emit: EmitterType, - agenda: Agenda, - artifact: Artifact, - resource: GCResource, - ) -> None: - # Put all functions in a group. We are going to use all these as (1) - # skill actions, but also as (2) chat functions and (3) chat commands. - # You can also put them in separate lists if you want to differentiate - # between these. - functions = [ - self.update_agenda, - self.execute_reasoning, - self.final_update, - ] - - # Add some skill routines. - routines: list[RoutineTypes] = [ - self.conversation_routine(), - ] - - # Configure the skill's chat driver. - # TODO: change where this is from. - self.openai_client = chat_driver_config.openai_client - chat_driver_config.instructions = INSTRUCTIONS - chat_driver_config.commands = functions - chat_driver_config.functions = functions - - # TODO: Persist these. They should be saved in the skills state by - # session_id. - self.agenda = agenda - self.artifact = artifact - self.resource = resource - self.chat_history = Conversation() - - self.emit = emit - - # Initialize the skill! - super().__init__( - name=NAME, - description=DESCRIPTION, - chat_driver_config=chat_driver_config, - skill_actions=functions, - routines=routines, - ) - - ################################## - # Routines - ################################## - - def conversation_routine(self) -> FunctionRoutine: - return FunctionRoutine( - name="conversation", - description="Run a guided conversation.", - init_function=self.conversation_init_function, - step_function=self.conversation_step_function, - skill=self, - ) - - async def conversation_init_function(self, context: RunContext, vars: dict[str, Any] | None = None): - if vars is None: - return - state = {"definition": vars["definition"]} - await context.routine_stack.set_current_state(state) - await self.conversation_step_function(context) - - async def conversation_step_function( - self, - context: RunContext, - message: Optional[str] = None, - ): - # TODO: Where is this conversation maintained? - # FIXME: None of this works. WIP. - frame = await context.routine_stack.peek() - state = frame.state if frame else {} - definition = GCDefinition(**state["definition"]) - while True: - match state["mode"]: - case None: - state["mode"] = "init" - case "init": - state["chat_history"] = [] - agenda, done = await self.update_agenda("") - if done: - state["mode"] = "finalize" - state["mode"] = "conversation" - self.emit(MessageEvent(message="Agenda updated")) - return - case "conversation": - state["chat_history"] += message - # state["artifact"] = self.update_artifact(context) - agenda, done = await self.update_agenda("") - if agenda: - state["agenda"] = agenda - if done: - state["mode"] = "finalize" - # await self.message_user(context, agenda) # generates the next message - return - case "finalize": - # self.final_update() # Generates the final message. - state["state"] = "done" - # runner.send(message) - return - case "done": - return state["artifact"] - - ################################## - # Actions - ################################## - - async def update_agenda(self, items: str) -> tuple[Agenda, bool]: - return await update_agenda( - self.openai_client, - items, - self.chat_history, - self.agenda, - self.resource, - ) - - async def execute_reasoning(self, context: RunContext, reasoning: str) -> BaseEvent: - return await execute_reasoning(self.openai_client, reasoning, self.artifact.get_schema_for_prompt()) - - async def final_update(self, context: RunContext, definition: GCDefinition): - await final_update(self.openai_client, definition, self.chat_history, self.artifact) diff --git a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/resources.py b/libraries/python/skills/skills/form-filler-skill/form_filler_skill/resources.py deleted file mode 100644 index 7d0b5f58..00000000 --- a/libraries/python/skills/skills/form-filler-skill/form_filler_skill/resources.py +++ /dev/null @@ -1,275 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - - -import logging -import math -import time -from enum import StrEnum - -from pydantic import BaseModel - -logger = logging.getLogger(__name__) - - -class ResourceConstraintUnit(StrEnum): - """ - Choose the unit of the resource constraint. Seconds and Minutes are - real-time and will be impacted by the latency of the model. - """ - - SECONDS = "seconds" - MINUTES = "minutes" - TURNS = "turns" - - -class ResourceConstraintMode(StrEnum): - """ - Choose how the agent should use the resource. - - Maximum: is an upper bound, i.e. the agent can end the conversation before - the resource is exhausted. - - Exact: The agent should aim to use exactly the given amount of the resource. - """ - - MAXIMUM = "maximum" - EXACT = "exact" - - -class ResourceConstraint(BaseModel): - """ - A structured representation of the resource constraint for the - GuidedConversation agent. - """ - - quantity: float | int - unit: ResourceConstraintUnit - mode: ResourceConstraintMode - - class Config: - arbitrary_types_allowed = True - - -def format_resource(quantity: float, unit: ResourceConstraintUnit) -> str: - """ - Get formatted string for a given quantity and unit (e.g. 1 second, 20 - seconds) - """ - if unit != ResourceConstraintUnit.TURNS: - quantity = round(quantity, 1) - if quantity == 1: - return f"{quantity} {unit.value.rstrip('s')}" - else: - return f"{quantity} {unit.value}" - - -class GCResource: - """ - Resource constraints for the GuidedConversation agent. This class is used to - keep track of the resource constraints. If resource_constraint is None, then - the agent can continue indefinitely. This also means that no agenda will be - created for the conversation. - - Args: - resource_constraint (ResourceConstraint | None): The resource constraint - for the conversation. - - initial_seconds_per_turn (int): The initial number of seconds per turn. - Defaults to 120 seconds. - """ - - def __init__( - self, - resource_constraint: ResourceConstraint | None, - initial_seconds_per_turn: int = 120, - ): - self.resource_constraint: ResourceConstraint | None = resource_constraint - self.initial_seconds_per_turn: int = initial_seconds_per_turn - - self.turn_number: int = 0 - - if resource_constraint is not None: - self.elapsed_units = 0 - self.remaining_units = resource_constraint.quantity - else: - self.elapsed_units = 0 - self.remaining_units = 0 - - def start_resource(self) -> None: - """To be called at the start of a conversation turn""" - if self.resource_constraint is not None and ( - self.resource_constraint.unit == ResourceConstraintUnit.SECONDS - or self.resource_constraint.unit == ResourceConstraintUnit.MINUTES - ): - self.start_time = time.time() - - def increment_resource(self) -> None: - """Increment the resource counter by one turn.""" - if self.resource_constraint is not None: - match self.resource_constraint.unit: - case ResourceConstraintUnit.SECONDS: - self.elapsed_units += time.time() - self.start_time - self.remaining_units = self.resource_constraint.quantity - self.elapsed_units - case ResourceConstraintUnit.MINUTES: - self.elapsed_units += (time.time() - self.start_time) / 60 - self.remaining_units = self.resource_constraint.quantity - self.elapsed_units - case ResourceConstraintUnit.TURNS: - self.elapsed_units += 1 - self.remaining_units -= 1 - case _: - raise ValueError("Invalid resource unit provided.") - self.turn_number += 1 - - def get_resource_mode(self) -> ResourceConstraintMode | None: - """ - Get the mode of the resource constraint. - """ - if self.resource_constraint is None: - return None - return self.resource_constraint.mode - - def get_elapsed_turns(self, formatted_repr: bool = False) -> str | int: - """ - Get the number of elapsed turns. - - Args: - formatted_repr (bool): If true, return a formatted string - representation of the elapsed turns. If false, return an integer. - Defaults to False. - - Returns: - str | int: The description/number of elapsed turns. - """ - if formatted_repr: - return format_resource(self.turn_number, ResourceConstraintUnit.TURNS) - else: - return self.turn_number - - def estimate_remaining_turns_formatted(self) -> str: - """ - Get the number of remaining turns in a formatted string. - """ - return format_resource(self.estimate_remaining_turns(), ResourceConstraintUnit.TURNS) - - def estimate_remaining_turns(self) -> int: - """ - Estimate the remaining turns based on the resource constraint, thereby - translating certain resource units (e.g. seconds, minutes) into turns. - """ - if self.resource_constraint is None: - logger.error( - "Resource constraint is not set, so turns cannot be estimated using function estimate_remaining_turns" - ) - raise ValueError( - "Resource constraint is not set. Do not try to call this method without a resource constraint." - ) - - match self.resource_constraint.unit: - case ResourceConstraintUnit.MINUTES: - if self.turn_number == 0: - time_per_turn = self.initial_seconds_per_turn - else: - time_per_turn = (self.elapsed_units * 60) / self.turn_number - time_per_turn /= 60 - remaining_turns = self.remaining_units / time_per_turn - if remaining_turns < 1: - return math.ceil(remaining_turns) - else: - return math.floor(remaining_turns) - - case ResourceConstraintUnit.SECONDS: - if self.turn_number == 0: - time_per_turn = self.initial_seconds_per_turn - else: - time_per_turn = self.elapsed_units / self.turn_number - remaining_turns = self.remaining_units / time_per_turn - if remaining_turns < 1: - return math.ceil(remaining_turns) - else: - return math.floor(remaining_turns) - - case ResourceConstraintUnit.TURNS: - return int(self.resource_constraint.quantity - self.turn_number) - - case _: - raise ValueError("Invalid resource unit provided.") - - def get_resource_instructions(self) -> str: - """Get the resource instructions for the conversation. - - Assumes we're always using turns as the resource unit. - - Returns: - str: the resource instructions - """ - if self.resource_constraint is None: - return "" - - formatted_elapsed_resource = format_resource( - self.elapsed_units, ResourceConstraintUnit.TURNS - ) - formatted_remaining_resource = format_resource( - self.remaining_units, ResourceConstraintUnit.TURNS - ) - - # if the resource quantity is anything other than 1, the resource unit should be plural (e.g. "minutes" instead of "minute") - is_plural_elapsed = self.elapsed_units != 1 - is_plural_remaining = self.remaining_units != 1 - - if self.elapsed_units > 0: - resource_instructions = f"So far, {formatted_elapsed_resource} {'have' if is_plural_elapsed else 'has'} elapsed since the conversation began. " - else: - resource_instructions = "" - - if self.resource_constraint.mode == ResourceConstraintMode.EXACT: - exact_mode_instructions = ( - f"There {'are' if is_plural_remaining else 'is'} {formatted_remaining_resource} " - "remaining (including this one) - the conversation will automatically terminate " - "when 0 turns are left. You should continue the conversation until it is " - "automatically terminated. This means you should NOT preemptively end the " - 'conversation, either explicitly (by selecting the "End conversation" action) ' - "or implicitly (e.g. by telling the user that you have all required information " - "and they should wait for the next step). Your goal is not to maximize efficiency " - "(i.e. complete the artifact as quickly as possible then end the conversation), " - "but rather to make the best use of ALL remaining turns available to you" - ) - - if is_plural_remaining: - resource_instructions += f"""{exact_mode_instructions}. This will require you to plan your actions carefully using the agenda: you want to avoid the situation where you have to pack too many topics into the final turns because you didn't account for them earlier, \ -or where you've rushed through the conversation and all fields are completed but there are still many turns left.""" - - # special instruction for the final turn (i.e. 1 remaining) in exact mode - else: - resource_instructions += f"""{exact_mode_instructions}, including this one. Therefore, you should use this turn to ask for any remaining information needed to complete the artifact, \ - or, if the artifact is already completed, continue to broaden/deepen the discussion in a way that's directly relevant to the artifact. Do NOT indicate to the user that the conversation is ending.""" - - elif self.resource_constraint.mode == ResourceConstraintMode.MAXIMUM: - resource_instructions += f"""You have a maximum of {formatted_remaining_resource} (including this one) left to complete the conversation. \ -You can decide to terminate the conversation at any point (including now), otherwise the conversation will automatically terminate when 0 turns are left. \ -You will need to plan your actions carefully using the agenda: you want to avoid the situation where you have to pack too many topics into the final turns because you didn't account for them earlier.""" - - else: - logger.error("Invalid resource mode provided.") - - return resource_instructions - - def to_json(self) -> dict: - return { - "turn_number": self.turn_number, - "remaining_units": self.remaining_units, - "elapsed_units": self.elapsed_units, - } - - @classmethod - def from_json( - cls, - json_data: dict, - ) -> "GCResource": - gc_resource = cls( - resource_constraint=None, - initial_seconds_per_turn=120, - ) - gc_resource.turn_number = json_data["turn_number"] - gc_resource.remaining_units = json_data["remaining_units"] - gc_resource.elapsed_units = json_data["elapsed_units"] - return gc_resource diff --git a/libraries/python/skills/skills/posix-skill/posix_skill/posix_skill.py b/libraries/python/skills/skills/posix-skill/posix_skill/posix_skill.py index 571bfb51..347c6465 100644 --- a/libraries/python/skills/skills/posix-skill/posix_skill/posix_skill.py +++ b/libraries/python/skills/skills/posix-skill/posix_skill/posix_skill.py @@ -53,7 +53,7 @@ def __init__( name=NAME, description=DESCRIPTION, chat_driver_config=chat_driver_config, - skill_actions=functions, + actions=functions, routines=routines, ) diff --git a/libraries/python/skills/skills/prospector-skill/prospector_skill/skill.py b/libraries/python/skills/skills/prospector-skill/prospector_skill/skill.py index 13046820..b66afc0b 100644 --- a/libraries/python/skills/skills/prospector-skill/prospector_skill/skill.py +++ b/libraries/python/skills/skills/prospector-skill/prospector_skill/skill.py @@ -38,7 +38,7 @@ def __init__( name=NAME, description=DESCRIPTION, chat_driver_config=chat_driver_config, - skill_actions=actions, + actions=actions, routines=routines, ) diff --git a/libraries/python/skills/skills/skill-template/your_skill/skill.py b/libraries/python/skills/skills/skill-template/your_skill/skill.py index 95a22957..99d17d8c 100644 --- a/libraries/python/skills/skills/skill-template/your_skill/skill.py +++ b/libraries/python/skills/skills/skill-template/your_skill/skill.py @@ -35,7 +35,7 @@ def __init__( name=NAME, description=DESCRIPTION, chat_driver_config=chat_driver_config, - skill_actions=actions, + actions=actions, routines=routines, )