diff --git a/.github/actions/generate-schema/action.yml b/.github/actions/generate-schema/action.yml new file mode 100644 index 0000000..4b2447d --- /dev/null +++ b/.github/actions/generate-schema/action.yml @@ -0,0 +1,17 @@ +name: Generate Config Schema +description: Generate the JSON schema for operagents config + +inputs: + output-path: + description: Output path for the generated schema + required: false + default: "operagents/config/operagents.schema.json" + +runs: + using: "composite" + steps: + - name: Generate + run: | + poetry run python ./scripts/generate_config_schema.py {{ inputs.output-path }} + echo "output-path={{ inputs.output-path }}" >> "$GITHUB_OUTPUT" + shell: bash diff --git a/.github/actions/setup-python/action.yml b/.github/actions/setup-python/action.yml new file mode 100644 index 0000000..515d089 --- /dev/null +++ b/.github/actions/setup-python/action.yml @@ -0,0 +1,24 @@ +name: Setup Python +description: Setup Python + +inputs: + python-version: + description: Python version + required: false + default: "3.10" + +runs: + using: "composite" + steps: + - name: Install poetry + run: pipx install poetry + shell: bash + + - uses: actions/setup-python@v4 + with: + python-version: ${{ inputs.python-version }} + architecture: "x64" + cache: "poetry" + + - run: poetry install + shell: bash diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml new file mode 100644 index 0000000..ad28155 --- /dev/null +++ b/.github/workflows/gh-pages.yml @@ -0,0 +1,60 @@ +name: GitHub Pages + +on: + push: + branches: + - master + pull_request: + +jobs: + generate-config-schema: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python environment + uses: ./.github/actions/setup-python + + - name: Generate + id: generate-schema + uses: ./.github/actions/generate-schema + + - name: Upload + uses: actions/upload-artifact@v2 + with: + name: operagents-config-schema + path: ${{ steps.generate-schema.outputs.output-path }} + retention-days: 1 + + build: + runs-on: ubuntu-latest + needs: generate-config-schema + steps: + - uses: actions/checkout@v4 + with: + ref: gh-pages + fetch-depth: 1 + + - name: Download Schema + uses: actions/download-artifact@v2 + with: + name: operagents-config-schema + path: schemas/ + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v3 + + deploy: + runs-on: ubuntu-latest + needs: build + if: ${{ github.event_name == 'push' }} + permissions: + pages: write + id-token: write + environment: + name: website + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy GitHub Pages site + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index ef78cd5..221662c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .idea .vscode +operagents/config/*.schema.json # Created by https://www.toptal.com/developers/gitignore/api/python,node,visualstudiocode,jetbrains,macos,windows,linux # Edit at https://www.toptal.com/developers/gitignore?templates=python,node,visualstudiocode,jetbrains,macos,windows,linux @@ -143,7 +144,6 @@ fabric.properties # Icon must end with two \r Icon - # Thumbnails ._* diff --git a/examples/chatbot/config.yaml b/examples/chatbot/config.yaml index d58a821..563882c 100644 --- a/examples/chatbot/config.yaml +++ b/examples/chatbot/config.yaml @@ -5,28 +5,36 @@ agents: backend: type: user system_template: "" - user_template: "" + user_template: |- + {% for event in timeline.past_events(agent) -%} + {% if event.type_ == "act" -%} + [Scene {{ event.scene.name}}] {{ event.character.agent_name }}({{ event.character.name }}): {{ event.content }} + {%- endif %} + {%- endfor %} John: backend: type: openai model: gpt-3.5-turbo-16k-0613 + temperature: 0.3 system_template: |- Your name is {{ agent.name }}. Current scene is {{ timeline.current_scene.name }}. {% if timeline.current_scene.description -%} {{ timeline.current_scene.description }} - {%- endif %} - You are acting as {{ timeline.current_character.agent_name }}. + {%- endif -%} + You are acting as {{ timeline.current_character.name }}. {% if timeline.current_character.description -%} {{ timeline.current_character.description }} - {%- endif %} - Please continue the conversation on behalf of {{ agent.name }} based on your known information. + {%- endif -%} user_template: |- + Here is your chat history: {% for event in timeline.past_events(agent) -%} {% if event.type_ == "act" -%} [Scene {{ event.scene.name}}] {{ event.character.agent_name }}({{ event.character.name }}): {{ event.content }} {%- endif %} {%- endfor %} + Please continue the conversation on behalf of {{ agent.name }}({{ timeline.current_character.name }}) based on your known information and make your answer appear as natural and coherent as possible. + Please answer directly what you want to say and keep your reply as concise as possible. opening_scene: talking diff --git a/examples/chatbot/run.py b/examples/chatbot/run.py deleted file mode 100644 index c38b4bf..0000000 --- a/examples/chatbot/run.py +++ /dev/null @@ -1,19 +0,0 @@ -import asyncio -from pathlib import Path - -import yaml - -from operagents import Opera, OperagentsConfig - -config = OperagentsConfig.model_validate( - yaml.safe_load(Path("config.json").read_text(encoding="utf-8")) -) -opera = Opera.from_config(config) - - -async def main(): - await opera.run() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/operagents/agent/__init__.py b/operagents/agent/__init__.py index 3c5ed36..5c5ed4a 100644 --- a/operagents/agent/__init__.py +++ b/operagents/agent/__init__.py @@ -11,8 +11,8 @@ from operagents.config import AgentConfig, TemplateConfig if TYPE_CHECKING: - from operagents.backend import Backend from operagents.timeline import Timeline + from operagents.backend import Backend, Message from operagents.timeline.event import TimelineEvent @@ -29,7 +29,7 @@ class Agent: user_template: TemplateConfig = field(kw_only=True) """The user template to use for generating text.""" - chat_history: list = field(default_factory=list, kw_only=True) + chat_history: list["Message"] = field(default_factory=list, kw_only=True) """The history of the agent self's chat messages.""" def __post_init__(self): @@ -49,13 +49,13 @@ def from_config(cls, name: str, config: AgentConfig) -> Self: async def act(self, timeline: "Timeline") -> "TimelineEvent": """Make the agent act.""" - system_message = await self.system_renderer.render_async( - agent=self, timeline=timeline - ) - new_message = await self.user_renderer.render_async( - agent=self, timeline=timeline - ) - messages = [ + system_message = ( + await self.system_renderer.render_async(agent=self, timeline=timeline) + ).strip() + new_message = ( + await self.user_renderer.render_async(agent=self, timeline=timeline) + ).strip() + messages: list["Message"] = [ { "role": "system", "content": system_message, @@ -66,9 +66,11 @@ async def act(self, timeline: "Timeline") -> "TimelineEvent": "content": new_message, }, ] - await self.logger.adebug("Acting", messages=messages) + await self.logger.adebug( + "Acting", character=timeline.current_character, messages=messages + ) response = await self.backend.generate(messages) - await self.logger.ainfo(response) + await self.logger.ainfo(response, character=timeline.current_character) self.chat_history.append( { @@ -88,3 +90,6 @@ async def act(self, timeline: "Timeline") -> "TimelineEvent": scene=timeline.current_scene, content=response, ) + + # TODO: Implement this + async def observe(self, timeline: "Timeline"): ... diff --git a/operagents/backend/__init__.py b/operagents/backend/__init__.py index 5c27889..2d17de8 100644 --- a/operagents/backend/__init__.py +++ b/operagents/backend/__init__.py @@ -2,8 +2,12 @@ from operagents.utils import get_all_subclasses, resolve_dot_notation from ._base import Backend as Backend +from ._base import Message as Message from .user import UserBackend as UserBackend +from ._base import UserMessage as UserMessage +from ._base import SystemMessage as SystemMessage from .openai import OpenAIBackend as OpenAIBackend +from ._base import AssistantMessage as AssistantMessage all_backend_types: dict[str, type[Backend]] = { b.type_: b for b in get_all_subclasses(Backend) diff --git a/operagents/backend/_base.py b/operagents/backend/_base.py index 8bf6229..93af5b2 100644 --- a/operagents/backend/_base.py +++ b/operagents/backend/_base.py @@ -1,11 +1,61 @@ import abc -from typing import ClassVar +from typing import Literal, ClassVar, TypeAlias +from typing_extensions import Required, TypedDict + + +class Function(TypedDict): + name: str + """The name of the function to call.""" + arguments: str + """ + The arguments to call the function with, as generated by the model in JSON + format. Note that the model does not always generate valid JSON, and may + hallucinate parameters not defined by your function schema. Validate the + arguments in your code before calling your function. + """ + + +class MessageToolCall(TypedDict): + type: Literal["function"] + """The type of the tool. Currently, only `function` is supported.""" + id: str + """The ID of the tool call.""" + function: Function + """The function that the model called.""" + + +class SystemMessage(TypedDict): + role: Literal["system"] + """The role of the messages author, in this case `system`.""" + content: str + """The contents of the system message.""" + + +class UserMessage(TypedDict): + role: Literal["user"] + """The role of the messages author, in this case `user`.""" + content: str + """The contents of the user message.""" + + +class AssistantMessage(TypedDict, total=False): + role: Required[Literal["assistant"]] + """The role of the messages author, in this case `assistant`.""" + content: str | None + """The contents of the assistant message. + + Required unless `tool_calls` is specified. + """ + tool_calls: list[MessageToolCall] + """The tool calls generated by the model, such as function calls.""" + + +Message: TypeAlias = SystemMessage | UserMessage | AssistantMessage class Backend(abc.ABC): type_: ClassVar[str] - # TODO: messages @abc.abstractmethod - async def generate(self, messages: list) -> str: + async def generate(self, messages: list[Message]) -> str: raise NotImplementedError diff --git a/operagents/backend/openai.py b/operagents/backend/openai.py index 53b3fa3..5852d78 100644 --- a/operagents/backend/openai.py +++ b/operagents/backend/openai.py @@ -2,23 +2,25 @@ import openai -from ._base import Backend +from ._base import Backend, Message class OpenAIBackend(Backend): type_ = "openai" - def __init__(self, model: str) -> None: + def __init__(self, model: str, temperature: float | None = None) -> None: super().__init__() self.client = openai.AsyncOpenAI() self.model: str = model + self.temperature: float | None = temperature @override - async def generate(self, messages: list) -> str: - # TODO: messages + async def generate(self, messages: list[Message]) -> str: response = await self.client.chat.completions.create( - model=self.model, messages=messages + model=self.model, + temperature=self.temperature, + messages=messages, # type: ignore ) reply = response.choices[0].message.content if reply is None: diff --git a/operagents/backend/user.py b/operagents/backend/user.py index 33adacb..659b4c1 100644 --- a/operagents/backend/user.py +++ b/operagents/backend/user.py @@ -1,11 +1,20 @@ from typing_extensions import override -from ._base import Backend +from noneprompt import InputPrompt, CancelledError + +from operagents.log import logger +from operagents.exception import OperaFinished + +from ._base import Backend, Message class UserBackend(Backend): type_ = "user" @override - async def generate(self, messages: list) -> str: - return input("You: ") + async def generate(self, messages: list[Message]) -> str: + try: + return await InputPrompt("You: ").prompt_async() + except CancelledError: + await logger.ainfo("User cancelled input.") + raise OperaFinished() from None diff --git a/operagents/cli/__init__.py b/operagents/cli/__init__.py index ab2d923..76ac0bf 100644 --- a/operagents/cli/__init__.py +++ b/operagents/cli/__init__.py @@ -4,6 +4,7 @@ import yaml +from operagents.log import logger from operagents.opera import Opera from operagents.config import OperagentsConfig @@ -12,11 +13,17 @@ subcommands = parser.add_subparsers() -def handle_run(config: str): - opera = Opera.from_config( - OperagentsConfig.model_validate(yaml.safe_load(Path(config).read_text())) - ) - asyncio.run(opera.run()) +async def handle_run(config: str): + await logger.ainfo("Loading opera config...", path=config) + try: + opera = Opera.from_config( + OperagentsConfig.model_validate(yaml.safe_load(Path(config).read_text())) + ) + except Exception: + await logger.aexception("Failed to load opera config.", path=config) + return + + await opera.run() run = subcommands.add_parser("run", help="Run the opera.") @@ -28,4 +35,4 @@ def main(): args = parser.parse_args() args = vars(args) handler = args.pop("handler") - handler(**args) + asyncio.run(handler(**args)) diff --git a/operagents/config/__init__.py b/operagents/config/__init__.py index 4c7a35f..8f0b588 100644 --- a/operagents/config/__init__.py +++ b/operagents/config/__init__.py @@ -9,6 +9,7 @@ class OpenaiBackendConfig(BaseModel): type_: Literal["openai"] = Field(alias="type") model: str + temperature: float | None = None class UserBackendConfig(BaseModel): diff --git a/operagents/config/operagents.schema.json b/operagents/config/operagents.schema.json deleted file mode 100644 index 3f46848..0000000 --- a/operagents/config/operagents.schema.json +++ /dev/null @@ -1 +0,0 @@ -{"$defs": {"AgentConfig": {"properties": {"backend": {"discriminator": {"mapping": {"custom": "#/$defs/CustomBackendConfig", "openai": "#/$defs/OpenaiBackendConfig", "user": "#/$defs/UserBackendConfig"}, "propertyName": "type"}, "oneOf": [{"$ref": "#/$defs/OpenaiBackendConfig"}, {"$ref": "#/$defs/UserBackendConfig"}, {"$ref": "#/$defs/CustomBackendConfig"}], "title": "Backend"}, "system_template": {"anyOf": [{"type": "string"}, {"$ref": "#/$defs/CustomTemplateConfig"}], "title": "System Template"}, "user_template": {"anyOf": [{"type": "string"}, {"$ref": "#/$defs/CustomTemplateConfig"}], "title": "User Template"}}, "required": ["backend", "system_template", "user_template"], "title": "AgentConfig", "type": "object"}, "CharacterConfig": {"properties": {"description": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Description"}, "agent_name": {"title": "Agent Name", "type": "string"}, "props": {"items": {"discriminator": {"mapping": {"custom": "#/$defs/CustomPropConfig", "function": "#/$defs/FunctionPropConfig"}, "propertyName": "type"}, "oneOf": [{"$ref": "#/$defs/FunctionPropConfig"}, {"$ref": "#/$defs/CustomPropConfig"}]}, "title": "Props", "type": "array"}}, "required": ["agent_name"], "title": "CharacterConfig", "type": "object"}, "CustomBackendConfig": {"additionalProperties": true, "properties": {"type": {"const": "custom", "title": "Type"}, "path": {"title": "Path", "type": "string"}}, "required": ["type", "path"], "title": "CustomBackendConfig", "type": "object"}, "CustomFlowConfig": {"additionalProperties": true, "properties": {"type": {"const": "custom", "title": "Type"}, "path": {"title": "Path", "type": "string"}}, "required": ["type", "path"], "title": "CustomFlowConfig", "type": "object"}, "CustomPropConfig": {"additionalProperties": true, "properties": {"type": {"const": "custom", "title": "Type"}, "path": {"title": "Path", "type": "string"}}, "required": ["type", "path"], "title": "CustomPropConfig", "type": "object"}, "CustomTemplateConfig": {"properties": {"content": {"title": "Content", "type": "string"}, "custom_functions": {"additionalProperties": {"type": "string"}, "title": "Custom Functions", "type": "object"}}, "required": ["content"], "title": "CustomTemplateConfig", "type": "object"}, "FunctionPropConfig": {"properties": {"type": {"const": "function", "title": "Type"}, "function": {"title": "Function", "type": "string"}}, "required": ["type", "function"], "title": "FunctionPropConfig", "type": "object"}, "OpenaiBackendConfig": {"properties": {"type": {"const": "openai", "title": "Type"}, "model": {"title": "Model", "type": "string"}}, "required": ["type", "model"], "title": "OpenaiBackendConfig", "type": "object"}, "OrderFlowConfig": {"properties": {"type": {"const": "order", "title": "Type"}}, "required": ["type"], "title": "OrderFlowConfig", "type": "object"}, "SceneConfig": {"properties": {"description": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null, "title": "Description"}, "characters": {"additionalProperties": {"$ref": "#/$defs/CharacterConfig"}, "title": "Characters", "type": "object"}, "flow": {"discriminator": {"mapping": {"custom": "#/$defs/CustomFlowConfig", "order": "#/$defs/OrderFlowConfig"}, "propertyName": "type"}, "oneOf": [{"$ref": "#/$defs/OrderFlowConfig"}, {"$ref": "#/$defs/CustomFlowConfig"}], "title": "Flow"}}, "required": ["characters", "flow"], "title": "SceneConfig", "type": "object"}, "UserBackendConfig": {"properties": {"type": {"const": "user", "title": "Type"}}, "required": ["type"], "title": "UserBackendConfig", "type": "object"}}, "properties": {"agents": {"additionalProperties": {"$ref": "#/$defs/AgentConfig"}, "title": "Agents", "type": "object"}, "scenes": {"additionalProperties": {"$ref": "#/$defs/SceneConfig"}, "title": "Scenes", "type": "object"}, "opening_scene": {"title": "Opening Scene", "type": "string"}}, "required": ["agents", "scenes", "opening_scene"], "title": "OperagentsConfig", "type": "object"} \ No newline at end of file diff --git a/operagents/opera/__init__.py b/operagents/opera/__init__.py index 7620b96..8d77fa0 100644 --- a/operagents/opera/__init__.py +++ b/operagents/opera/__init__.py @@ -33,11 +33,11 @@ def from_config(cls, config: OperagentsConfig) -> Self: ) async def run(self): - await logger.adebug("Starting opera...") + await logger.ainfo("Starting opera...") async with self.timeline: while True: try: await self.timeline.next_time() except OperaFinished: break - await logger.debug("Opera finished.") + await logger.ainfo("Opera finished.") diff --git a/operagents/timeline/__init__.py b/operagents/timeline/__init__.py index f1bf084..95dd1a9 100644 --- a/operagents/timeline/__init__.py +++ b/operagents/timeline/__init__.py @@ -1,3 +1,4 @@ +import asyncio import weakref from types import TracebackType from typing import TYPE_CHECKING @@ -83,12 +84,16 @@ async def next_scene(self) -> "Scene": async def next_time(self) -> None: """Go to the next character or scene.""" + await logger.adebug( + f"Current character {self.current_character.name} starts to act." + ) await self.character_act() if await self.scene_finished(): self._current_scene = await self.next_scene() self._current_character = await self.begin_character() else: self._current_character = await self.next_character() + await logger.adebug(f"Next character {self.current_character.name}.") async def __aenter__(self) -> "Timeline": self._events = [] @@ -106,7 +111,7 @@ async def __aexit__( exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: - await logger.adebug("Timeline ends.") + await asyncio.shield(asyncio.create_task(logger.adebug("Timeline ends."))) self._events = None self._current_scene = None self._current_character = None diff --git a/poetry.lock b/poetry.lock index 4e85201..89545f2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -888,4 +888,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "a457ef82c030721c093f64b4cac41b22a798fc04c74a6dc20e32c1db11ab8106" +content-hash = "eebb025e339cdb260d46bdfb48478c77d63cccf6a4f90bc09042d85576e10136" diff --git a/pyproject.toml b/pyproject.toml index 07e5405..3c44bf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,11 +10,12 @@ readme = "README.md" python = "^3.10" rich = "^13.7.1" pyyaml = "^6.0.1" +jinja2 = "^3.1.3" openai = "^1.13.3" pydantic = "^2.6.3" +noneprompt = "^0.1.9" structlog = "^24.1.0" typing-extensions = "^4.10.0" -jinja2 = "^3.1.3" [tool.poetry.group.dev.dependencies] ruff = "^0.3.0"